Compare commits

..

190 Commits

Author SHA1 Message Date
you fb70b42425 perf: add TTL cache layer + rewrite bulk-health to single-query
- Add TTLCache class with hit/miss tracking
- Cache all expensive endpoints:
  - analytics/* endpoints: 60s TTL
  - channels: 30s TTL
  - channels/:hash/messages: 15s TTL
  - nodes/:pubkey: 30s TTL
  - nodes/:pubkey/health: 30s TTL
  - observers: 30s TTL
  - bulk-health: 60s TTL
- Invalidate all caches on new packet ingestion (POST + MQTT)
- Rewrite bulk-health from N×5 queries to 1 query + JS matching
- Add cache stats (size, hits, misses, hitRate) to /api/perf
2026-03-20 02:06:23 +00:00
you e7651549ea feat: add frontend API response caching with TTL, in-flight dedup, and WebSocket invalidation
- Replace api() with caching version supporting TTL and request deduplication
- Add appropriate TTLs to all api() call sites across all frontend JS files:
  - /stats: 5s TTL (was called 962 times in 3 min)
  - /nodes/:pubkey: 15s, /health: 30s, /observers: 30s
  - /channels: 15s, messages: 10s
  - /analytics/*: 60s, /bulk-health: 60s, /network-status: 60s
  - /nodes?*: 10s
- Skip caching for real-time endpoints (/packets, /resolve-hops, /perf)
- Invalidate /stats, /nodes, /channels caches on WebSocket messages
- Deduplicate in-flight requests (same path returns same promise)
- Add cache hit rate to window.apiPerf() console debugging
- Update all cache busters in index.html
2026-03-20 02:03:25 +00:00
you 6bf5beafcb merge: performance instrumentation 2026-03-20 01:49:19 +00:00
you 36bf6eac82 feat: performance instrumentation — server timing middleware, client API tracking, /api/perf endpoint, #/perf dashboard 2026-03-20 01:34:25 +00:00
you af94065399 release: v2.0.1 — mobile packets UX 2026-03-20 01:19:19 +00:00
you 95db662c5a merge: mobile packets improvements (v2.0.1) 2026-03-20 01:19:19 +00:00
you 5bca618b95 mobile: hide hash column by default 2026-03-20 01:15:49 +00:00
you 8128f86d9d 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 10c1dad703 readme: GIF under Live Trace Map, iOS screenshot under Mobile Ready section 2026-03-20 01:09:48 +00:00
you 59ffac3348 readme: lead with GIF, resize iOS screenshot to 400px 2026-03-20 01:05:17 +00:00
you 989d5e4f3c add screenshots for README 2026-03-20 01:02:38 +00:00
you b0b4d5955a add CHANGELOG.md 2026-03-20 01:01:40 +00:00
you bd11a9c3cd release: v2.0.0 — analytics, live VCR, mobile, accessibility, 100+ fixes 2026-03-20 01:00:16 +00:00
you ec16c9faea fix: mobile VCR bar bottom padding with safe-area + 20px fallback 2026-03-20 00:34:16 +00:00
you 4e1616fb61 fix: bump feed/legend safe-area offsets to account for two-row VCR on mobile 2026-03-20 00:33:24 +00:00
you 514ee825dd 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 7a0eaf9727 fix: VCR bar respects iOS safe area inset (home indicator) 2026-03-20 00:22:00 +00:00
you bd9831be9a Revert "release: v2.0.0 — analytics, mobile redesign, accessibility, 100+ fixes"
This reverts commit 0c52ecfcdb.
2026-03-20 00:05:51 +00:00
you 0c52ecfcdb release: v2.0.0 — analytics, mobile redesign, accessibility, 100+ fixes 2026-03-20 00:04:30 +00:00
you 75bf3bce51 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 52dde28a70 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 a13608fcef mobile: hide feed+legend, show LCD, fix rotation with visualViewport + forced height recalc 2026-03-19 23:47:43 +00:00
you 8fb476a0d6 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 265283448f mobile live: hide feed+legend, keep LCD visible 2026-03-19 23:41:42 +00:00
you d64778326f fix: remove stray CSS fragment corrupting live.css 2026-03-19 23:40:46 +00:00
you 61495e0caf fix: live map tiles swap instantly on theme toggle via MutationObserver 2026-03-19 23:40:09 +00:00
you 4a19c7381e fix: legend bottom 12px→58px to clear VCR bar 2026-03-19 23:38:33 +00:00
you ddcb9e7f1d 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 c277bb36e4 fix: VCR bar properly sized — bigger buttons, taller timeline (28px), comfortable padding 2026-03-19 23:32:52 +00:00
you ecf1b03421 fix: VCR bar much thinner — 3px padding, 12px timeline, feed bottom 40px; removed dead CSS 2026-03-19 23:26:52 +00:00
you 61d6c7daa5 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 2539e4c098 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 5e315e615a feat: packets view — ★ My Nodes toggle filters to only claimed/favorited node packets 2026-03-19 23:09:44 +00:00
you 56e717f1d9 fix: claimed nodes always fetched even if not in current page; auto-sync claimed→favorites 2026-03-19 23:07:49 +00:00
you 1aaf44b2af 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 62349af52d feat: claimed nodes get visual distinction — blue tint, left accent border, ★ badge 2026-03-19 23:04:41 +00:00
you 9e4c282f29 fix: node analytics — compact layout, add descriptions to every stat card and chart 2026-03-19 23:01:16 +00:00
you b9782bdd0b fix: network status computed server-side across ALL nodes, not just top 50 2026-03-19 22:57:19 +00:00
you 75d3131dfd fix: move bulk-health route before ALL :pubkey wildcards (not just /health) 2026-03-19 22:54:26 +00:00
you dc62339cbf fix: live page respects dark/light theme — replace hardcoded colors with CSS variables 2026-03-19 22:52:18 +00:00
you 8897057aa8 fix: move bulk-health route before :pubkey wildcard — Express route ordering 2026-03-19 22:49:24 +00:00
you fcb4a80801 perf: bulk health endpoint — single API call replaces 50 individual health requests for Nodes tab 2026-03-19 22:46:24 +00:00
you 035b4beb20 fix: hash matrix — free prefixes show actual hex code (e.g. 'A7') instead of dots 2026-03-19 22:45:14 +00:00
you 2751b04436 fix: nodes tab — unwrap {nodes} from API response 2026-03-19 22:44:00 +00:00
you b4ea97362e fix: hash matrix color scheme — empty=subtle, 1=light green, 2+=orange→red progression 2026-03-19 22:43:21 +00:00
you fdf5555eb6 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 798f96f97d 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 0ee2830f59 fix: claimed (My Mesh) nodes sort to top, then favorites, then rest 2026-03-19 22:36:33 +00:00
you 341dd1fec3 fix: analytics page scroll — add overflow-y:auto to wrapper 2026-03-19 22:35:50 +00:00
you 134c6a989a fix: favorited (claimed) nodes always sort to top of nodes list 2026-03-19 22:34:50 +00:00
you 95929258b1 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 55f49eebe6 fix: merge Recent Adverts + Recent Activity into single Recent Packets section 2026-03-19 22:21:54 +00:00
you 25b31117ff feat: richer node detail — status badge, avg SNR/hops, observer breakdown table, totalPackets 2026-03-19 22:17:00 +00:00
you a7035be2a8 fix: add QR code to full-screen node detail view, bump cache buster 2026-03-19 22:11:04 +00:00
you 2c8b9d53a5 fix: restore proper advert-entry markup, bump cache buster 2026-03-19 22:08:43 +00:00
you 40eb3b9558 fix: add cache busters to all JS and CSS files 2026-03-19 22:07:33 +00:00
you 686387649e debug: Recent Adverts sidebar — plain HTML, no CSS classes, hardcoded colors, to find rendering issue 2026-03-19 22:07:03 +00:00
you 492204cc03 fix: Recent Adverts shows packet type + observer + explicit text color, handles missing timestamp 2026-03-19 21:57:25 +00:00
you 94c5ae0bee fix: role-aware status thresholds — repeaters/rooms 24h/72h, companions/sensors 1h/24h 2026-03-19 21:54:02 +00:00
you 5790db4859 fix: always show QR code in node detail, add Recent Adverts section to sidebar detail 2026-03-19 21:53:22 +00:00
you babae62f94 fix: map markers use distinct shapes (diamond/circle/square/triangle) + high-contrast colors for accessibility 2026-03-19 21:49:40 +00:00
you 1cbb5f3525 fix: remove dead Regions column from nodes table (closes #26) 2026-03-19 21:48:32 +00:00
you 873c457fa7 fix: column resize steals from ALL right columns proportionally, wider grab handle, 50px min 2026-03-19 21:46:02 +00:00
you e417ca9471 fix: restore geographic prefix disambiguation for route overlay 2026-03-19 21:41:58 +00:00
you 0ac5d4d2c7 fix: don't fitBounds on initial load — respect Bay Area default center 2026-03-19 21:39:38 +00:00
you 3ffcf9f3b8 fix: VCR replay paginates — fetches next 10k when buffer exhausted instead of jumping to live 2026-03-19 21:33:55 +00:00
you d0fa45a365 Replace hardcoded section-row background with CSS variable for dark mode
closes #32
2026-03-19 21:32:58 +00:00
you 9c236a27fe Add empty/error states to data tables with aria-live for accessibility
closes #31
2026-03-19 21:32:54 +00:00
you 9712ae4845 Add SRI integrity hashes to Leaflet CDN scripts
closes #30
2026-03-19 21:32:48 +00:00
you 4a0c5cf302 Remove dead code: svgLine(), .vcr-clock, .vcr-lcd-time display:none rules
closes #29
2026-03-19 21:32:43 +00:00
you 38fa35f385 Remove duplicate escapeHtml and debounce functions, keep globals in app.js
closes #28
2026-03-19 21:32:39 +00:00
you 4d2428b144 fix: VCR scrub fetches ASC from scrub point — prevents jumping forward when >10k packets exist 2026-03-19 21:32:26 +00:00
you 10bb9b191f fix: restore vcrReplayFromTs fetch limit to 10000 — 2000 caused rubber banding on scrub 2026-03-19 21:30:01 +00:00
you 5d086bc4f9 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 da5a227c71 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 3bd2bf648f 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 ca2d5cedeb fix: chat message bubble max-width constraint
closes #21
2026-03-19 21:11:28 +00:00
you e8ab1b2de3 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 0558efafff fix: observers table horizontal scroll wrapper on mobile
closes #20
2026-03-19 21:11:04 +00:00
you 4052b1e014 fix: hash matrix mobile overflow and scatter plot color-blind accessibility
closes #17
closes #24
2026-03-19 21:11:04 +00:00
you c7601f1479 fix: Excel-like column resize — drag steals from neighbor, percentages persist, panel drag reflows proportionally 2026-03-19 21:04:15 +00:00
you b1baad00e5 fix: explicitly set left panel + table width during drag for live column reflow 2026-03-19 21:01:01 +00:00
you 2c6f907452 fix: force table reflow during detail pane drag resize 2026-03-19 20:59:50 +00:00
you 0dadd648b7 fix: use max-width:0 on td so table compresses/expands with detail pane resize 2026-03-19 20:58:14 +00:00
you e346303c02 Revert "fix: packets table uses table-layout:fixed with proportional column widths — resizes like Excel when detail pane is dragged"
This reverts commit f809b06b98.
2026-03-19 20:57:08 +00:00
you f809b06b98 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 7551e1169f fix: restore max-width on td, give Details column more room (fixes #72 regression) 2026-03-19 20:53:40 +00:00
you a39fa15d99 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 3f39185e7d 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 de18a0ea62 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 b719413d7e 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 afbaacaa7b 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 a745f6cee0 fix: ARIA tab pattern, form labels, focus management (closes #10, #13, #14) 2026-03-19 19:00:43 +00:00
you f1a8cb5905 fix: SVG alt text, hash matrix color-blind, observer health shapes (closes #12, #22, #23) 2026-03-19 18:58:57 +00:00
you afe0ab72e8 fix: packets mobile columns, BYOP dialog a11y, filter combobox ARIA (closes #18, #65, #66) 2026-03-19 18:58:32 +00:00
you f5c3c8d32a fix: channels sender keyboard access, node panel focus trap (closes #82, #83) 2026-03-19 18:57:55 +00:00
you 32e697e3fe fix: live page mobile VCR, LCD aria, feed keyboard (closes #15, #55, #56) 2026-03-19 18:57:21 +00:00
you 6ad5868897 fix: WS debounce helper, clean up remaining window globals (closes #7, #8) 2026-03-19 16:51:34 +00:00
you 4041031675 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 6c549860ad fix: home.js listener stacking, packets.js filter bar rebuild (closes #4, #6) 2026-03-19 16:46:41 +00:00
you f7d4d2a6b7 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 c86af95d44 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
you 9952abe0a7 Fix hash matrix colors: green=free, yellow=taken, red=collision 2026-03-19 09:12:33 +00:00
you c6d5c1c70e Analytics: widen to 1600px, side-by-side card layout
- max-width 900px → 1600px for analytics page
- Added analytics-grid CSS class for auto-fit columns
- Hash Stats: distribution + timeline side-by-side, adopters + top hops side-by-side
- RF, Topology, Channels already used analytics-row flex layout
- Cards now fill available screen width instead of being squished
2026-03-19 09:04:54 +00:00
you 14f1a14d0a Fix hash matrix: green=available, blue=1 node, yellow-red=collision 2026-03-19 09:02:38 +00:00
you 46f7ec507d Hash matrix: show prefix labels, color by collision risk not traffic
Each cell displays the hex prefix (AB, C3, etc). Color meaning:
- Dark: unused prefix (no known nodes)
- Green: 1 node using it (safe, no collision)
- Yellow→Red: 2+ nodes sharing prefix (collision risk)
Legend added below matrix.
2026-03-19 09:01:08 +00:00
you c909672641 Fix route pattern lag: prefix index + disambiguation cache
Built O(1) prefix index on allNodes (replaces O(n) filter per hop).
Cache disambiguation results by hop sequence key — same path only
resolved once. Subpaths endpoint: ~1s for 19K packets (was 30s+).
2026-03-19 08:58:36 +00:00
you aabc04fcb1 Route patterns: use sequential hop disambiguation
Replace naive first-match resolution in /api/analytics/subpaths and
/api/analytics/subpath-detail with shared disambiguateHops() function.
Each path is now resolved with forward+backward nearest-neighbor pass
and distance sanity check, matching live map and resolve-hops logic.
2026-03-19 08:54:31 +00:00
you e7ee705744 Split Hash Collisions into own tab, add hop distances to route patterns
- Hash Stats tab: general hash size distribution, top hops, timeline
- Hash Collisions tab: 16x16 usage matrix + collision risk analysis
- Route Patterns detail: inter-hop distances with color coding
  (green <50km, amber 50-200km, red >200km) and total path distance
2026-03-19 08:48:55 +00:00
you a2aa357502 Hash matrix: click cell to show nodes using that prefix
Clicking an active cell shows a detail panel with linked node names,
coordinates, and roles. Matrix + detail panel in flex layout.
Hover highlights, selected cell gets accent outline + glow.
2026-03-19 08:47:08 +00:00
you 72d0a65657 Add 1-byte hash usage matrix (16x16 heatmap)
Rows = high nibble, columns = low nibble. Color intensity shows
packet count (log scale). Hover for exact count. Placed above
collision risk table in Repeater Hashes tab.
2026-03-19 08:44:45 +00:00
you b7a8b5180a Rename Hash Sizes → Repeater Hashes, enrich collisions with distance
1-byte collision table now shows:
- Max pairwise distance between colliding nodes
- Classification: Local (<50km, true collision), Regional (50-200km,
  possible atmospheric), Distant (>200km, internet bridge/MQTT/separate mesh)
- Coordinates for each node
- Legend explaining each classification
- Sorted: distant first (most interesting)
2026-03-19 08:43:26 +00:00
you 08aba7acba Increase live map node limit from 500 to 2000
500 limit was cutting off nodes, causing single-candidate false
matches (e.g. Osprey-Base in Seattle winning for prefix 60 when
little russia in SF was beyond the limit). With ~400 nodes having
coords, 2000 ensures all are loaded.
2026-03-19 08:38:24 +00:00
you 303467f2b9 Drop impossibly distant hops as prefix collisions
After sequential disambiguation, sanity-check each hop: if it's
>200km (~1.8°) from both neighbors, it's almost certainly a 1-byte
prefix collision with a distant node, not an actual hop. Mark as
unreliable (server) or drop from animation (live map).

Fixes packet 19384 bouncing to Seattle — Spiden Repeater shares
prefix 1D with an unknown local node.
2026-03-19 08:34:53 +00:00
you 30bc4c990e Live map: sequential hop disambiguation matching server logic
Forward + backward pass resolving each ambiguous hop by distance
to previous/next known hop, instead of center-of-path averaging.
2026-03-19 08:29:31 +00:00
you 970156761a Sequential hop disambiguation: resolve each hop by distance to previous
Instead of averaging all hops to a center point, walk the path
sequentially — each ambiguous hop picks the candidate closest to
the previously resolved hop. Forward pass seeds from first known
position, backward pass seeds from observer. This matches physical
reality: packets travel hop-by-hop in RF range.
2026-03-19 08:28:07 +00:00
you b684be1466 Fix observer location for hop disambiguation
- ADVERT is payload_type 4 not 1
- Frontend sends observer_id (not just observer_name) since some
  observers have null name
- Observer position derived from geographic center of nodes it
  commonly receives ADVERTs from
- Last hop in path disambiguated by proximity to observer position
2026-03-19 08:26:37 +00:00
you 677775a08a Fix route map: bidirectional prefix match for full pubkeys
Packets page now passes server-disambiguated full pubkeys.
Map needs bidirectional prefix match since DB nodes may have
truncated (8-char) or full (64-char) keys. Removes redundant
client-side geographic disambiguation since packets page already
resolved via /api/resolve-hops.
2026-03-19 08:20:19 +00:00
you d48eb5f8e3 Fix hop disambiguation: last hop prefers observer proximity
- Observer name resolved to lat/lon for geographic context
- Last hop in path sorted by distance to observer (not center)
  since it's the node that delivered the packet to the observer
- Packets page now passes observer_name to /api/resolve-hops
- Fixes CroatR1 being picked over Kpa Roof Solar for hop 8A
2026-03-19 08:18:26 +00:00
you 646e9dde8c Pass server-disambiguated full pubkeys to map route view
Was fetching /api/resolve-hops (with geographic disambiguation) then
throwing away the result and still passing raw 1-byte prefixes. Now
passes full pubkeys so the map doesn't re-disambiguate differently.
2026-03-19 08:15:39 +00:00
you debc2970f1 Use longest path sibling for grouped packets
When the same packet is received multiple times (different hop counts
as it propagates), use the reception with the most hops for display.
Fixes routes showing incomplete paths when the first reception had
fewer hops than later ones.

Applies to both grouped query and individual packet detail endpoint.
2026-03-19 08:14:27 +00:00
you 2faf258e17 Clickable route hop markers with popup details + node link
Each hop marker shows: label (Origin/Hop N/Destination), node name,
role, pubkey, coordinates, and 'View Node →' link to node detail.
2026-03-19 08:07:55 +00:00
you 1d4312d010 Geographic prefix disambiguation for route overlay on map page 2026-03-19 08:05:55 +00:00
you a1427a3a52 Fix duplicate knownPositions declaration breaking live page 2026-03-19 08:03:59 +00:00
you 0142912a96 Geographic prefix collision disambiguation on live page
When a 1-byte hop prefix matches multiple nodes, pick the one
geographically closest to the center of other known hops in the
path — same logic as /api/resolve-hops but client-side.

Previously just took the first match, which could be a node in
Chicago matching a local Bay Area hop prefix.
2026-03-19 08:02:07 +00:00
you 2a8ff33924 Fix heatmap remnants: clear nodesLayer and animLayer on scrub
Previous fix only removed markers tracked in nodeMarkers object,
missing glow circles and other elements added directly to nodesLayer.
2026-03-19 07:57:08 +00:00
you 647bf0be0f Scrub replay fetches ALL packets from scrub point to now
No more 5-minute window or 200-packet limit. Fetches up to 10K
packets from the scrub point forward and replays them all.
When replay finishes, resumes live.
2026-03-19 07:56:12 +00:00
you 76a0dd431e Fix: clear nodeActivity+heatmap on scrub; hold playhead after replay ends
- nodeActivity cleared in clearNodeMarkers (removes ghost heatmap)
- When replay finishes, set scrubTs to last packet's timestamp so
  updateTimelinePlayhead holds position instead of snapping right
2026-03-19 07:55:07 +00:00
you 350ccb6d86 Clear heatmap layer when clearing nodes for replay 2026-03-19 07:51:42 +00:00
you 04f891132a ADVERT packets during replay add nodes to map; simplify unpause
- animatePacket checks for ADVERT type and adds new nodes to map
  with marker if they have location data
- Unpause always resumes to live (removed missed packets prompt)
2026-03-19 07:51:02 +00:00
you f698ca05c1 Time-travel map: filter nodes by replay timestamp
When scrubbing to a past time, only nodes that had advertised before
that time appear on the map. Nodes that hadn't been seen yet are
hidden.

- Added 'before' param to /api/nodes (filters first_seen <= before)
- loadNodes(beforeTs) accepts optional timestamp filter
- clearNodeMarkers() removes all markers and data
- vcrReplayFromTs clears and reloads nodes for replay time
- vcrResumeLive reloads all nodes (no filter)
2026-03-19 07:50:17 +00:00
you 2790351e26 Real 7-segment LCD digits, live clock tick, remove confusing counter
- Canvas-based 7-segment digit renderer with ghost segments (dim
  background showing all segments like a real LCD)
- Clock ticks every second in LIVE mode
- Removed packet counter (1/182) from scrub/replay — only shows
  +N PKTS when paused with missed packets
2026-03-19 07:45:17 +00:00
you 1c6f8271bd Add vintage LCD panel to VCR bar
Dark green-on-black LCD display on right side of VCR bar showing:
- Mode: LIVE / PAUSE / PLAY 2x (green)
- Time: HH:MM:SS with glow (green)
- Packet counter: +N PKTS when paused, N/total during replay (amber)

Styled with dark background, inset shadow, monospace font,
text-shadow glow — vintage VCR aesthetic.
2026-03-19 07:41:57 +00:00
you c99256412c Fix scrub replay: add 'until' filter to API, fetch correct time window
ROOT CAUSE: API query used ORDER BY timestamp DESC with no upper
bound — scrubbing to 3h ago fetched the newest 200 packets (from
now), not packets near the scrub target.

Fix:
- Added 'until' query param to /api/packets (both grouped and flat)
- vcrReplayFromTs fetches a 5-minute window around the scrub target
- Server restart required (old process was stale on port 3000)
2026-03-19 07:35:05 +00:00
you a6310a93d2 Fix scrub: auto-replay on release, replay from correct timestamp
1. scrubRelease now calls vcrReplayFromTs directly (no pause step)
2. vcrReplayFromTs builds replay buffer from ONLY fetched DB packets,
   not merged with stale WS buffer entries. Starts playhead at 0
   (first fetched packet), not closest-match in mixed buffer.
   Fixes replay starting from session start (00:06) instead of
   scrub target.
2026-03-19 07:25:32 +00:00
you e14bd8f53d Fix replay after scrub: fetch from DB and play from scrub point
Scrub + release now pauses (no fetch). Hitting play detects
VCR.scrubTs and fetches packets from DB around that timestamp,
then replays 50 packets from the closest match.
2026-03-19 07:22:51 +00:00
you f3fc2d4c11 Rewrite scrubber: simple pause on release, no async fetch
Previous approach tried to fetch packets from DB on scrub release,
causing race conditions, rubber-banding, and stale state. Replaced
with dead-simple approach:
- Drag: moves playhead visually + updates clock
- Release: pauses at that position (VCR.scrubTs holds timestamp)
- No async fetch, no replay on release
- updateTimelinePlayhead uses scrubTs to hold position
- Click LIVE to resume
2026-03-19 07:21:00 +00:00
you e19f2cd6d1 Add VCR digital clock display
Red monospace clock next to LIVE/mode indicator shows current
playhead time. Updates during drag, replay ticks, and on mode
changes. Retro VCR aesthetic with text-shadow glow.
2026-03-19 07:18:14 +00:00
you eaef2b6e4c Fix scrubber snap-back on release: keep dragging flag during fetch
VCR.dragging=false was set on mouseup before the async DB fetch
completed. During the fetch (~100-200ms), updateTimelinePlayhead
saw dragging=false, playhead=-1, fell to else→x=cw (right snap).

Fix: keep dragging=true until fetch resolves and replay starts.
2026-03-19 07:16:10 +00:00
you 0df8c85638 Fix VCR scrubber: remove CSS transition causing drag lag
The playhead had 'transition: left 0.3s ease' which made it animate
300ms behind the mouse during drag (felt stuck) and ease to an offset
position on release (felt like rubber-banding). Removed.
2026-03-19 07:14:30 +00:00
you b07c5b0b86 Fix VCR scrubber: freeze timeline reference during replay
THE root cause: timeline coordinate system uses Date.now() as right
edge, making it a sliding window. Playhead position = (packetTs -
(now - scope)) / scope — but 'now' advances every frame, sliding
all positions left continuously. Any scrub position drifts.

Fix: VCR.frozenNow captures Date.now() when leaving LIVE mode.
All timeline calculations use frozenNow instead of Date.now() during
REPLAY/PAUSED. Timeline stops sliding. Playhead stays put.
Cleared on return to LIVE.
2026-03-19 07:10:40 +00:00
you 73402e9b0b Fix VCR scrubber rubber-band: hold dragPct during async fetch
Root cause traced: mouseup sets VCR.dragging=false and starts async
fetch. Between fetch start and response (~50-200ms), the 30s interval
fires updateTimelinePlayhead() which found no matching branch for
REPLAY+old playhead, defaulting to x=cw (right edge snap).

Fix: dragPct now takes priority over buffer-based position during
REPLAY/PAUSED modes. Cleared only when fetch completes and replay
actually starts with real buffer data.
2026-03-19 07:07:26 +00:00
you 9506fa57b5 Fix Recent Activity on nodes page: remove extra closing div
An extra </div> was closing the node-detail wrapper before the
Recent Activity section rendered, causing content to fall outside
the styled container.
2026-03-19 07:06:01 +00:00
you 92d66ea4d1 Fix VCR scrubber: limit replay to 50 packets from scrub point
Root cause: scrub replay was playing through the ENTIRE buffer
from scrub point to end (thousands of packets), racing the playhead
forward to now. Now caps replay at 50 packets from scrub point,
then pauses. Hit LIVE to resume real-time.
2026-03-19 07:02:42 +00:00
you 43bd3d07c2 Fix VCR scrubber: pause after replay ends, hold playhead position
Two root causes fixed:
1. startReplay() was calling vcrResumeLive() when buffer exhausted,
   snapping back to LIVE mode. Now pauses instead.
2. updateTimelinePlayhead() recalculated position from timestamps
   relative to now (which shifts). Now holds at dragPct position
   when paused after a scrub.
2026-03-19 07:01:01 +00:00
you 8da7603d0c Fix VCR scrubber: pure visual drag + DB fetch on release
Root cause: scrubbing moved playhead to closest buffer entry (recent
WS packets only), then replay tick() recalculated position relative
to current time, snapping it back. Fixed by splitting into two phases:
1. scrubVisual: only moves the DOM playhead element during drag
2. scrubCommit: on release, fetches packets from DB around target
   timestamp, merges into buffer, seeks to closest entry, starts replay.
No more rubber-banding.
2026-03-19 06:58:42 +00:00
you 5ee08f1fa5 Fix VCR scrubber rubber-band, compact replay button, fix channel links
VCR: playhead now stays where you drag it — updateTimelinePlayhead()
skips during drag (VCR.dragging flag). Always update playhead visually
via direct DOM during scrub instead of relying on buffer position calc.

Packets: replay button compact, inline next to View Route button.
Analytics: channel links now navigate to specific channel hash.
2026-03-19 06:49:11 +00:00
you d36c206a8e Fix single packet replay: pause VCR to suppress live packets 2026-03-19 06:40:25 +00:00
you 511b1f2915 Move replay button next to View Route in packet detail 2026-03-19 06:38:54 +00:00
you 5178963644 Single packet replay: skip replayRecent when navigating from packets page
Only animates the selected packet instead of loading 8 recent ones.
2026-03-19 06:36:57 +00:00
you fa4e1d7a6f Map: default to Bay Area, save/restore user position+zoom
Defaults to [37.6, -122.1] zoom 9. Saves position to localStorage
on moveend. Restores on page load. Skips fitBounds when user has
saved position.
2026-03-19 06:33:50 +00:00
you 8513eb4f45 Fix timeline scrubbing: handle empty buffer, fetch from DB for historical times
Click/drag on timeline now works even when buffer is empty.
Dragging to a point before the buffer's start triggers DB fetch
on release. Removed redundant click handler (drag handles clicks).
Visual playhead feedback during drag even without buffer data.
2026-03-19 06:32:31 +00:00
you 08c3a7b0a9 Fix Discord embed: serve OG image from GitHub (bypass geo-block) 2026-03-19 06:25:03 +00:00
you f9de299455 Add drag scrubbing to VCR timeline
Mouse drag and touch drag support on the timeline sparkline.
Scrubs playhead in real-time as you drag. Touch support for mobile.
Grab cursor on hover, grabbing while dragging.
2026-03-19 06:24:11 +00:00
you 98e95ebd9b Fix node claiming: grid not updating after first claim
myNodesGrid element was only rendered when hasNodes was true on
initial page load. Claiming first node called loadMyNodes() but
the grid container didn't exist. Now always render the grid div.
Also dynamically update hero text and hide onboarding prompt.
2026-03-19 06:22:42 +00:00
you dc98f7cf42 Fix timeline scrubber: fetch historical timestamps from DB
Timeline sparkline was only showing packets from the current browser
session (WS buffer). Now fetches timestamps from DB via lightweight
/api/packets/timestamps endpoint, so 6h/12h/24h scopes actually
show historical activity density.
2026-03-19 06:19:35 +00:00
you be1c1ea892 Fix OG image: crop to 1200x630 banner ratio 2026-03-19 06:03:18 +00:00
you 76b2e34368 Add OG meta tags and embed image for Discord/social sharing 2026-03-19 05:56:09 +00:00
you fb524f6d74 Fix channel chat not showing new messages
Two bugs:
1. refreshMessages() compared array length to detect changes — at the
   200 message limit, new messages don't change the count. Now compares
   last message timestamp instead.
2. WS handler only triggered on type 'message' — observer-decoded
   GRP_TXT packets broadcast as type 'packet' were missed. Now also
   triggers refresh on packet events with GRP_TXT payload type.
2026-03-19 04:04:50 +00:00
you 847dc46e6f Improve Route Patterns layout — less squished
- Detail sidebar starts collapsed, expands on click (420px, max 50vw)
- Route column allows word wrap instead of forced nowrap
- Tables use auto layout for breathing room
- List panel has min-width:0 to prevent flex overflow
- Smoother transitions on sidebar open
2026-03-19 03:00:22 +00:00
you 31e09b86f8 Add jump nav buttons for chain lengths in Route Patterns 2026-03-19 02:56:40 +00:00
you f9dad5f533 Add toggle to hide prefix collision self-loops in route patterns
Checkbox persists to localStorage. Hides rows with self-loops
(same node repeated consecutively) which are likely 1-byte collisions.
2026-03-19 02:55:58 +00:00
you 1c36738c37 Make hop names clickable in packet byte breakdown panel
Hop labels in the field table now link to the node detail page.
2026-03-19 02:55:10 +00:00
you 27c0fa7b29 Relabel signal quality as 'Observer Receive Signal' with caveat 2026-03-19 02:54:08 +00:00
you 0146cb50d0 Add subpath detail sidebar with minimap and analytics
Click any route in Route Patterns to open detail sidebar showing:
- Minimap with nodes and route line (green=origin, red=dest, amber=hops)
- Signal quality (avg SNR/RSSI)
- Activity by hour (UTC) bar chart
- First/last seen timestamps
- Observers that captured this route
- Full parent paths containing this subpath

Split layout with scrollable list + fixed sidebar, responsive on mobile.
2026-03-19 01:27:03 +00:00
you 4ca16ef4ec Show prefix subpath on separate line below friendly names 2026-03-19 01:22:39 +00:00
you c2ebf549a1 Show raw hop prefixes alongside friendly names in route patterns
Each hop now displays as 'NodeName [ab]' with the hex prefix visible.
Makes prefix collisions obvious — e.g. same prefix resolving to same
name confirms it's a collision, different prefixes confirm real route.
2026-03-19 01:21:36 +00:00
you 8a8800fada Fix escapeHtml → esc, flag self-loop subpaths with 🔄
Self-loops (likely prefix collisions) shown at 60% opacity with amber
frequency bar and 🔄 icon with tooltip explaining the collision.
2026-03-19 01:20:23 +00:00
you 067fe3c595 Add Route Patterns subpage to analytics
New 'Route Patterns' analytics tab showing most common subpaths in the
mesh, broken down by length (pairs, triples, quads, long chains).
Reveals backbone routes, bottlenecks, and preferred relay chains.
Each subpath shows occurrence count, % of all paths, and frequency bar.
2026-03-19 01:18:20 +00:00
you 17524fa3db Geographic hop disambiguation for 1-byte path prefixes
When multiple nodes match a 1-byte hop prefix, use geographic context
(observer's typical nodes + other resolved hops) to pick the closest
match. Shows ⚠ indicator on ambiguous hops with tooltip listing all
candidates. Hover to see which nodes collide on that prefix.
2026-03-18 23:41:26 +00:00
you 72f540db86 Disable caching for JS/CSS/HTML files — force fresh loads 2026-03-18 23:36:02 +00:00
you 246f09c71e Fix channels showing oldest messages instead of latest
Messages were sliced from the start (oldest first). Now returns the
last N messages so the chat view shows the most recent conversation.
2026-03-18 23:30:13 +00:00
you 5055135eb7 Add 'View packet' link in channel chat messages
Include packetId in channel message API response, render as link
in message metadata row that navigates to #/packets/id/<id>.
2026-03-18 23:19:37 +00:00
you 88d757e27f Filter packet list by hash when navigating to specific packet
When arriving via #/packets/id/<id>, fetch the packet first, set the
hash filter, reload the list, then show the detail sidebar. List now
shows only related packets instead of the full unfiltered view.
2026-03-18 23:13:10 +00:00
you 34d10cdf8d Add Analyze links to Recent Activity in full-screen node view 2026-03-18 23:06:06 +00:00
you 0d370df94f Fix Recent Activity showing empty rows — PAYLOAD_TYPES was undefined
ReferenceError killed the entire .map() call, rendering empty div shells
with just the dot and no text. Added PAYLOAD_TYPES constant to nodes.js.
2026-03-18 23:04:01 +00:00
you a2e4221033 Fix nav bar auto-hide leaking to other pages
navTimeout was local to init() — destroy() couldn't clear it, so the
4s timeout would fire after navigation and hide the nav on other pages.
Now uses module-scoped _navCleanup; destroy clears timeout and removes
mousemove/touchstart/click listeners from live page element.
2026-03-18 22:57:15 +00:00
you 4aeeab5df7 Fix node detail Recent Activity — fetch health data for real packet list
Desktop detail panel was only showing adverts as 'Recent Activity'.
Now fetches /health endpoint too and displays all recent packets
(adverts, channel msgs, DMs) with type icons and Analyze links.
2026-03-18 22:55:22 +00:00
you cc8118b85a Fix route overlay disappearing on zoom/pan — use separate routeLayer
Route markers and polyline were on markerLayer, which gets cleared
by renderMarkers() on any filter/zoom change. Now uses dedicated
routeLayer that persists independently.
2026-03-18 22:53:02 +00:00
you d22d47aa87 Fix View Route on Map — use sessionStorage instead of URL params
Hop hashes are 1-2 byte truncated values that don't work in URL params.
Now passes raw hops via sessionStorage; map page reads them after nodes
load and resolves via prefix match against full public keys.
2026-03-18 22:43:00 +00:00
you 6b38a6a76f Fix stuck radar pings — add 2s safety timeout and try-catch cleanup 2026-03-18 22:38:12 +00:00
you 7b7400538c Clickable hop links + View Route on Map from packet detail
- Hops in packet table are now clickable links to node detail (#/nodes/<hop>)
- Packet detail panel shows 'View route on map' link for packets with hops
- Map page reads ?highlight= query param and draws dashed route polyline
- Route shows numbered markers: green=origin, amber=hops, red=destination
- Map auto-fits to route bounds
2026-03-18 22:36:43 +00:00
you acc6f9c856 Add node name filter to packets page, fix duplicate node WHERE clause
- Autocomplete dropdown searches /api/nodes/search as you type
- Selecting a node filters packets by that node's pubkey
- Fixed duplicate node filter condition in grouped packets query
2026-03-18 21:51:02 +00:00
you bbdbc297ba Preserve expanded group rows across live refresh
Re-fetch children for expanded groups after loadPackets rebuilds
the packet array, so expanded rows don't collapse on WS refresh.
2026-03-18 21:49:45 +00:00
you 8be0113eae Fix Analyze link in node detail — use #/packets/id/<id> deep link 2026-03-18 21:48:27 +00:00
you c679205c5c VCR timeline time tooltip, real packet timestamps in feed, replay-from-packets button
- Timeline scrubber shows time on hover (tooltip follows cursor)
- Feed items display actual packet timestamps, not render time
- Packet detail panel has 'Replay on Live Map' button → navigates to live view and animates the packet
2026-03-18 21:36:35 +00:00
you e9e6406463 Add config.example.json 2026-03-18 19:55:28 +00:00
you 4a17583016 Update NEW_USER_SPEC.md to reflect implemented features 2026-03-18 19:42:36 +00:00
you 8ad6913a17 Rewrite BUILD_PLAN.md to reflect all 21 completed milestones 2026-03-18 19:41:35 +00:00
you 3e93026526 Initial commit: MeshCore Analyzer
Bay Area MeshCore mesh network analyzer with:
- Live packet visualization with map, contrail animations, shockwave pulses
- VCR controls: pause/play/rewind/scrub timeline with speed control
- Packet browser with grouped view, detail panel, byte breakdown
- Channel message decryption (hashtag-derived PSKs)
- Node directory with health cards, favorites, search
- Analytics dashboard with network insights
- Observer management and BLE/companion bridge support
- Trace route visualization
- Dark theme, responsive design, accessibility
- SQLite storage, WebSocket live feed, REST API
2026-03-18 19:34:05 +00:00
90 changed files with 1547 additions and 23910 deletions
-1
View File
@@ -1 +0,0 @@
{"schemaVersion":1,"label":"backend coverage","message":"83.88%","color":"brightgreen"}
-1
View File
@@ -1 +0,0 @@
{"schemaVersion":1,"label":"backend tests","message":"701 passed","color":"brightgreen"}
-1
View File
@@ -1 +0,0 @@
{"schemaVersion":1,"label":"coverage","message":"76%","color":"yellow"}
-1
View File
@@ -1 +0,0 @@
{"schemaVersion":1,"label":"frontend coverage","message":"38.98%","color":"red"}
-1
View File
@@ -1 +0,0 @@
{"schemaVersion":1,"label":"frontend tests","message":"13 E2E passed","color":"brightgreen"}
-1
View File
@@ -1 +0,0 @@
{"schemaVersion":1,"label":"tests","message":"844/844 passed","color":"brightgreen"}
-14
View File
@@ -1,14 +0,0 @@
# Docker
.git
node_modules
data
config.json
*.db
*.db-shm
*.db-wal
*.bak
benchmark*.sh
benchmark*.js
PERFORMANCE.md
docs/
.gitignore
-145
View File
@@ -1,145 +0,0 @@
name: Deploy
on:
push:
branches: [master]
paths-ignore:
- '**.md'
- 'LICENSE'
- '.gitignore'
- 'docs/**'
concurrency:
group: deploy
cancel-in-progress: true
jobs:
test:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --production=false
- name: Detect changes
id: changes
run: |
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
FRONTEND=$(git diff --name-only HEAD~1 | grep -cE '^public/' || true)
TESTS=$(git diff --name-only HEAD~1 | grep -cE '^test-|^tools/' || true)
CI=$(git diff --name-only HEAD~1 | grep -cE '\.github/|package\.json|test-all\.sh|scripts/' || true)
# If CI/test infra changed, run everything
if [ "$CI" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
# If test files changed, run everything
if [ "$TESTS" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
echo "backend=$([[ $BACKEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
echo "frontend=$([[ $FRONTEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
- name: Backend tests + coverage
if: steps.changes.outputs.backend == 'true'
run: |
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
TOTAL_PASS=$(grep -oP '\d+(?= passed)' test-output.txt | awk '{s+=$1} END {print s}')
TOTAL_FAIL=$(grep -oP '\d+(?= failed)' test-output.txt | awk '{s+=$1} END {print s}')
BE_COVERAGE=$(grep 'Statements' test-output.txt | tail -1 | grep -oP '[\d.]+(?=%)')
mkdir -p .badges
BE_COLOR="red"
[ "$(echo "$BE_COVERAGE > 60" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="yellow"
[ "$(echo "$BE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="brightgreen"
echo "{\"schemaVersion\":1,\"label\":\"backend tests\",\"message\":\"${TOTAL_PASS} passed\",\"color\":\"brightgreen\"}" > .badges/backend-tests.json
echo "{\"schemaVersion\":1,\"label\":\"backend coverage\",\"message\":\"${BE_COVERAGE}%\",\"color\":\"${BE_COLOR}\"}" > .badges/backend-coverage.json
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
- name: Backend tests (quick — no coverage)
if: steps.changes.outputs.backend == 'false'
run: npm run test:unit
- name: Install Playwright browser
if: steps.changes.outputs.frontend == 'true'
run: npx playwright install chromium --with-deps 2>/dev/null || true
- name: Frontend coverage (instrumented Playwright)
if: steps.changes.outputs.frontend == 'true'
run: |
sh scripts/instrument-frontend.sh
COVERAGE=1 PORT=13581 node server.js &
SERVER_PID=$!
sleep 5
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
kill $SERVER_PID 2>/dev/null || true
mkdir -p .badges
if [ -f .nyc_output/frontend-coverage.json ]; then
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
FE_COVERAGE=${FE_COVERAGE:-0}
FE_COLOR="red"
[ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow"
[ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen"
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
fi
echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
- name: Frontend E2E only (no coverage)
if: steps.changes.outputs.frontend == 'false'
run: |
PORT=13581 node server.js &
SERVER_PID=$!
sleep 5
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
kill $SERVER_PID 2>/dev/null || true
- name: Publish badges
if: always()
continue-on-error: true
run: |
git config user.name "github-actions"
git config user.email "actions@github.com"
git remote set-url origin https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git
git add .badges/ -f
git diff --cached --quiet || (git commit -m "ci: update test badges [skip ci]" && git push) || echo "Badge push failed"
deploy:
needs: test
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Validate JS
run: sh scripts/validate.sh
- name: Build and deploy
run: |
set -e
docker build -t meshcore-analyzer .
docker rm -f meshcore-analyzer 2>/dev/null || true
docker run -d \
--name meshcore-analyzer \
--restart unless-stopped \
-p 80:80 -p 443:443 -p 1883:1883 \
-v $HOME/meshcore-data:/app/data \
-v $HOME/meshcore-config.json:/app/config.json:ro \
-v $HOME/caddy-data:/data/caddy \
-v $HOME/meshcore-analyzer/Caddyfile:/etc/caddy/Caddyfile \
meshcore-analyzer
echo "Deployed $(git rev-parse --short HEAD)"
-8
View File
@@ -4,11 +4,3 @@ data/
*.db
*.db-journal
config.json
data-lincomatic/
config-lincomatic.json
theme.json
firmware/
coverage/
public-instrumented/
.nyc_output/
.setup-state
-309
View File
@@ -1,309 +0,0 @@
# AGENTS.md — MeshCore Analyzer
Guide for AI agents working on this codebase. Read this before writing any code.
## Architecture
Single Node.js server + static frontend. No build step. No framework. No bundler.
```
server.js — Express API + MQTT ingestion + WebSocket broadcast
decoder.js — MeshCore packet parser (header, path, payload, adverts)
packet-store.js — In-memory packet store + query engine (backed by SQLite)
db.js — SQLite schema + prepared statements
public/ — Frontend (vanilla JS, one file per page)
app.js — SPA router, shared globals, theme loading
roles.js — ROLE_COLORS, TYPE_COLORS, health thresholds, shared helpers
nodes.js — Nodes list + side pane + full detail page
map.js — Leaflet map with markers, legend, filters
packets.js — Packets table + detail pane + hex breakdown
packet-filter.js — Wireshark-style filter engine (standalone, testable)
customize.js — Theme customizer panel (self-contained IIFE)
analytics.js — Analytics tabs (RF, topology, hash issues, etc.)
channels.js — Channel message viewer
live.js — Live packet feed + VCR mode
home.js — Home/onboarding page
hop-resolver.js — Client-side hop prefix → node name resolution
style.css — Main styles, CSS variables for theming
live.css — Live page styles
home.css — Home page styles
index.html — SPA shell, script/style tags with cache busters
```
### Data Flow
1. MQTT brokers → server.js ingests packets → decoder.js parses → packet-store.js stores in memory + SQLite
2. WebSocket broadcasts new packets to connected browsers
3. Frontend fetches via REST API, filters/sorts client-side
## Rules — Read These First
### 1. No commit without tests
Every change that touches logic MUST have unit tests. Run `node test-packet-filter.js && node test-aging.js` before pushing. If you add new logic, add tests to the appropriate test file or create a new one. No exceptions.
### 2. No commit without browser validation
After pushing, verify the change works in an actual browser. Use `browser profile=openclaw` against the running instance. Take a screenshot if the change is visual. If you can't validate it, say so — don't claim it works.
### 3. Cache busters — ALWAYS bump them
Every time you change a `.js` or `.css` file in `public/`, bump the cache buster in `index.html`. This has caused 7 separate production regressions. Use:
```bash
NEWV=$(date +%s) && sed -i "s/v=[0-9]*/v=$NEWV/g" public/index.html
```
Do this in the SAME commit as the code change, not as a follow-up.
### 4. Verify API response shape before building UI
Before writing client code that consumes an API endpoint, check what the endpoint ACTUALLY returns. Use `curl` or check the server code. Don't assume fields exist — grouped packets (`groupByHash=true`) have different fields than raw packets. This has caused multiple breakages.
### 5. Plan before implementing
Present a plan with milestones to the human. Wait for sign-off before starting. The plan must include:
- What changes in each milestone
- What tests will be written
- What browser validation will be done
- What config/customizer implications exist (see rule 8)
Do NOT start coding until the human says "go" or "start" or equivalent.
### 6. One commit per logical change
Don't push half-finished work. Don't push "let me try this" experiments. Get it right locally, test it, THEN push ONE commit. The QR overlay took 6 commits because each one was pushed without looking at the result. That's 6x the review burden for one visual change.
### 7. Understand before fixing
When something doesn't work as expected, INVESTIGATE before "fixing." Read the firmware source. Check the actual data. Understand WHY before changing code. The hash_size saga (21 commits) happened because we guessed at behavior instead of reading the MeshCore source.
### 8. Config values belong in the customizer eventually
If a feature introduces configurable values (thresholds, timeouts, display limits), note in the plan that these should be exposed in the customizer in a later milestone. It's OK to hardcode initially, but don't forget — track it in the plan.
### 9. Explicit git add only
Never use `git add -A` or `git add .`. Always list files explicitly: `git add file1.js file2.js`. Review with `git diff --cached --stat` before committing.
### 10. Don't regress performance
The packets page loads 30K+ packets. Don't add per-packet API calls. Don't add O(n²) loops. Client-side filtering is preferred over server-side. If you need data from the server, fetch it once and cache it.
## MeshCore Firmware — Source of Truth
The MeshCore firmware source is cloned at `firmware/` (gitignored — not part of this repo). This is THE authoritative reference for anything related to the protocol, packet format, device behavior, advert structure, flags, hash sizes, route types, or how repeaters/companions/rooms/sensors behave.
**Before implementing any feature that touches protocol behavior:**
1. Check the firmware source in `firmware/src/` and `firmware/docs/`
2. Key files: `Mesh.h` (constants, packet structure), `Packet.cpp` (encoding/decoding), `helpers/AdvertDataHelpers.h` (advert flags/types), `helpers/CommonCLI.cpp` (CLI commands), `docs/packet_format.md`, `docs/payloads.md`
3. If `firmware/` doesn't exist, clone it: `git clone --depth 1 https://github.com/meshcore-dev/MeshCore.git firmware`
4. To update: `cd firmware && git pull`
**Do NOT guess at protocol behavior.** The hash_size saga (21 commits) and the advert flags bug (room servers misclassified as repeaters) both happened because we assumed instead of reading the firmware source. The firmware is C++ — read it.
## MeshCore Protocol
**Do not memorize or hardcode protocol details from this file.** Read the firmware source.
- Packet format: `firmware/docs/packet_format.md`
- Payload types & structures: `firmware/docs/payloads.md`
- Advert flags & types: `firmware/src/helpers/AdvertDataHelpers.h`
- Route types & constants: `firmware/src/Mesh.h`
- CLI commands & behavior: `firmware/docs/cli_commands.md`
- FAQ (advert intervals, etc.): `firmware/docs/faq.md`
If you need to know how something works — a flag, a field, a timing, a behavior — **open the file and read it.** Don't rely on comments in our code, don't rely on what someone told you, don't guess. The firmware C++ source is the only thing that matters.
## Frontend Conventions
### Theming
All colors MUST use CSS variables. Never hardcode `#hex` values outside of `:root` definitions. The customizer controls colors via `THEME_CSS_MAP` in customize.js. If you add a new color, add it as a CSS variable and map it in the customizer.
### Shared Helpers (roles.js)
- `getNodeStatus(role, lastSeenMs)` → 'active' | 'stale'
- `getHealthThresholds(role)``{ staleMs, degradedMs, silentMs }`
- `ROLE_COLORS`, `ROLE_STYLE`, `TYPE_COLORS` — global color maps
### Shared Helpers (nodes.js)
- `getStatusInfo(n)``{ status, statusLabel, explanation, roleColor, ... }`
- `renderNodeBadges(n, roleColor)` → HTML string
- `renderStatusExplanation(n)` → HTML string
### last_heard vs last_seen
- `last_seen` = DB timestamp, only updates on adverts/direct upserts
- `last_heard` = from in-memory packet store, updates on ALL traffic
- Always prefer `n.last_heard || n.last_seen` for display and status calculation
### Packet Filter (packet-filter.js)
Standalone module. No dependencies on app globals (copies what it needs). Testable in Node.js:
```bash
node test-packet-filter.js
```
Uses firmware-standard type names (GRP_TXT, TXT_MSG, REQ) with aliases for convenience.
## Testing
### Test Pipeline
```bash
npm test # all backend tests + coverage summary
npm run test:unit # fast: unit tests only (no server needed)
npm run test:coverage # all tests + HTML coverage report
npm run test:full-coverage # backend + instrumented frontend coverage via Playwright
```
### Test Files
```bash
# Backend (deterministic, run before every push)
node test-packet-filter.js # filter engine
node test-aging.js # node aging system
node test-regional-filter.js # regional observer filtering
node test-decoder.js # packet decoder
node test-decoder-spec.js # spec-driven + golden fixture tests
node test-server-helpers.js # extracted server functions
node test-server-routes.js # API route tests via supertest
node test-packet-store.js # in-memory packet store
node test-db.js # SQLite operations
node test-frontend-helpers.js # frontend logic (via vm.createContext)
node tools/e2e-test.js # E2E: temp server + synthetic packets
node tools/frontend-test.js # frontend smoke: HTML, JS refs, API shapes
# Frontend E2E (requires running server or Playwright)
node test-e2e-playwright.js # 8 Playwright browser tests (default: localhost:3000)
```
### Rules
**ALL existing tests must pass before pushing.** No exceptions. No "known failures."
**Every new feature must add tests.** Unit tests for logic, Playwright tests for UI changes. Test count only goes up.
**Coverage targets:** Backend 85%+, Frontend 42%+ (both should only go up). CI reports both and updates badges automatically.
### When writing a new feature
1. Write the feature code
2. Write unit tests for the logic
3. Write/update Playwright tests if it's a UI change
4. Run `npm test` — all tests must pass
5. Run `node test-e2e-playwright.js` against a local server — E2E must pass
6. THEN push to master
### Testing infrastructure
- **Backend coverage**: c8 tracks server-side code in-process
- **Frontend coverage**: Istanbul instruments `public/*.js` → Playwright exercises them → `window.__coverage__` extracted → nyc reports. Instrumented files are generated fresh each CI run, never checked in.
- **CI pipeline**: backend tests + coverage → instrument frontend → start local server → Playwright E2E + coverage collection → badges update → deploy (only if all pass)
- **Playwright tests default to localhost:3000** — NEVER run against prod. CI sets `BASE_URL=http://localhost:13581`. Running locally: start your server, then `node test-e2e-playwright.js`
- **ARM machines**: Basic Playwright tests work with system chromium (`CHROMIUM_PATH=/usr/bin/chromium-browser`). Heavy coverage collection scripts may crash — use CI for those.
Tests that need live mesh data can use `https://analyzer.00id.net` — all API endpoints are public, no auth required.
### What Needs Tests
- Parsers and decoders (packet-filter, decoder)
- Threshold/status calculations (aging, health)
- Data transformations (hash size computation, field resolvers)
- Anything with edge cases (null handling, boundary values)
- UI interactions that exercise frontend code branches
## Engineering Principles
These aren't optional. Every change must follow these principles.
### DRY — Don't Repeat Yourself
If the same logic exists in two places, it MUST be extracted into a shared function. We had **5 separate implementations** of hash prefix disambiguation across the codebase — that's a maintenance nightmare and a bug factory. One implementation, imported everywhere.
**Before writing new code, search the codebase for existing implementations.** `grep -rn 'functionName\|pattern' public/ server.js` takes 2 seconds and prevents duplication.
### SOLID Principles
- **Single Responsibility**: Each function does ONE thing. A 200-line function that fetches, transforms, renders, and caches is wrong. Split it.
- **Open/Closed**: Add behavior by extending, not modifying. Use callbacks, options objects, or configuration — not `if (caller === 'live')` branches inside shared code.
- **Dependency Injection**: Functions should accept their dependencies as parameters, not reach into globals. `resolveHops(hops, nodeList)` — not `resolveHops(hops)` where it secretly reads `window.allNodes`. This makes functions testable in isolation.
- **Interface Segregation**: Don't force callers to depend on things they don't need. If a function returns 20 fields but the caller uses 3, consider a simpler return shape or let the caller pick.
### Code Reuse
- **Shared helpers go in shared files.** Frontend: `roles.js`, `hop-resolver.js`. Backend: `server-helpers.js`, `decoder.js`.
- **Don't copy-paste between files.** If `live.js` needs the same algorithm as `packets.js`, import it from a shared module. If the shared module doesn't exist yet, create one.
- **Parameterize, don't duplicate.** If two callers need slightly different behavior, add a parameter — don't fork the function.
### Testability
- **Write functions that are easy to test.** Pure functions (input → output, no side effects) are ideal. If a function reads from the DOM, the DB, and localStorage, it's untestable without mocking everything.
- **Dependency injection enables testing.** Pass the node list, the map reference, the API function as parameters. Tests can substitute fakes.
- **Test the real code, not copies.** Don't paste a function into a test file and test the copy. Import/require the actual module. If the module isn't importable (IIFE, browser-only), refactor it so it is — or use `vm.createContext` like `test-frontend-helpers.js` does.
- **Every bug fix gets a regression test.** If it broke once, it'll break again. The test proves it stays fixed.
### Type Safety (without TypeScript)
- **Cast at the boundary.** Data from the DB, API, or localStorage may be strings when you expect numbers. Cast early: `Number(val)`, `parseInt(val)`, `String(val)`. Don't let type mismatches propagate deep into logic where they cause cryptic `.toFixed is not a function` errors.
- **Null-check before method calls.** `val != null ? Number(val).toFixed(1) : '—'` — not `val.toFixed(1)`.
### Performance Awareness
- **No per-item API calls.** Fetch bulk data once, filter/transform client-side.
- **No O(n²) in hot paths.** The packets page has 30K+ rows. A nested loop over all packets × all nodes = 20 billion operations. Use Maps/Sets for lookups.
- **Cache expensive computations.** If you compute the same thing on every render, cache it and invalidate on data change.
## XP (Extreme Programming) Practices
### Test-First Development
Write the test BEFORE the code. Not after. Not "I'll add tests later." The test defines the expected behavior, then you write the minimum code to make it pass.
**Flow:** Red (write failing test) → Green (make it pass) → Refactor (clean up).
This prevents shipping bugs like `.toFixed on a string` — if the test existed first with string inputs, the bug could never have been introduced. Every bug fix starts by writing a test that reproduces the bug, THEN fixing it.
### YAGNI — You Aren't Gonna Need It
Don't build for hypothetical future requirements. Build the simplest thing that solves the current problem. The 5 separate disambiguation implementations happened because each page rolled its own "just in case" version instead of importing the one that already existed.
If you're writing code that handles a case nobody asked for: stop. Delete it. Add it when there's a real need.
### Refactor Mercilessly
When you touch a file and see duplication, dead code, unclear names, or structural mess — clean it up in the same commit. Don't leave it for "later." Later never comes. Tech debt compounds.
**The Boy Scout Rule:** Leave every file cleaner than you found it.
### Simple Design
The simplest solution that works is the correct one. Complexity is a bug. Before building something, ask:
1. Does this already exist somewhere in the codebase?
2. Can I solve this with an existing function + a parameter?
3. Am I over-engineering for a case that doesn't exist yet?
If the answer to any of these is yes, simplify.
### Pair Programming (Human + AI Model)
For this project, pair programming means: **subagent writes the code → parent agent reviews and tests locally → THEN pushes to master.** The subagent is the "driver," the parent is the "navigator."
**What this means in practice:**
- Subagent output is NEVER pushed directly without review
- Parent agent runs the tests, checks the diff, verifies the behavior
- If the subagent's work is wrong, parent fixes it before pushing — not after
- "The subagent said it works" is not verification. Running the tests is.
### Continuous Integration as a Gate
CI must pass before code is considered shipped. But CI is the LAST line of defense, not the first. The process is:
1. Test locally (unit + E2E)
2. Review the diff
3. Push
4. CI confirms
If CI catches something you missed locally, that's a process failure — figure out why your local testing didn't catch it and fix the gap.
### 10-Minute Build
Everything must be testable locally in under 10 minutes. If local tests are broken, flaky, or crashing — that's a P0 blocker. Fix the test infrastructure before shipping features. Broken tests = no tests = shipping blind.
### Collective Code Ownership
No file is "someone else's problem." Every file follows the same patterns, uses the same shared modules, meets the same quality bar. `live.js` doesn't get to be a special snowflake with its own reimplementation of everything. If it drifts from the shared patterns, bring it back in line.
### Small Releases
One logical change per commit. Each commit is deployable. Each commit has its tests. Don't bundle "fix A + feature B + cleanup C" into one push — if B breaks, you can't revert without losing A and C.
## Common Pitfalls
| Pitfall | Times it happened | Prevention |
|---------|-------------------|------------|
| Forgot cache busters | 7 | Always bump in same commit |
| Grouped packets missing fields | 3 | curl the actual API first |
| last_seen vs last_heard mismatch | 4 | Always use `last_heard \|\| last_seen` |
| CSS selectors don't match SVG | 2 | Manipulate SVG in JS after generation |
| Feature built on wrong assumption | 5+ | Read source/data before coding |
| Pushed without testing | 5+ | Run tests + browser check every time |
| Tests defaulting to prod | 2 | Always default to localhost, never prod |
| Gave up testing locally | 2 | Basic tests work on ARM — only heavy coverage scripts crash |
| Copy-pasted functions for "coverage" | 1 | Test the real code, not copies in a helper file |
| Subagent timed out mid-work | 4 | Give clear scope, don't try to run slow pipelines locally |
## File Naming
- Tests: `test-{feature}.js` in repo root
- No build step, no transpilation — write ES2020 for server, ES5/6 for frontend (broad browser support)
## What NOT to Do
- **Don't check in private information** — no names, API keys, tokens, passwords, IP addresses, personal data, or any identifying information. This is a PUBLIC repo.
- Don't add npm dependencies without asking
- Don't create a build step
- Don't add framework abstractions (React, Vue, etc.)
- Don't hardcode colors — use CSS variables
- Don't make per-packet server API calls from the frontend
- Don't push without running tests
- Don't start implementing without plan approval
-146
View File
@@ -1,146 +0,0 @@
# Mesh Audio — Sonification Plan
*Turn raw packet bytes into generative music.*
## What Every Packet Has (guaranteed)
- `raw_hex` — melody source
- `hop_count` — note duration + filter cutoff
- `observation_count` — volume + chord voicing
- `payload_type` — instrument + scale + root key
- `node_lat/lon` — stereo pan
- `timestamp` — arrival timing
## Final Mapping
| Data | Musical Role |
|------|-------------|
| **payload_type** | Instrument + scale + root key |
| **payload bytes** (evenly sampled, sqrt(len) count) | Melody notes (pitch) |
| **byte value** | Note length (higher = longer sustain, lower = staccato) |
| **byte-to-byte delta** | Note spacing (big jump = longer gap, small = rapid) |
| **hop_count** | Low-pass filter cutoff (more hops = more muffled) |
| **observation_count** | Volume + chord voicing (more observers = louder + stacked detuned voices) |
| **node longitude** | Stereo pan (west = left, east = right) |
| **BPM tempo** (user control) | Master time multiplier on all durations |
## Instruments & Scales by Type
| Type | Instrument | Scale | Root |
|------|-----------|-------|------|
| ADVERT | Bell / pad | C major pentatonic | C |
| GRP_TXT | Marimba / pluck | A minor pentatonic | A |
| TXT_MSG | Piano | E natural minor | E |
| TRACE | Ethereal synth | D whole tone | D |
## How a Packet Plays
1. **Header configures the voice** — payload type selects instrument, scale, root key. Flags/transport codes select envelope shape. Header bytes are NOT played as notes.
2. **Sample payload bytes** — pick `sqrt(payload_length)` bytes, evenly spaced across payload:
- 16-byte payload → 4 notes
- 36-byte payload → 6 notes
- 64-byte payload → 8 notes
3. **Each sampled byte → a note:**
- **Pitch**: byte value (0-255) quantized to selected scale across 2-3 octaves
- **Length**: byte value maps to sustain duration (low byte = short staccato ~50ms, high byte = sustained ~400ms)
- **Spacing**: delta between current and next sampled byte determines gap to next note (small delta = rapid fire, large delta = pause). Scaled by BPM tempo multiplier.
4. **Filter**: low-pass cutoff from hop_count — few hops = bright/clear, many hops = muffled (signal traveled far)
5. **Volume**: observation_count — more observers = louder
6. **Chord voicing**: if observations > 1, stack slightly detuned voices (±5-15 cents per voice, chorus effect)
7. **Pan**: origin node longitude mapped to stereo field
8. **All timings scaled by BPM tempo control**
## UI Controls
- **Audio toggle** — on/off (next to Matrix / Rain)
- **BPM tempo slider** — master time multiplier (slow = ambient, fast = techno)
- **Volume slider** — master gain
- **Mute button** — pause audio without losing toggle state
## Implementation
### Library: Tone.js (~150KB)
- `Tone.Synth` / `Tone.PolySynth` for melody + chords
- `Tone.Sampler` for realistic instruments
- `Tone.Filter` for hop-based cutoff
- `Tone.Chorus` for observation detuning
- `Tone.Panner` for geographic stereo
- `Tone.Reverb` for spatial depth
### Integration
- `animatePacket(pkt)` also calls `sonifyPacket(pkt)`
- Optional "Sonify" button on packet detail page
- Web Audio runs on separate thread — won't block UI/animations
- Polyphony capped at 8-12 voices to prevent mudding
- Voice stealing when busy
### Core Function
```
sonifyPacket(pkt):
1. Extract raw_hex → byte array
2. Separate header (first ~3 bytes) from payload
3. Header → select instrument, scale, root key, envelope
4. Sample sqrt(payload.length) bytes evenly across payload
5. For each sampled byte:
- pitch = quantize(byte, scale, rootKey)
- duration = map(byte, 50ms, 400ms) × tempoMultiplier
- gap to next = map(abs(nextByte - byte), 30ms, 300ms) × tempoMultiplier
6. Set filter cutoff from hop_count
7. Set gain from observation_count
8. Set pan from origin longitude
9. If observation_count > 1: detune +/- cents per voice
10. Schedule note sequence via Tone.js
```
## Percussion Layer
Percussion fires **instantly** on packet arrival — gives you the rhythmic pulse while the melodic notes unfold underneath.
### Drum Kit Mapping
| Packet Type | Drum Sound | Why |
|-------------|-----------|-----|
| **Any packet** | Kick drum | Network heartbeat. Every arrival = one kick. Busier network = faster kicks. |
| **ADVERT** | Hi-hat | Most frequent, repetitive — the timekeeper tick. |
| **GRP_TXT / TXT_MSG** | Snare | Human-initiated messages are accent hits. |
| **TRACE** | Rim click | Sparse, searching — light metallic tick. |
| **8+ hops OR 10+ observations** | Cymbal crash | Big network events get a crash. Rare = special. |
### Sound Design (all synthesized, no samples)
**Kick:** Sine oscillator, frequency ramp 150Hz → 40Hz in ~50ms, short gain envelope.
**Hi-hat:** White noise through highpass filter (7-10kHz).
- **Closed** (1-2 hops): 30ms decay — tight tick
- **Open** (3+ hops): 150ms decay — sizzle
**Snare:** White noise burst (bandpass ~200-1000Hz) + sine tone body (~180Hz). Observation count scales intensity (more observers = louder crack, longer decay).
**Rim click:** Short sine pulse at ~800Hz with fast decay (20ms). Dry, metallic.
**Cymbal crash:** White noise through bandpass (3-8kHz), long decay (500ms-1s). Only triggers on exceptional packets.
### Byte-Driven Variation
First payload byte mod 4 selects between variations of each percussion sound:
- Slightly different pitch (±10-20%)
- Different decay length
- Different filter frequency
Prevents machine-gun effect of identical repeated hits.
### Timing
- Percussion: fires immediately on packet arrival (t=0)
- Melody: unfolds over 0.6-1.6s starting at t=0
- Result: rhythmic hit gives you the pulse, melody gives you the data underneath
## The Full Experience
Matrix mode + Rain + Audio: green hex bytes flow across the map, columns of raw data rain down, and each packet plays its own unique melody derived from its actual bytes. Quiet periods are sparse atmospheric ambience; traffic bursts become dense polyrhythmic cascades. Crank the BPM for techno, slow it down for ambient.
## Future Ideas
- "Record" button → export MIDI or WAV
- Per-type mute toggles (silence ADVERTs, only hear messages)
- "DJ mode" — crossfade between regions
- Historical playback at accelerated speed = mesh network symphony
- Presets (ambient, techno, classical, minimal)
- ADVERT ambient drone layer (single modulated oscillator, not per-packet)
-175
View File
@@ -1,175 +0,0 @@
# AUDIO-WORKBENCH.md — Sound Shaping & Debug Interface
## Problem
Live packets arrive randomly and animate too fast to understand what's happening musically. You hear sound, but can't connect it to what the data is doing — which bytes become which notes, why this packet sounds different from that one.
## Milestone 1: Packet Jukebox
A standalone page (`#/audio-lab`) that lets you trigger packets manually and understand the data→sound mapping.
### Packet Buckets
Pre-load representative packets from the database, bucketed by type:
| Type ID | Name | Typical Size | Notes |
|---------|------|-------------|-------|
| 0x04 | ADVERT | 109-177 bytes | Node advertisements, most musical (long payload) |
| 0x05 | GRP_TXT | 18-173 bytes | Group messages, wide size range |
| 0x01 | TXT_MSG | 22-118 bytes | Direct messages |
| 0x02 | ACK/REQ | 22-57 bytes | Short acknowledgments |
| 0x09 | TRACE | 11-13 bytes | Very short, sparse |
| 0x00 | RAW | 22-33 bytes | Raw packets |
For each type, pull 5-10 representative packets spanning the size range (smallest, median, largest) and observation count range (1 obs, 10+ obs, 50+ obs).
### API
New endpoint: `GET /api/audio-lab/buckets`
Returns pre-selected packets grouped by type with decoded data and raw_hex. Server picks representatives so the client doesn't need to sift through hundreds.
### UI Layout
```
┌─────────────────────────────────────────────────────┐
│ 🎵 Audio Lab │
├──────────┬──────────────────────────────────────────┤
│ │ │
│ ADVERT │ [▶ Play] [🔁 Loop] [⏱ Slow 0.5x] │
│ ▸ #1 │ │
│ ▸ #2 │ ┌─ Packet Data ──────────────────────┐ │
│ ▸ #3 │ │ Type: ADVERT │ │
│ │ │ Size: 141 bytes (payload: 138) │ │
│ GRP_TXT │ │ Hops: 3 Observations: 12 │ │
│ ▸ #1 │ │ Raw: 04 8b 33 87 e9 c5 cd ea ... │ │
│ ▸ #2 │ └────────────────────────────────────┘ │
│ │ │
│ TXT_MSG │ ┌─ Sound Mapping ────────────────────┐ │
│ ▸ #1 │ │ Instrument: Bell (triangle) │ │
│ │ │ Scale: C major pentatonic │ │
│ TRACE │ │ Notes: 12 (√138 ≈ 11.7) │ │
│ ▸ #1 │ │ Filter: 4200 Hz (3 hops) │ │
│ │ │ Volume: 0.48 (12 obs) │ │
│ │ │ Voices: 4 (12 obs, capped) │ │
│ │ │ Pan: -0.3 (lon: -105.2) │ │
│ │ └────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Note Sequence ────────────────────┐ │
│ │ │ #1: byte 0x8B → C4 (880Hz) 310ms │ │
│ │ │ gap: 82ms (Δ=0x58) │ │
│ │ │ #2: byte 0x33 → G3 (392Hz) 120ms │ │
│ │ │ gap: 210ms (Δ=0xB4) │ │
│ │ │ ... │ │
│ │ └────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Byte Visualizer ──────────────────┐ │
│ │ │ ████░░██████░░░████████░░██░░░░████ │ │
│ │ │ ↑ ↑ ↑ ↑ │ │
│ │ │ sampled bytes highlighted in payload │ │
│ │ └────────────────────────────────────┘ │
├──────────┴──────────────────────────────────────────┤
│ BPM [====●========] 120 Vol [==●===========] 30 │
│ Voice: [constellation ▾] │
└─────────────────────────────────────────────────────┘
```
### Key Features
1. **Play button** — triggers `sonifyPacket()` with the selected packet
2. **Loop** — retrigger every N seconds (configurable)
3. **Slow mode** — 0.25x / 0.5x / 1x / 2x tempo override (separate from BPM, multiplies it)
4. **Note sequence breakdown** — shows every sampled byte, its MIDI note, frequency, duration, gap to next. Highlights each note in real-time as it plays.
5. **Byte visualizer** — hex dump of payload with sampled bytes highlighted. Shows which bytes the voice module chose and what they became.
6. **Sound mapping panel** — shows computed parameters (instrument, scale, filter, pan, volume, voice count) so you can see exactly why it sounds the way it does.
### Playback Highlighting
As each note plays, highlight:
- The corresponding byte in the hex dump
- The note row in the sequence table
- A playhead marker on the byte visualizer bar
This connects the visual and auditory — you SEE which byte is playing RIGHT NOW.
---
## Milestone 2: Parameter Overrides
Once you can hear individual packets clearly, add override sliders to shape the sound:
### Envelope & Tone
- **Oscillator type** — sine / triangle / square / sawtooth
- **ADSR sliders** — attack, decay, sustain, release (with real-time envelope visualizer curve)
- **Scale override** — force any scale regardless of packet type (C maj pent, A min pent, E nat minor, D whole tone, chromatic, etc.)
- **Root note** — base MIDI note for the scale
### Spatial & Filter
- **Filter type** — lowpass / highpass / bandpass
- **Filter cutoff** — manual override of hop-based cutoff (Hz slider + "data-driven" toggle)
- **Filter Q/resonance** — 0.1 to 20
- **Pan lock** — force stereo position (-1 to +1)
### Voicing & Dynamics
- **Voice count** — force 1-8 voices regardless of observation count
- **Detune spread** — cents per voice (0-50)
- **Volume** — manual override of observation-based volume
- **Limiter threshold** — per-packet compressor threshold (dB)
- **Limiter ratio** — 1:1 to 20:1
### Note Timing
- **Note duration range** — min/max duration mapped from byte value
- **Note gap range** — min/max gap mapped from byte delta
- **Lookahead** — scheduling buffer (ms)
Each override has a "lock 🔒" toggle — locked = your value, unlocked = data-driven. Unlocked shows the computed value in real-time so you can see what the data would produce.
The voice module's `play()` accepts an `overrides` object from the workbench. Locked parameters override computed values; unlocked ones pass through.
---
## Milestone 3: A/B Voice Comparison
- Split-screen: two voice modules side by side
- Same packet, different voices
- "Play Both" button with configurable delay between them
- Good for iterating on v2/v3 voices against v1 constellation
---
## Milestone 4: Sequence Editor
- Drag packets into a timeline to create a sequence
- Adjust timing between packets manually
- Play the sequence as a composition
- Export as audio (MediaRecorder API → WAV/WebM)
- Useful for demoing "this is what the mesh sounds like" without waiting for live traffic
---
## Milestone 5: Live Annotation Mode
- Toggle on live map that shows the sound mapping panel for each packet as it plays
- Small floating card near the animated path showing: type, notes, instrument
- Fades out after the notes finish
- Connects the live visualization with the audio in real-time
---
## Architecture Notes
- Audio Lab is a new SPA page like packets/nodes/analytics
- Reuses existing `MeshAudio.sonifyPacket()` and voice modules
- Voice modules need a small extension: `play()` should return a `NoteSequence` object describing what it will play, not just play it. This enables the visualizer.
- Or: add a `describe(parsed, opts)` method that returns the mapping without playing
- BPM/volume/voice selection shared with live map via `MeshAudio.*`
## Implementation Order
1. API endpoint for bucketed representative packets
2. Basic page layout with packet list and play button
3. Sound mapping panel (computed parameters display)
4. Note sequence breakdown
5. Playback highlighting
6. Byte visualizer
7. Override sliders (M2)
+33 -163
View File
@@ -1,172 +1,42 @@
# Changelog
## [2.5.0] "Digital Rain" — 2026-03-22
## v2.0.0 (2026-03-20)
### ✨ Matrix Mode — Full Cyberpunk Map Theme
Toggle **Matrix** on the live map to transform the entire visualization:
- **Green phosphor CRT aesthetic** — map tiles are desaturated and re-tinted through a `sepia → hue-rotate(70°) → saturate` filter chain, giving roads, coastlines, and terrain a faint green wireframe look against a dark background
- **CRT scanline overlay** — subtle horizontal lines with a gentle flicker animation across the entire map
- **Node markers dim to dark green** (#008a22 at 50% opacity) so they don't compete with packet animations
- **Forces dark mode** while active (saves and restores your previous theme on toggle off)
- **Disables heat map** automatically (incompatible visual combo)
- **All UI panels themed** — feed panel, VCR controls, node detail all go green-on-black with monospace font
- New markers created during Matrix mode (e.g. VCR timeline scrub) are automatically tinted
85+ commits — analytics, mobile redesign, accessibility, 100+ bug fixes.
### ✨ Matrix Hex Flight — Packet Bytes on the Wire
When Matrix mode is enabled, packet animations between nodes show the **actual hex bytes from the raw packet data** flowing along the path:
- **Real packet data** — bytes come from the packet's `raw_hex` field, not random/generated
- **White leading byte** with triple-layer green neon glow (`text-shadow: 0 0 8px, 0 0 16px, 0 0 24px`)
- **Trailing bytes fade** from bright to dim green, shrinking in size with distance from the head
- **Scrolls through all bytes** in the packet as it travels each hop
- **60fps animation** via `requestAnimationFrame` with time-based interpolation (1.1s per hop)
- **300ms fade-out** after reaching the destination node
- Replaces the standard contrail animation; toggle off to restore normal mode
### ✨ 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
### Matrix Rain — Falling Packet Columns
A separate **Rain** toggle adds a canvas-rendered overlay of falling hex byte columns, Matrix-style:
- **Each incoming packet** spawns a column of its actual raw hex bytes falling from the top of the screen
- **Fall distance proportional to hop count** — 4+ hops reach the bottom of the screen; a 1-hop packet barely drops. Matches the real mesh network: more hops = more propagation = longer rain trail
- **Fall duration scales with distance** — 5 seconds for a full-screen drop, proportional for shorter
- **Multiple observations = more rain** — each observation of a packet spawns its own column, staggered 150ms apart. A packet seen by 8 observers creates 8 simultaneous falling columns with ±1 hop variation for visual variety
- **Leading byte is bright white** with green glow; trailing bytes progressively fade to green
- **Entire column fades out** in the last 30% of its lifetime
- **Canvas-rendered at 60fps** — no DOM overhead, handles hundreds of simultaneous drops
- **Works independently or with Matrix mode** — combine both for the full effect
- **Replay support** — the ▶ Replay button on packet detail pages now includes raw hex data so replayed packets produce rain
### 📱 Mobile
- Two-row VCR bar layout (controls+LCD / full-width timeline)
- iOS safe area support (home indicator clearance)
- Feed/legend hidden on mobile — just map + VCR + LCD
- JS-driven viewport height for reliable orientation changes
- Touch-friendly targets, horizontal scroll on tables
### 🐛 Bug Fixes
- **Fixed null element errors in Matrix hex flight** — `getElement()` returns null when DivIcon hasn't been rendered to DOM yet during fast VCR replay
- **Fixed animation null-guard cascade** — `pulseNode`, `animatePath`, and `drawAnimatedLine` now bail early if map layers are null (stale `setInterval` callbacks after page navigation)
- **Fixed WS broadcast with null packet** — deduplicated observations caused `fullPacket` to be null in WebSocket broadcasts
- **Fixed pause button crash** — was killing WS handler registration
- **Fixed multi-select menu close handler** — null-guard for missing elements
### ♿ Accessibility
- ARIA tab patterns, focus management, keyboard navigation
- Distinct SVG marker shapes per node role
- Color-blind safe palettes, screen reader support
### ⚡ Technical Notes
- Matrix hex flight uses Leaflet `L.divIcon` markers for each character — the smoothness ceiling is Leaflet's DOM repositioning speed. CSS transitions were tested but caused stutter due to conflicts with Leaflet's internal transform updates.
- Matrix Rain uses a raw `<canvas>` overlay at z-index 9998 for zero-DOM-overhead rendering. Each drop is a simple `{x, maxY, duration, bytes, startTime}` struct rendered in a single `requestAnimationFrame` loop.
- Map tile tinting applies CSS filters to `.leaflet-tile-pane` and green overlays via `::before`/`::after` pseudo-elements on the map container (same element as `.leaflet-container`, so selectors use `.matrix-theme.leaflet-container` not descendant `.matrix-theme .leaflet-container`).
### 🐛 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
## [2.4.1] — 2026-03-22
## v1.0.0 (2026-03-19)
Hotfix release for regressions introduced in v2.4.0.
### Fixed
- Packet ingestion broken: `insert()` returned undefined after legacy table removal, causing all MQTT packets to fail silently
- Live packet updates not working: pause button `addEventListener` on null element crashed `init()`, preventing WS handler registration
- Pause button not toggling: event delegation was on `app` variable not in IIFE scope; moved to `document`
- WS broadcast had null packet data when observation was deduped (2nd+ observer of same packet)
- Multi-select filter menu close handler crashed on null `observerFilterWrap`/`typeFilterWrap` elements
- Live map animation cleanup crashed with null `animLayer`/`pathsLayer` after navigating away (setInterval kept firing)
## [2.4.0] — 2026-03-22
UI polish, client-side filtering, time window selector, DB cleanup, and bug fixes.
### Added
- Observation-level deeplinks (`#/packets/HASH?obs=OBSERVER_ID`)
- Observation detail pane (click any child row for its specific data)
- Observation sort: Observer / Path ↑↓ / Time ↑↓ with persistent preference
- Ungrouped mode flattens all observations into individual rows
- Sort help tooltip (ⓘ) explaining each mode
- Distance/Range analytics tab with haversine calculations
- View on Map buttons for distance leaderboard entries
- Realistic packet propagation mode on live map
- Packet propagation time in detail pane
- Replay sends all observations with realistic animation
- Paths-through section on node detail (desktop + mobile)
- Regional filters on all tabs (shared RegionFilter component)
- Favorites filter on live map (packet-level, not node markers)
- Configurable map defaults via `config.json`
- Hash prefix labels on map with spiral deconfliction + callout lines
- Channel rainbow table (pre-computed keys for common names)
- Zero-API live channel updates via WebSocket
- Channel message dedup by packet hash
- Channel name tags (blue pill) in packet detail column
- Shareable channel URLs (`#/channels/HASH`)
- API key required for POST endpoints
- HTTPS support (lincomatic PR #105)
- Graceful shutdown (lincomatic PR #109)
- Filter bar: logical grouping, consistent 34px height, help tooltips
- Multi-select Observer and Type filters (checkbox dropdowns, OR logic)
- Hex Paths toggle: show raw hex hash prefixes vs resolved node names
- Time window selector (15min/30min/1h/3h/6h/12h/24h/All) replaces fixed packet count limit
- Pause/resume button (⏸/▶) for live WebSocket updates with buffered packet count
- localStorage persistence for all filter/view preferences
### Changed
- Channel keys: plain `String(channelHash)`, `hashChannels` for auto-derived SHA256
- Node region filtering uses ADVERT-based index (accurate local presence vs mesh-wide routing)
- Header row reflects first sorted observation's data
- Max hop distance filter: 1000km → 300km (LoRa record ~250km)
- Route view labels use deconflicted divIcons
- Channels page hides encrypted messages, shows only decrypted
- Dark mode: active filter buttons retain accent styling
- Region dropdown: `IATA - Friendly Name` format, proper sizing
- Observer/Type filters are pure client-side (no API calls on filter change)
- Packet loading: time-window based (`since`) instead of fixed count limit
- Header row shows matching observer when observer filter is active
### Removed
- Legacy `packets` and `paths` database tables (auto-migrated on startup)
- Redundant server-side type/observer filtering (client filters in-memory)
### Fixed
- Header row showed longest path instead of first observer's path
- Observer/path mismatch when earlier observation arrives later
- Auto-seeding fake data on empty DB (now requires `--seed` flag)
- Channel "10h ago" bug (used stale `first_seen` instead of current time)
- Stale UI: wrong ID type for packet lookup after insert
- ADVERT timestamp validation rejecting valid nodes
- Channels page API spam on every WS update
- Duplicate observations in expanded view
- Analytics RF 500 error (stack overflow with 193K observations)
- Region filter SQL using non-existent column
- Channel hash: decimal→hex, keyed by decrypted name
- Corrupted repeater entries (ADVERT validation at ingestion)
- Hash_size: uses newest ADVERT, precomputed at startup
- Tab backgrounding: skip animations, resume cleanly
- Feed panel position (obscured by VCR bar)
- Hop disambiguation anchored from sender origin
- Packet hash case normalization for deeplinks
- Critical: packet ingestion broken after legacy table removal (`insert()` returned undefined)
- Sort help tooltip rendering (CSS pseudo-elements don't support newlines)
### Performance
- `/api/analytics/distance`: 3s → 630ms
- `/api/analytics/topology`: 289ms → 193ms
- `/api/observers`: 3s → 130ms
- `/api/nodes`: 50ms → 2ms (precomputed hash_size)
- Event loop max: 3.2s → 903ms (startup only)
- Pre-warm yields event loop via `setImmediate`
- Client-side hop resolution
- SQLite manual PASSIVE checkpointing
- Single API call for packet expand (was 3)
## [2.3.0] - 2026-03-20
### Added
- **Packet Deduplication**: Normalized storage with `transmissions` and `observations` tables — packets seen by multiple observers are stored once with linked observation records
- **Observation count badges**: Packets page shows 👁 badge indicating how many observers saw each transmission
- **`?expand=observations`**: API query param to include full observation details on packet responses
- **`totalTransmissions` / `totalObservations`**: Health and analytics APIs return both deduped and raw counts
- **Migration script**: `scripts/migrate-dedup.js` for converting existing packet data to normalized schema
- **Live map deeplinks**: Node detail panel links to full node detail, observer detail, and filtered packets
- **CI validation**: `setup-node` added to deploy workflow for JS syntax checking
### Changed
- In-memory packet store restructured around transmissions (primary) with observation indexes
- Packets API returns unique transmissions by default (was returning inflated observation rows)
- Home page shows "Transmissions" instead of "Packets" for network stats
- Analytics overview uses transmission counts for throughput metrics
- Node health stats include `totalTransmissions` alongside legacy `totalPackets`
- WebSocket broadcasts include `observation_count`
### Fixed
- Packet expand showing only the collapsed row instead of individual observations
- Live page "Heard By" showing "undefined pkts" (wrong field name)
- Recent packets deeplink using query param instead of route path
- Migration script handling concurrent dual-write during live deployment
### Performance
- **8.19× dedup ratio on production** (117K observations → 14K transmissions)
- RAM usage reduced proportionally — store loads transmissions, not inflated observations
Initial release.
-152
View File
@@ -1,152 +0,0 @@
# CUSTOMIZATION-PLAN.md — White-Label / Multi-Instance Theming
## Status: Phase 1 Complete (v2.6.0+)
### What's Built
- Floating draggable customizer panel (🎨 in nav)
- Basic (7 colors) + Advanced (12 colors + fonts) with light/dark mode
- Node role colors + packet type colors
- Branding (site name, logo, favicon)
- Home page content editor with markdown support
- Auto-save to localStorage + admin JSON export
- Colors restore on page load before any rendering
### Known Bugs to Fix
- Nav background sometimes doesn't repaint (gradient caching)
- Some pages may flash default colors before customization applies
- Color picker dragging can still feel sluggish on complex pages
- Reset preview may not fully restore all derived variables
### Next Round: Phase 2
- **Click-to-identify**: Click any UI element → customizer scrolls to the setting that controls it (like DevTools inspect but for theme colors)
- **Theme presets**: Built-in themes (Default, Cascadia Navy, Forest Green, Midnight) — one-click switch
- **Import config**: Paste JSON to load a theme (reverse of export)
- **Preview home page changes live** without navigating away
- Fix remaining 8 hardcoded colors from audit (nav stats, trace labels, rec-dot)
- Hex viewer color customization (Advanced section)
### Architecture Notes
- `customize.js` MUST load right after `roles.js`, before `app.js` — color restore timing is critical
- `syncBadgeColors()` in roles.js is the single source for badge CSS
- `ROLE_STYLE[role].color` must be updated alongside `ROLE_COLORS[role]`
- Auto-save debounced 500ms, theme-refresh debounced 300ms
## Problem
Regional mesh admins (e.g. CascadiaMesh) fork the analyzer and manually edit CSS/HTML to customize branding, colors, and content. This is fragile — every upstream update requires re-applying customizations.
## Goal
A `config.json`-driven customization system where admins configure branding, colors, labels, and home page content without touching source code. Accessible via a **Tools → Customization** UI that outputs the config.
## Direct Feedback (CascadiaMesh Admin)
Customizations they made manually:
- **Branding**: Custom logo, favicon, site title ("CascadiaMesh Analyzer")
- **Colors**: Node type colors (repeaters blue instead of red, companions red)
- **UI styling**: Custom color scheme (deep navy theme — "Cascadia" theme)
- **Home page**: Intro section emojis, steps, checklist content
Requested config options:
- Configurable branding assets (logo, favicon, site name)
- Configurable UI colors/text labels
- Configurable node type colors
- Everything in the intro/home section should be configurable
## Config Schema (proposed)
```json
{
"branding": {
"siteName": "CascadiaMesh Analyzer",
"logoUrl": "/assets/logo.png",
"faviconUrl": "/assets/favicon.ico",
"tagline": "Pacific Northwest Mesh Network Monitor"
},
"theme": {
"accent": "#20468b",
"accentHover": "#2d5bb0",
"navBg": "#111c36",
"navBg2": "#060a13",
"statusGreen": "#45644c",
"statusYellow": "#b08b2d",
"statusRed": "#b54a4a"
},
"nodeColors": {
"repeater": "#3b82f6",
"companion": "#ef4444",
"room": "#8b5cf6",
"sensor": "#10b981",
"observer": "#f59e0b"
},
"home": {
"heroTitle": "CascadiaMesh Network Monitor",
"heroSubtitle": "Real-time packet analysis for the Pacific Northwest mesh",
"steps": [
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" },
{ "emoji": "📊", "title": "Analyze", "description": "Understand your network's health" }
],
"checklist": [
{ "question": "How do I add my node?", "answer": "..." },
{ "question": "What regions are covered?", "answer": "..." }
],
"footerLinks": [
{ "label": "Discord", "url": "https://discord.gg/..." },
{ "label": "GitHub", "url": "https://github.com/..." }
]
},
"labels": {
"latestPackets": "Latest Packets",
"liveMap": "Live Map"
}
}
```
## Implementation Plan
### Phase 1: Config Loading + CSS Variables (Server)
- Server reads `config.json` theme section
- New endpoint: `GET /api/config/theme` returns merged theme config
- Client injects CSS variables from theme config on page load
- Node type colors configurable via `window.TYPE_COLORS` override
### Phase 2: Branding
- Config drives nav bar title, logo, favicon
- `index.html` rendered server-side with branding placeholders OR
- Client JS replaces branding elements on load from `/api/config/theme`
### Phase 3: Home Page Content
- Home page sections (hero, steps, checklist, footer) driven by config
- Default content baked in; config overrides specific sections
- Emoji + text for each step configurable
### Phase 4: Tools → Customization UI
- New page `#/customize` (admin only?)
- Color pickers for theme variables
- Live preview
- Branding upload (logo, favicon)
- Export as JSON config
- Home page content editor (WYSIWYG-lite)
### Phase 5: CSS Theme Presets
- Built-in themes: Default (blue), Cascadia (navy), Forest (green), Midnight (dark)
- One-click theme switching
- Custom theme = override any variable
## Architecture Notes
- Theme CSS variables are already in `:root {}` — just need to override from config
- Node type colors used in `roles.js` via `TYPE_COLORS` — make configurable
- Home page content is in `home.js` — extract to template driven by config
- Logo/favicon: serve from config-specified path, default to built-in
- No build step — pure runtime configuration
- Config changes take effect on page reload (no server restart needed for theme)
## Priority
1. Theme colors (CSS variables from config) — highest impact, lowest effort
2. Branding (site name, logo) — visible, requested
3. Node type colors — requested specifically
4. Home page content — requested
5. Customization UI — nice to have, lower priority
-93
View File
@@ -1,93 +0,0 @@
# Packet Deduplication Design
## The Problem
A single physical RF transmission gets recorded as N rows in the DB, where N = number of observers that heard it. Each row has the same `hash` but different `path_json` and `observer_id`.
### Example
```
Pkt 1 repeat 1: Path: A→B→C→D→E (observer E)
Pkt 1 repeat 2: Path: A→B→F→G (observer G)
Pkt 1 repeat 3: Path: A→C→H→J→K (observer K)
```
- Repeater A sent 1 packet, not 3
- Repeater B sent 1 packet, not 2 (C and F both heard the same broadcast)
- The hash is identical across all 3 rows
### Why the hash works
`computeContentHash()` = `SHA256(header_byte + payload)`, skipping path hops. Two observations of the same original packet through different paths produce the same hash. This is the dedup key.
## What's inflated (and what's not)
| Context | Current (inflated?) | Correct behavior |
|---------|-------------------|------------------|
| Node "total packets" | COUNT(*) — inflated | COUNT(DISTINCT hash) for transmissions |
| Packets/hour on observer page | Raw count | Correct — each observer DID receive it |
| Node analytics throughput | Inflated | DISTINCT hash |
| Live map animations | N animations per physical packet | 1 animation? Or 1 per path? TBD |
| "Heard By" table | Observations per observer | Correct as-is |
| RF analytics (SNR/RSSI) | Mixes observations | Each observation has its own SNR — all valid |
| Topology/path analysis | All paths shown | All paths are valuable — don't discard |
| Packet list (grouped mode) | Groups by hash already | Probably fine |
| Packet list (ungrouped) | Shows every observation | Maybe show distinct, expand for repeats? |
## Key Principle
**Observations are valuable data — never discard them.** The paths tell you about mesh topology, coverage, and redundancy. But **counts displayed to users should reflect reality** (1 transmission = 1 count).
## Design Decisions Needed
1. **What does "packets" mean in node detail?** Unique transmissions? Total observations? Both?
2. **Live map**: 1 animation with multiple path lines? Or 1 per observation?
3. **Analytics charts**: Should throughput charts show transmissions or observations?
4. **Packet list default view**: Group by hash by default?
5. **New metric: "observation ratio"?** — avg observations per transmission tells you about mesh redundancy/coverage
## Work Items
- [ ] **DB/API: Add distinct counts**`findPacketsForNode()` and health endpoint should return both `totalTransmissions` (DISTINCT hash) and `totalObservations` (COUNT(*))
- [ ] **Node detail UI** — show "X transmissions seen Y times" or similar
- [ ] **Bulk health / network status** — use distinct hash counts
- [ ] **Node analytics charts** — throughput should use distinct hashes
- [ ] **Packets page default** — consider grouping by hash by default
- [ ] **Live map** — decide on animation strategy for repeated observations
- [ ] **Observer page** — observation count is correct, but could add "unique packets" column
- [ ] **In-memory store** — add hash→[packets] index if not already there (check `pktStore.byHash`)
- [ ] **API: packet siblings**`/api/packets/:id/siblings` or `?groupByHash=true` (may already exist)
- [ ] **RF analytics** — keep all observations for SNR/RSSI (each is a real measurement) but label counts correctly
- [ ] **"Coverage ratio" metric** — avg(observations per unique hash) per node/observer — measures mesh redundancy
## Live Map Animation Design
### Current behavior
Every observation triggers a separate animation. Same packet heard by 3 observers = 3 independent route animations. Looks like 3 packets when it was 1.
### Options considered
**Option A: Single animation, all paths simultaneously (PREFERRED)**
When a hash first arrives, buffer briefly (500ms-2s) for sibling observations, then animate all paths at once. One pulse from origin, multiple route lines fanning out simultaneously. Most accurate — this IS what physically happened: one RF burst propagating through the mesh along multiple paths at once.
Timing challenge: observations don't arrive simultaneously (seconds apart). Need to buffer the first observation, wait for siblings, then render all together. Adds slight latency to "live" feel.
**Option B: Single animation, "best" path only** — REJECTED
Pick shortest/highest-SNR path, animate only that. Clean but loses coverage/redundancy info.
**Option C: Single origin pulse, staggered path reveals** — REJECTED
Origin pulses once, paths draw in sequence with delay. Dramatic but busy, and doesn't reflect reality (the propagation is simultaneous).
**Option D: Animate first, suppress siblings** — REJECTED (pragmatic but inaccurate)
First observation gets animation, subsequent same-hash observations silently logged. Simple but you never see alternate paths on the live map.
### Implementation notes (for when we build this)
- Need a client-side hash buffer: `Map<hash, {timer, packets[]}>`
- On first WS packet with new hash: start timer (configurable, ~1-2s)
- On subsequent packets with same hash: add to buffer, reset/extend timer
- On timer expiry: animate all buffered paths for that hash simultaneously
- Feed sidebar could show consolidated entry: "1 packet, 3 paths" with expand
- Buffer window should be configurable (config.json)
## Status
**Discussion phase** — no code changes yet. Iavor wants to finalize design before implementation. Live map changes tabled for later.
-236
View File
@@ -1,236 +0,0 @@
# Packet Deduplication — Normalized Schema Migration Plan
## Overview
Split the monolithic `packets` table into two tables:
- **`packets`** — one row per unique physical transmission (keyed by content hash)
- **`observations`** — one row per observer sighting (SNR, RSSI, path, observer, timestamp)
This fixes inflated packet counts across the entire app and enables proper "1 transmission seen N times" semantics.
## Current State
**`packets` table**: 1 row per observation. ~61MB, ~30K+ rows. Same hash appears N times (once per observer). Fields mix transmission data (raw_hex, payload_type, decoded_json, hash) with observation data (observer_id, snr, rssi, path_json).
**`packet-store.js`**: In-memory mirror of packets table. Indexes: `byId`, `byHash` (hash → [packets]), `byObserver`, `byNode`. All reads served from RAM. SQLite is write-only for packets.
**Touch surface**: ~66 SQL queries across db.js/server.js/packet-store.js. ~12 frontend files consume packet data.
---
## Milestone 1: Schema Migration (Backend Only)
**Goal**: New tables exist, data migrated, old table preserved as backup. No behavioral changes yet.
### Tasks
1. **Create new schema** in `db.js` init:
```sql
CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
hash TEXT NOT NULL,
observer_id TEXT,
observer_name TEXT,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_transmissions_hash ON transmissions(hash);
CREATE INDEX idx_transmissions_first_seen ON transmissions(first_seen);
CREATE INDEX idx_transmissions_payload_type ON transmissions(payload_type);
CREATE INDEX idx_observations_hash ON observations(hash);
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_id ON observations(observer_id);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
```
2. **Write migration script** (`scripts/migrate-dedup.js`):
- Read all rows from `packets` ordered by timestamp
- Group by hash
- For each unique hash: INSERT into `transmissions` (use first observation's raw_hex, decoded_json, etc.)
- For each row: INSERT into `observations` with foreign key to transmission
- Verify counts: `SELECT COUNT(*) FROM observations` = old packets count
- Verify: `SELECT COUNT(*) FROM transmissions` < observations count
- **Do NOT drop old `packets` table** — rename to `packets_backup`
3. **Print migration stats**: total packets, unique transmissions, dedup ratio, time taken
### Validation
- `COUNT(*) FROM observations` = `COUNT(*) FROM packets_backup`
- `COUNT(*) FROM transmissions` = `COUNT(DISTINCT hash) FROM packets_backup`
- Spot-check: pick 5 known multi-observer packets, verify transmission + observations match
### Risk: LOW — additive only, old data preserved
---
## Milestone 2: Dual-Write Ingest
**Goal**: New packets written to both old and new tables. Read path unchanged. Zero downtime.
### Tasks
1. **Update `db.js` `insertPacket()`**:
- On new packet: check if `transmissions` row exists for hash
- If not: INSERT into `transmissions`, get id
- If yes: UPDATE `first_seen` if this timestamp is earlier
- INSERT into `observations` with transmission_id
- **Still also write to old `packets` table** (dual-write for safety)
2. **Update `packet-store.js` `insert()`**: Mirror the dual-write in memory model
- Maintain both old flat array AND new `byTransmission` Map
### Validation
- Send test packets, verify they appear in both old and new tables
- Verify multi-observer packet creates 1 transmission + N observations
### Risk: LOW — old read path still works as fallback
---
## Milestone 3: In-Memory Store Restructure
**Goal**: `packet-store.js` switches from flat packet array to transmission-centric model.
### Tasks
1. **New in-memory data model**:
```
transmissions: Map<hash, {id, raw_hex, hash, first_seen, payload_type, decoded_json, observations: []}>
```
Each observation: `{id, observer_id, observer_name, snr, rssi, path_json, timestamp}`
2. **Update indexes**:
- `byHash`: hash → transmission object (1:1 instead of 1:N)
- `byObserver`: observer_id → [observation references]
- `byNode`: pubkey → [transmission references] (deduped!)
- `byId`: observation.id → observation (for backward compat with packet detail links)
3. **Update `load()`**: Read from `transmissions` JOIN `observations` instead of `packets`
4. **Update query methods**:
- `findPackets()` — returns transmissions by default, with `.observations` attached
- `findPacketsForNode()` — returns transmissions where node appears in ANY observation's path/decoded_json
- `getSiblings()` — becomes `getObservations(hash)` — trivial, just return `transmission.observations`
- `countForNode()` — returns `{transmissions: N, observations: M}`
### Validation
- All existing API endpoints return valid data
- Packet counts decrease (correctly!) for multi-observer nodes
- `/api/perf` shows no regression
### Risk: MEDIUM — core read path changes. Test thoroughly.
---
## Milestone 4: API Response Changes
**Goal**: APIs return deduped data with observation counts.
### Tasks
1. **`GET /api/packets`**:
- Default: return transmissions (1 row per unique packet)
- Each transmission includes `observation_count` and optionally `observations[]`
- `?expand=observations` to include full observation list
- `?groupByHash` becomes the default behavior (deprecate param)
- Preserve `observer` filter: return transmissions where at least one observation matches
2. **`GET /api/nodes/:pubkey/health`**:
- `stats.totalPackets` → `stats.totalTransmissions` (distinct hashes)
- Add `stats.totalObservations` (old count, for reference)
- `recentPackets` → returns transmissions with observation_count
3. **`GET /api/nodes/bulk-health`**: Same changes as health
4. **`GET /api/nodes/network-status`**: Use transmission counts
5. **`GET /api/nodes/:pubkey/analytics`**: All throughput charts use transmission counts
6. **WebSocket broadcast**: Include `observation_count` when sibling observations exist for same hash
### Backward Compatibility
- Add `?legacy=1` param that returns old-style flat observations (for any external consumers)
- Include both `totalTransmissions` and `totalObservations` in health responses during transition
### Risk: MEDIUM — frontend expects certain shapes. May need coordinated deploy with Milestone 5.
---
## Milestone 5: Frontend Updates
**Goal**: UI shows correct counts and leverages observation data.
### Tasks
1. **Packets page**:
- Default view shows transmissions (already has groupByHash mode — make it default)
- Expand row to see individual observations with their paths/SNR/RSSI
- Badge: "×3 observers" on grouped rows
2. **Node detail panel** (nodes.js + live.js):
- Show "X transmissions" not "X packets"
- Or "X packets (seen Y times)" to show both
3. **Home page**: Network stats use transmission counts
4. **Node analytics**: Throughput charts use transmissions
5. **Observer detail**: Keep observation counts (correct metric for observers)
6. **Analytics page**: Topology/RF analysis uses all observations (SNR per observation is valid data)
### Risk: LOW-MEDIUM — mostly display changes
---
## Milestone 6: Cleanup
**Goal**: Remove dual-write, drop old table, clean up.
### Tasks
1. Remove dual-write from `insertPacket()`
2. Drop `packets_backup` table (after confirming everything works for 1+ week)
3. Remove `?legacy=1` support if unused
4. Update DEDUP-DESIGN.md → mark as complete
5. VACUUM the database
6. Tag release (v2.3.0?)
### Risk: LOW — cleanup only, all functional changes already proven
---
## Estimated Scope
| Milestone | Files Modified | Complexity | Can Deploy Independently? |
|-----------|---------------|------------|--------------------------|
| 1. Schema Migration | db.js, new script | Low | Yes — additive only |
| 2. Dual-Write | db.js, packet-store.js | Low | Yes — old reads unchanged |
| 3. Memory Store | packet-store.js | Medium | No — must deploy with M4 |
| 4. API Changes | server.js, db.js | Medium | No — must deploy with M5 |
| 5. Frontend | 8+ public/*.js files | Medium | No — must deploy with M4 |
| 6. Cleanup | db.js, server.js | Low | Yes — after bake period |
**Milestones 1-2**: Safe to deploy independently, no user-visible changes.
**Milestones 3-5**: Must ship together (API shape changes + frontend expects new shape).
**Milestone 6**: Ship after 1 week bake.
## Open Questions
1. **Table naming**: `transmissions` + `observations`? Or keep `packets` + add `observations`? The word "transmission" is more accurate but "packet" is what the whole UI calls them.
2. **Packet detail URLs**: Currently `#/packet/123` uses the observation ID. Keep observation IDs as the URL key? Or switch to hash?
3. **Path dedup in paths table**: The `paths` table also has per-observation entries. Normalize that too, or leave as-is?
4. **Migration on prod**: Run migration script before deploying new code, or make new code handle both old and new schema?
-33
View File
@@ -1,33 +0,0 @@
FROM node:22-alpine
RUN apk add --no-cache mosquitto mosquitto-clients supervisor caddy
WORKDIR /app
# Install Node dependencies
COPY package.json package-lock.json ./
RUN npm ci --production
# Copy application
COPY *.js config.example.json channel-rainbow.json ./
COPY public/ ./public/
# Supervisor + Mosquitto + Caddy config
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile
# Create data directory for SQLite + Mosquitto persistence + Caddy certs
RUN mkdir -p /app/data /var/lib/mosquitto /data/caddy && \
chown -R node:node /app/data && \
chown -R mosquitto:mosquitto /var/lib/mosquitto
# Default config: copy example if no config mounted
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80 443 1883
VOLUME ["/app/data", "/data/caddy"]
ENTRYPOINT ["/entrypoint.sh"]
-67
View File
@@ -1,67 +0,0 @@
# Performance — v2.1.0
**Dataset:** 28,014 packets, ~650 nodes, 2 observers
**Hardware:** ARM64 (MikroTik CCR2116), single-core Node.js
## A/B Benchmark: v2.0.1 (before) vs v2.1.0 (after)
All times are averages over 3 runs. "Cached" = warm TTL cache hit.
| Endpoint | v2.0.1 | v2.1.0 (cold) | v2.1.0 (cached) | Speedup |
|---|---|---|---|---|
| **Bulk Health** | 7,059 ms | 3 ms | 1 ms | **7,059×** |
| **Node Analytics** | 381 ms | 2 ms | 1 ms | **381×** |
| **Hash Sizes** | 353 ms | 193 ms | 1 ms | **353×** |
| **Topology** | 685 ms | 579 ms | 2 ms | **342×** |
| **RF Analytics** | 253 ms | 235 ms | 1 ms | **253×** |
| **Channels** | 206 ms | 77 ms | 1 ms | **206×** |
| **Node Health** | 195 ms | 1 ms | 1 ms | **195×** |
| **Node Detail** | 133 ms | 1 ms | 1 ms | **133×** |
| **Channel Analytics** | 95 ms | 73 ms | 2 ms | **47×** |
| **Packets (grouped)** | 76 ms | 33 ms | 28 ms | **2×** |
| **Stats** | 2 ms | 1 ms | 1 ms | 2× |
| **Nodes List** | 3 ms | 2 ms | 2 ms | 1× |
| **Observers** | 1 ms | 8 ms | 1 ms | 1× |
## Architecture
### Two-Layer Performance Stack
1. **In-Memory Packet Store** (`packet-store.js`)
- All packets loaded from SQLite into RAM on startup (~28K packets = ~12MB)
- Indexed by `id`, `hash`, `observer`, and `node` (Map-based O(1) lookup)
- Ring buffer with configurable max memory (default 1GB, ~2.3M packets)
- SQLite becomes **write-only** for packets — reads never touch disk
- New packets from MQTT written to both RAM + SQLite
2. **TTL Cache** (`server.js`)
- Computed API responses cached with configurable TTLs (via `config.json`)
- Smart invalidation: packet bursts only invalidate channels/observers; analytics expire by TTL only
- Pre-warmed on startup: subpaths, RF, topology, channels, hash-sizes, bulk-health
- Result: most API responses served in **1-2ms** from cache
### Key Optimizations
- **Eliminated all `LIKE '%pubkey%'` queries**: Every node-specific endpoint was doing full-table scans on the packets table via `decoded_json LIKE '%pubkey%'`. Replaced with O(1) `pktStore.byNode` Map lookups.
- **Single-pass computations**: Channels, analytics, and subpaths computed in one loop instead of multiple SQL queries.
- **Client-side WebSocket prepend**: New packets appended to the table without re-fetching the API.
- **RF response compression**: Server-side histograms + scatter downsampling (1MB → 15KB).
- **Configurable everything**: All TTLs, packet store limits, and thresholds in `config.json`.
### What Didn't Work
- **Background refresh (`setInterval`)**: Attempted to re-warm caches at 80% TTL. Blocked the event loop — Node.js is single-threaded. Response times went from 3ms to 1,200ms. Reverted immediately.
- **Worker threads**: `structuredClone` overhead of 416ms for 28K packets negated the compute savings. Only viable at 10× data growth or with `SharedArrayBuffer` (zero-copy).
## Running the Benchmark
```bash
# Stop the production server first
supervisorctl stop meshcore-analyzer
# Run A/B benchmark (launches two servers: old v2.0.1 vs current)
./benchmark-ab.sh
# Restart production
supervisorctl start meshcore-analyzer
```
+7 -92
View File
@@ -1,11 +1,5 @@
# MeshCore Analyzer
[![Backend Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/backend-tests.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Backend Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/backend-coverage.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Frontend Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/frontend-tests.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Frontend Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/frontend-coverage.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Deploy](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml/badge.svg)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
> 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, per-node analytics, and more.
@@ -54,58 +48,14 @@ Full experience on your phone — proper touch controls, iOS safe area support,
- **Observer Status** — health monitoring, packet counts, uptime
- **Hash Collision Matrix** — detect address collisions across the mesh
- **Claimed Nodes** — star your nodes, always sorted to top, visual distinction
- **Dark / Light Mode** — auto-detects system preference, instant toggle, map tiles swap too
- **Multi-Broker MQTT** — connect to multiple MQTT brokers simultaneously with per-source IATA filtering
- **Observer Detail Pages** — click any observer for analytics, charts, status, radio info, recent packets
- **Channel Key Auto-Derivation** — hashtag channels (`#channel`) keys derived automatically via SHA256
- **Dark / Light Mode** — auto-detects system preference, instant toggle
- **Global Search** — search packets, nodes, and channels (Ctrl+K)
- **Shareable URLs** — deep links to individual packets, channels, and observer detail pages
- **Mobile Responsive** — proper two-row VCR bar, iOS safe area support, touch-friendly
- **Accessible** — ARIA patterns, keyboard navigation, screen reader support, distinct marker shapes
### ⚡ Performance (v2.1.1)
Two-layer caching architecture: in-memory packet store + TTL response cache. All packet reads served from RAM — SQLite is write-only. Heavy endpoints pre-warmed on startup.
| Endpoint | Before | After | Speedup |
|---|---|---|---|
| Bulk Health | 7,059 ms | 1 ms | **7,059×** |
| Node Analytics | 381 ms | 1 ms | **381×** |
| Topology | 685 ms | 2 ms | **342×** |
| Node Health | 195 ms | 1 ms | **195×** |
| Node Detail | 133 ms | 1 ms | **133×** |
See [PERFORMANCE.md](PERFORMANCE.md) for the full benchmark.
## Quick Start
### Docker (Recommended)
```bash
git clone https://github.com/Kpa-clawbot/meshcore-analyzer.git
cd meshcore-analyzer
./manage.sh setup
```
The setup wizard walks you through everything — config, domain, HTTPS, build, and run. Safe to cancel and re-run at any point.
After setup:
```bash
./manage.sh status # Health check + packet/node counts
./manage.sh logs # Follow logs
./manage.sh backup # Backup database
./manage.sh update # Pull latest + rebuild + restart
./manage.sh mqtt-test # Check if observer data is flowing
./manage.sh help # All commands
```
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for the full deployment guide, HTTPS options (auto cert, bring your own, Cloudflare Tunnel), MQTT security, backups, and troubleshooting.
**Theme customization:** Use the built-in customizer (Tools → Customize) to design your theme, download the `theme.json` file, and place it next to your `config.json`. Changes are picked up on page refresh.
### Manual Install
#### Prerequisites
### Prerequisites
- **Node.js** 18+ (tested with 22.x)
- **MQTT broker** (Mosquitto recommended) — optional, can inject packets via API
@@ -125,25 +75,10 @@ Edit `config.json`:
```json
{
"port": 3000,
"https": {
"cert": "/path/to/cert.pem",
"key": "/path/to/key.pem"
},
"mqtt": {
"broker": "mqtt://localhost:1883",
"topic": "meshcore/+/+/packets"
},
"mqttSources": [
{
"name": "remote-feed",
"broker": "mqtts://remote-broker:8883",
"topics": ["meshcore/+/+/packets", "meshcore/+/+/status"],
"username": "user",
"password": "pass",
"rejectUnauthorized": false,
"iataFilter": ["SJC", "SFO", "OAK"]
}
],
"channelKeys": {
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
},
@@ -159,17 +94,9 @@ Edit `config.json`:
| Field | Description |
|-------|-------------|
| `port` | HTTP server port (default: 3000) |
| `https.cert` / `https.key` | Optional PEM cert/key paths to enable native HTTPS (falls back to HTTP if omitted or unreadable) |
| `mqtt.broker` | Local MQTT broker URL. Set to `""` to disable |
| `mqtt.broker` | MQTT broker URL. Set to `""` to disable MQTT and use API-only mode |
| `mqtt.topic` | MQTT topic pattern for packet ingestion |
| `mqttSources` | Array of external MQTT broker connections (optional) |
| `mqttSources[].name` | Friendly name for logging |
| `mqttSources[].broker` | Broker URL (`mqtt://` or `mqtts://` for TLS) |
| `mqttSources[].topics` | Array of MQTT topic patterns to subscribe to |
| `mqttSources[].username` / `password` | Broker credentials |
| `mqttSources[].rejectUnauthorized` | Set `false` for self-signed TLS certs |
| `mqttSources[].iataFilter` | Only accept packets from these IATA regions |
| `channelKeys` | Named channel decryption keys (hex). Hashtag channels auto-derived via SHA256 |
| `channelKeys` | Named channel decryption keys (hex). `public` is the default MeshCore public channel |
| `defaultRegion` | Default IATA region code for the UI |
| `regions` | Map of IATA codes to human-readable region names |
@@ -231,26 +158,17 @@ Observer Node → USB → meshcoretomqtt → MQTT Broker → Analyzer Server →
```
meshcore-analyzer/
├── Dockerfile # Single-container build (Node + Mosquitto + Caddy)
├── .dockerignore
├── config.example.json # Example config (copy to config.json)
├── config.json # MQTT, channel keys, regions (gitignored)
├── config.json # MQTT, channel keys, regions
├── server.js # Express + WebSocket + MQTT + REST API
├── decoder.js # Custom MeshCore packet decoder
├── db.js # SQLite schema + queries
├── packet-store.js # In-memory packet store (ring buffer, indexed)
├── docker/
│ ├── supervisord.conf # Process manager config
│ ├── mosquitto.conf # MQTT broker config
│ ├── Caddyfile # Default Caddy config (localhost)
│ └── entrypoint.sh # Container entrypoint
├── data/
│ └── meshcore.db # Packet database (auto-created)
├── public/
│ ├── index.html # SPA shell
│ ├── style.css # Theme (light/dark)
│ ├── app.js # Router, WebSocket, utilities
│ ├── packets.js # Packet feed + byte breakdown + detail page
│ ├── packets.js # Packet feed + byte breakdown
│ ├── map.js # Leaflet map with route visualization
│ ├── live.js # Live trace page with VCR playback
│ ├── channels.js # Channel chat
@@ -258,10 +176,7 @@ meshcore-analyzer/
│ ├── analytics.js # Global analytics dashboard
│ ├── node-analytics.js # Per-node analytics with charts
│ ├── traces.js # Packet tracing
── observers.js # Observer status
│ ├── observer-detail.js # Observer detail with analytics
│ ├── home.js # Dashboard home page
│ └── perf.js # Performance monitoring dashboard
── observers.js # Observer status
└── tools/
├── generate-packets.js # Synthetic packet generator
├── e2e-test.js # End-to-end API tests
-64
View File
@@ -1,64 +0,0 @@
# v2.6.0 — Audio Sonification, Regional Hop Filtering, Audio Lab
## 🔊 Mesh Audio Sonification
Packets now have sound. Each packet's raw bytes become music through a modular voice engine.
- **Payload type → instrument + scale**: ADVERTs play triangle waves on C major pentatonic, GRP_TXT uses sine on A minor pentatonic, TXT_MSG on E natural minor, TRACE on D whole tone
- **Payload bytes → melody**: √(payload_length) bytes sampled evenly, quantized to scale
- **Byte value → note duration**: low bytes = staccato, high = sustained
- **Byte delta → note spacing**: small deltas = rapid fire, large = pauses
- **Observation count → volume + chord voicing**: more observers = louder + richer (up to 8 detuned voices via log₂ scaling)
- **Hop count → filter cutoff**: more hops = more muffled (lowpass 800-8000Hz)
- **Node longitude → stereo pan**
- **BPM tempo slider** for ambient ↔ techno feel
- **Per-packet limiter** prevents amplitude spikes from overlapping notes
- **Exponential envelopes** eliminate click/pop artifacts
- **"Tap to enable audio" overlay** handles browser autoplay policy
- **Modular voice architecture**: engine (`audio.js`) + swappable voice modules. New voices = new file + script tag.
## 🎵 Audio Lab (Packet Jukebox)
New `#/audio-lab` page for understanding and debugging the audio:
- **Packet buckets by type** — representative packets spanning size/observation ranges
- **Play/Loop/Speed controls** — trigger individual packets, 0.25x to 4x speed
- **Sound Mapping panel** — shows WHY each parameter has its value (formulas + computed results)
- **Note Sequence table** — every sampled byte → MIDI note → frequency → duration → gap, with derivation formulas
- **Real-time playback highlighting** — hex dump, note rows, and byte visualizer highlight in sync as each note plays
- **Click individual notes** — play any single note from the sequence
- **Byte Visualizer** — bar chart of payload bytes, sampled bytes colored by type
## 🗺️ Regional Hop Filtering (#117)
1-byte repeater IDs (0-255) collide globally. Previously, resolve-hops picked candidates from anywhere, causing false cross-regional paths (e.g., Eugene packet showing Vancouver repeaters).
- **Layered filtering**: GPS distance to IATA center (bridge-proof) → observer-based fallback → global fallback
- **60+ IATA airport coordinates** built in for geographic distance calculations
- **Regional candidates sorted by distance** — closest to region center wins when no sender GPS available
- **Sender GPS as origin anchor** — ADVERTs use their own coordinates; channel messages look up sender node GPS from previous ADVERTs in the database
- **Per-observer resolution** — packet list batch-resolves ambiguous hops per observer via server API
- **Conflict popover** — clickable ⚠ badges show all regional candidates with distances, each linking to node detail
- **Shared HopDisplay module** — consistent conflict display across packets, nodes, and detail views
## 🏷️ Region Dropdown Improvements (#116)
- **150+ built-in IATA-to-city mappings** — dropdown shows `SEA - Seattle, WA` automatically, no config needed
- **Layout fixes** — dropdown auto-sizes for longer labels, checkbox alignment, ellipsis overflow
## 📍 Location & Navigation
- **Packet detail shows location** for ADVERTs (direct GPS), channel texts (sender node lookup), and all resolvable senders
- **📍 Map link** navigates to `#/map?node=PUBKEY` — centers on the actual node and opens its popup
- **Observer IATA regions** shown in packet detail, node detail, and live map node panels
## 🔧 Fixes
- **Realistic mode fixed** — secondary WS broadcast paths (ADVERT, GRP_TXT, TXT_MSG, TRACE) were missing `hash` field, bypassing the 5-second grouping buffer entirely
- **Observation count passed to sonification** — realistic mode now provides actual observer count for volume/chord voicing
- **Packet list dedup** — O(1) hash index via Map prevents duplicate rows
- **Observer names in packet detail** — direct navigation to `#/packets/HASH` now loads observers first
- **Observer detail packet links** — fixed to use hash (not ID) and correct route
- **Time window bypassed for direct links** — `#/packets/HASH` always shows the packet regardless of time filter
- **CI: `docker rm -f`** — prevents stale container conflicts during deploy
- **CI: `paths-ignore`** — skips deploy on markdown/docs/license changes
-131
View File
@@ -1,131 +0,0 @@
#!/bin/bash
# A/B benchmark: old (pre-perf) vs new (current)
# Usage: ./benchmark-ab.sh
set -e
PORT_OLD=13003
PORT_NEW=13004
RUNS=3
DB_PATH="$(pwd)/data/meshcore.db"
OLD_COMMIT="23caae4"
NEW_COMMIT="$(git rev-parse HEAD)"
echo "═══════════════════════════════════════════════════════"
echo " A/B Benchmark: Pre-optimization vs Current"
echo "═══════════════════════════════════════════════════════"
echo "OLD: $OLD_COMMIT (v2.0.1 — before any perf work)"
echo "NEW: $NEW_COMMIT (current)"
echo "Runs per endpoint: $RUNS"
echo ""
# Get a real node pubkey for testing
ORIG_DIR="$(pwd)"
PUBKEY=$(sqlite3 "$DB_PATH" "SELECT public_key FROM nodes ORDER BY last_seen DESC LIMIT 1")
echo "Test node: ${PUBKEY:0:16}..."
echo ""
# Setup old version in temp dir
OLD_DIR=$(mktemp -d)
echo "Cloning old version to $OLD_DIR..."
git worktree add "$OLD_DIR" "$OLD_COMMIT" --quiet 2>/dev/null || {
git worktree add "$OLD_DIR" "$OLD_COMMIT" --detach --quiet
}
# Copy config + db symlink
# Copy config + db + share node_modules
cp config.json "$OLD_DIR/"
mkdir -p "$OLD_DIR/data"
cp "$ORIG_DIR/data/meshcore.db" "$OLD_DIR/data/meshcore.db"
ln -sf "$ORIG_DIR/node_modules" "$OLD_DIR/node_modules"
ENDPOINTS=(
"Stats|/api/stats"
"Packets(50)|/api/packets?limit=50"
"PacketsGrouped|/api/packets?limit=50&groupByHash=true"
"NodesList|/api/nodes?limit=50"
"NodeDetail|/api/nodes/$PUBKEY"
"NodeHealth|/api/nodes/$PUBKEY/health"
"NodeAnalytics|/api/nodes/$PUBKEY/analytics?days=7"
"BulkHealth|/api/nodes/bulk-health?limit=50"
"NetworkStatus|/api/nodes/network-status"
"Channels|/api/channels"
"Observers|/api/observers"
"RF|/api/analytics/rf"
"Topology|/api/analytics/topology"
"ChannelAnalytics|/api/analytics/channels"
"HashSizes|/api/analytics/hash-sizes"
)
bench_endpoint() {
local port=$1 path=$2 runs=$3 nocache=$4
local total=0
for i in $(seq 1 $runs); do
local url="http://127.0.0.1:$port$path"
if [ "$nocache" = "1" ]; then
if echo "$path" | grep -q '?'; then
url="${url}&nocache=1"
else
url="${url}?nocache=1"
fi
fi
local ms=$(curl -s -o /dev/null -w "%{time_total}" "$url" 2>/dev/null)
local ms_int=$(echo "$ms * 1000" | bc | cut -d. -f1)
total=$((total + ms_int))
done
echo $((total / runs))
}
# Launch old server
echo "Starting OLD server (port $PORT_OLD)..."
cd "$OLD_DIR"
PORT=$PORT_OLD node server.js &>/dev/null &
OLD_PID=$!
cd - >/dev/null
# Launch new server
echo "Starting NEW server (port $PORT_NEW)..."
PORT=$PORT_NEW node server.js &>/dev/null &
NEW_PID=$!
# Wait for both
sleep 12 # old server has no memory store; new needs prewarm
# Verify
curl -s "http://127.0.0.1:$PORT_OLD/api/stats" >/dev/null 2>&1 || { echo "OLD server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
curl -s "http://127.0.0.1:$PORT_NEW/api/stats" >/dev/null 2>&1 || { echo "NEW server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
echo ""
echo "Warming up caches on new server..."
for ep in "${ENDPOINTS[@]}"; do
path="${ep#*|}"
curl -s -o /dev/null "http://127.0.0.1:$PORT_NEW$path" 2>/dev/null
done
sleep 2
printf "\n%-22s %9s %9s %9s %9s\n" "Endpoint" "Old(ms)" "New-cold" "New-cache" "Speedup"
printf "%-22s %9s %9s %9s %9s\n" "──────────────────────" "─────────" "─────────" "─────────" "─────────"
for ep in "${ENDPOINTS[@]}"; do
name="${ep%%|*}"
path="${ep#*|}"
old_ms=$(bench_endpoint $PORT_OLD "$path" $RUNS 0)
new_cold=$(bench_endpoint $PORT_NEW "$path" $RUNS 1)
new_cached=$(bench_endpoint $PORT_NEW "$path" $RUNS 0)
if [ "$old_ms" -gt 0 ] && [ "$new_cached" -gt 0 ]; then
speedup="${old_ms}/${new_cached}"
speedup_x=$(echo "scale=0; $old_ms / $new_cached" | bc 2>/dev/null || echo "?")
printf "%-22s %7dms %7dms %7dms %7d×\n" "$name" "$old_ms" "$new_cold" "$new_cached" "$speedup_x"
else
printf "%-22s %7dms %7dms %7dms %9s\n" "$name" "$old_ms" "$new_cold" "$new_cached" "∞"
fi
done
echo ""
echo "═══════════════════════════════════════════════════════"
# Cleanup
kill $OLD_PID $NEW_PID 2>/dev/null
git worktree remove "$OLD_DIR" --force 2>/dev/null
echo "Done."
-246
View File
@@ -1,246 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* Benchmark suite for meshcore-analyzer.
* Launches two server instances — one with in-memory store, one with pure SQLite —
* and compares performance side by side.
*
* Usage: node benchmark.js [--runs 5] [--json]
*/
const http = require('http');
const { spawn } = require('child_process');
const path = require('path');
const args = process.argv.slice(2);
const RUNS = Number(args.find((a, i) => args[i - 1] === '--runs') || 5);
const JSON_OUT = args.includes('--json');
const PORT_MEM = 13001; // In-memory store
const PORT_SQL = 13002; // SQLite-only
const ENDPOINTS = [
{ name: 'Stats', path: '/api/stats' },
{ name: 'Packets (50)', path: '/api/packets?limit=50' },
{ name: 'Packets (100)', path: '/api/packets?limit=100' },
{ name: 'Packets grouped', path: '/api/packets?limit=100&groupByHash=true' },
{ name: 'Packets filtered', path: '/api/packets?limit=50&type=5' },
{ name: 'Packets timestamps', path: '/api/packets/timestamps?since=2020-01-01' },
{ name: 'Nodes list', path: '/api/nodes?limit=50' },
{ name: 'Node detail', path: '/api/nodes/__FIRST_NODE__' },
{ name: 'Node health', path: '/api/nodes/__FIRST_NODE__/health' },
{ name: 'Bulk health', path: '/api/nodes/bulk-health?limit=50' },
{ name: 'Network status', path: '/api/nodes/network-status' },
{ name: 'Observers', path: '/api/observers' },
{ name: 'Channels', path: '/api/channels' },
{ name: 'RF Analytics', path: '/api/analytics/rf' },
{ name: 'Topology', path: '/api/analytics/topology' },
{ name: 'Channel Analytics', path: '/api/analytics/channels' },
{ name: 'Hash Sizes', path: '/api/analytics/hash-sizes' },
{ name: 'Subpaths 2-hop', path: '/api/analytics/subpaths?minLen=2&maxLen=2&limit=50' },
{ name: 'Subpaths 3-hop', path: '/api/analytics/subpaths?minLen=3&maxLen=3&limit=30' },
{ name: 'Subpaths 4-hop', path: '/api/analytics/subpaths?minLen=4&maxLen=4&limit=20' },
{ name: 'Subpaths 5-8 hop', path: '/api/analytics/subpaths?minLen=5&maxLen=8&limit=15' },
];
function fetch(url) {
return new Promise((resolve, reject) => {
const t0 = process.hrtime.bigint();
const req = http.get(url, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
resolve({ ms, bytes: Buffer.byteLength(body), status: res.statusCode, body });
});
});
req.on('error', reject);
req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
});
}
function median(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)]; }
function p95(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length*0.95)]; }
function avg(arr) { return arr.reduce((a,b)=>a+b,0)/arr.length; }
function fmt(ms) { return ms >= 1000 ? (ms/1000).toFixed(1)+'s' : ms.toFixed(1)+'ms'; }
function fmtSize(b) { return b >= 1048576 ? (b/1048576).toFixed(1)+'MB' : b >= 1024 ? (b/1024).toFixed(0)+'KB' : b+'B'; }
function launchServer(port, env = {}) {
return new Promise((resolve, reject) => {
const child = spawn('node', ['server.js'], {
cwd: __dirname,
env: { ...process.env, PORT: String(port), ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let started = false;
const timeout = setTimeout(() => { if (!started) { child.kill(); reject(new Error('Server start timeout')); } }, 30000);
child.stdout.on('data', (d) => {
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
started = true; clearTimeout(timeout); resolve(child);
}
});
child.stderr.on('data', (d) => {
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
started = true; clearTimeout(timeout); resolve(child);
}
});
child.on('exit', (code) => { if (!started) { clearTimeout(timeout); reject(new Error(`Server exited with ${code}`)); } });
// Fallback: wait longer (SQLite-only mode pre-warms subpaths ~6s)
setTimeout(() => {
if (!started) {
started = true; clearTimeout(timeout);
resolve(child);
}
}, 15000);
});
}
async function waitForServer(port, maxMs = 20000) {
const t0 = Date.now();
while (Date.now() - t0 < maxMs) {
try {
const r = await fetch(`http://127.0.0.1:${port}/api/stats`);
if (r.status === 200) return true;
} catch {}
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Server on port ${port} didn't start`);
}
async function benchmarkEndpoints(port, endpoints, nocache = false) {
const results = [];
for (const ep of endpoints) {
const suffix = nocache ? (ep.path.includes('?') ? '&nocache=1' : '?nocache=1') : '';
const url = `http://127.0.0.1:${port}${ep.path}${suffix}`;
// Warm-up
try { await fetch(url); } catch {}
const times = [];
let bytes = 0;
let failed = false;
for (let i = 0; i < RUNS; i++) {
try {
const r = await fetch(url);
if (r.status !== 200) { failed = true; break; }
times.push(r.ms);
bytes = r.bytes;
} catch { failed = true; break; }
}
if (failed || !times.length) {
results.push({ name: ep.name, failed: true });
} else {
results.push({
name: ep.name,
avg: Math.round(avg(times) * 10) / 10,
p50: Math.round(median(times) * 10) / 10,
p95: Math.round(p95(times) * 10) / 10,
bytes
});
}
}
return results;
}
async function run() {
console.log(`\nMeshCore Analyzer Benchmark — ${RUNS} runs per endpoint`);
console.log('Launching servers...\n');
// Launch both servers
let memServer, sqlServer;
try {
console.log(' Starting in-memory server (port ' + PORT_MEM + ')...');
memServer = await launchServer(PORT_MEM, {});
await waitForServer(PORT_MEM);
console.log(' ✅ In-memory server ready');
console.log(' Starting SQLite-only server (port ' + PORT_SQL + ')...');
sqlServer = await launchServer(PORT_SQL, { NO_MEMORY_STORE: '1' });
await waitForServer(PORT_SQL);
console.log(' ✅ SQLite-only server ready\n');
} catch (e) {
console.error('Failed to start servers:', e.message);
if (memServer) memServer.kill();
if (sqlServer) sqlServer.kill();
process.exit(1);
}
// Get first node pubkey
let firstNode = '';
try {
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/nodes?limit=1`);
const data = JSON.parse(r.body);
firstNode = data.nodes?.[0]?.public_key || '';
} catch {}
const endpoints = ENDPOINTS.map(e => ({
...e,
path: e.path.replace('__FIRST_NODE__', firstNode),
}));
// Get packet count
try {
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/stats`);
const stats = JSON.parse(r.body);
console.log(`Dataset: ${(stats.totalPackets || '?').toLocaleString()} packets\n`);
} catch {}
// Run benchmarks
console.log('Benchmarking in-memory store (nocache for true compute cost)...');
const memResults = await benchmarkEndpoints(PORT_MEM, endpoints, true);
console.log('Benchmarking SQLite-only (nocache)...');
const sqlResults = await benchmarkEndpoints(PORT_SQL, endpoints, true);
// Also test cached in-memory for the full picture
console.log('Benchmarking in-memory store (cached)...');
const memCachedResults = await benchmarkEndpoints(PORT_MEM, endpoints, false);
// Kill servers
memServer.kill();
sqlServer.kill();
if (JSON_OUT) {
console.log(JSON.stringify({ memoryNocache: memResults, sqliteNocache: sqlResults, memoryCached: memCachedResults }, null, 2));
return;
}
// Print results
const W = 94;
console.log(`\n${'═'.repeat(W)}`);
console.log(' 🏁 BENCHMARK RESULTS: SQLite vs In-Memory Store');
console.log(`${'═'.repeat(W)}`);
console.log(`${'Endpoint'.padEnd(24)} ${'SQLite'.padStart(9)} ${'Memory'.padStart(9)} ${'Cached'.padStart(9)} ${'Speedup'.padStart(9)} ${'Size (SQL)'.padStart(10)} ${'Size (Mem)'.padStart(10)}`);
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(10)} ${'─'.repeat(10)}`);
for (let i = 0; i < endpoints.length; i++) {
const sql = sqlResults[i];
const mem = memResults[i];
const cached = memCachedResults[i];
if (!sql || sql.failed || !mem || mem.failed) {
console.log(`${endpoints[i].name.padEnd(24)} ${'FAILED'.padStart(9)}`);
continue;
}
const speedup = sql.avg > 0 && mem.avg > 0 ? Math.round(sql.avg / mem.avg) + '×' : '—';
const cachedStr = cached && !cached.failed ? fmt(cached.avg) : '—';
console.log(
`${sql.name.padEnd(24)} ${fmt(sql.avg).padStart(9)} ${fmt(mem.avg).padStart(9)} ${cachedStr.padStart(9)} ${speedup.padStart(9)} ${fmtSize(sql.bytes).padStart(10)} ${fmtSize(mem.bytes).padStart(10)}`
);
}
// Summary
const sqlTotal = sqlResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
const memTotal = memResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)}`);
console.log(`${'TOTAL'.padEnd(24)} ${fmt(sqlTotal).padStart(9)} ${fmt(memTotal).padStart(9)} ${''.padStart(9)} ${(Math.round(sqlTotal/memTotal)+'×').padStart(9)}`);
console.log(`\n${'═'.repeat(W)}\n`);
}
run().catch(e => { console.error(e); process.exit(1); });
-298
View File
@@ -1,298 +0,0 @@
{
"#LongFast": "2cc3d22840e086105ad73443da2cacb8",
"#MediumSlow": "99aa7084b6312841eb9b79b3a146bea4",
"#ShortFast": "18267412b697cb98344c4a44b044d04d",
"#ShortSlow": "8dffe23f9ed28b7d617fc587bdb19ec0",
"#LongSlow": "7f8722cce459fc6d452db4f5be59ba5e",
"#MediumFast": "7a5d6b6c3977df0e9a0929cd6fe98f5f",
"#LongModerate": "ca954bbfd33831fa3a2bb7018d3ab654",
"#ShortTurbo": "efe09f21c232292838d5c657a0ecb814",
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
"#public": "8b4b705b080c0d943b1c80f6b3ef6b6d",
"#general": "4c49f3f24629f5ee4ad5b3965db47985",
"#chat": "d0bdd6d71538138ed979eec00d98ad97",
"#local": "d2d35fa76be9875ed254db80397483a5",
"#emergency": "e1ad578d25108e344808f30dfdaaf926",
"#help": "dcc67fae2067046832af7b2b0b743165",
"#info": "ce51a275a0a0507c43d1651d78292320",
"#news": "ecadb1a7d803db8958bea1302ca6e8be",
"#weather": "88f502554fee92a1625cfb311546e7cb",
"#admin": "889334b7e486938c776dbdde120da9de",
"#mod": "8e238c8f71e508c849fa1743783359c9",
"#ops": "3b644de377c32c78793605a25aa915bf",
"#dev": "d41bcba61e9dca7177c7b8533d23a0bc",
"#debug": "fd7a60ed4796efcd1965c2de466105cb",
"#bot": "eb50a1bcb3e4e5d7bf69a57c9dada211",
"#bots": "0d24f5830b449668b8c221759b6c50d2",
"#sf": "a32c1fcfda0def959c305e4cd803def1",
"#bayarea": "7f9a5fd3070ad14e337ba100ca53a89b",
"#sfo": "1ecce5970716c9415b0411bf190944b1",
"#oakland": "c5a2f1d9f4433d041881447bf443084b",
"#sanjose": "ce964c28170b1c043d06073e6fcd83a7",
"#socal": "f4018307615ac79d2e5ef17bb44654d4",
"#la": "21349a74e68588be435f33abed117d84",
"#losangeles": "3dd9373dcea0294bd05ab067cc58e9d4",
"#sandiego": "08623bbd90a96ecdc1f5c34e7292b35f",
"#sacramento": "a6d6927f0b48762cf1346e2ae95cec14",
"#nyc": "6e6554655f84ca26fbc09d81d15d6b96",
"#newyork": "82a78024dd7edf6c9be298c919632e25",
"#brooklyn": "bcc6e13acd87570dcee4d5d87ac711e6",
"#manhattan": "56c90afc93b5bc55d1e3bdb1003dc2ef",
"#queens": "55ff7df317b63d269d878e51d125ced3",
"#seattle": "ef627a9bbbb549347fdb76bf0cd3bc14",
"#portland": "45c6bc719c15b9fb809f48f594359877",
"#pdx": "e75d6c892ff4d085e66701548c97acec",
"#denver": "b24355a0d22ed2bf393ec530d75810b4",
"#boulder": "eaa379d95ac9bbf857f499019ed0e8a5",
"#colorado": "ef61c9e5a3286053746f7603044bcb08",
"#austin": "b2e6f9af95d959734d71cdf90ca62533",
"#dallas": "5b2efa4e2ad0a2b83c5486ca4dd244de",
"#houston": "c001fbacad2d97676395ca37e2576345",
"#texas": "c4a214e133de5e9ad276f99fdbd7216f",
"#dfw": "5b7dc809ba579affccab5462c537244e",
"#chicago": "c1c289b131e5222370cbc2048445844b",
"#detroit": "bd01f26bf7d8c90952753157a41c61ac",
"#minneapolis": "3283cca82b7b0ac50e8014c344cb8a86",
"#stlouis": "f366422991a19e745f65096e59b43d51",
"#boston": "9587d847a7208da684c89cc1f525bc03",
"#philly": "9ff9182dd800e0be620dead724cbdf88",
"#philadelphia": "963ad5382e8d910ce0872958c5b36e6a",
"#dc": "0f3aa71fed514f5c16ebaf265ef05b2e",
"#washingtondc": "0067d451cc79f26bc9924b3fc53f28d7",
"#baltimore": "c5e649cacdc8fe661d5910d00c7c95ac",
"#atlanta": "3c8f15665f99a349a97427e7c312ee0e",
"#miami": "d81c566a5d337d588ebd250df6fc1b63",
"#tampa": "8d10508c39d5d8e6c5e3e8fab41d1c09",
"#orlando": "3553bdbd9b3a54da760624a27dcda156",
"#florida": "c44bd74eac2c81dcb6dfb217727b05cd",
"#phoenix": "027850d9410fa98809819c96644ec04c",
"#tucson": "cf989ccea881cf5ddcf40d87bbfc441e",
"#arizona": "8017183d8f9c01b660cd3663b8972e9d",
"#vegas": "d45435b6467e7ded33c28f6796bf5183",
"#lasvegas": "b82b823d6dcc845ead47cee8cfa758b3",
"#nevada": "9dc06c13ed0875b4a1acd545653fef33",
"#hawaii": "3fd57495820328594e1641d14583faa6",
"#honolulu": "81a4f1ae399448b8c10f8e761ba4e216",
"#maui": "cd08692b0cfbecfc06248bf8b8f10463",
"#alaska": "2a5841192b151422baec71537b0b5238",
"#anchorage": "2d87885e753b3231d33fd57dc53b5d69",
"#london": "9881d2b7ab9105a41a8d0f6ba449447e",
"#uk": "22b2eed34b5cc429ce1dc5e88635ff84",
"#berlin": "8bffd7b0bf481d92afc625e409b88a16",
"#germany": "0a834b4687fd4e09f72f6eeb3191ee25",
"#paris": "d0fc2b1ec400eb8669010ce0311a00fb",
"#france": "edcb362b38e74b99025a7e551d925d20",
"#amsterdam": "d768f5a0aa65f8c54e4ea521bd49eb4f",
"#netherlands": "cfc0a6c4004324e8adc99dfec1501943",
"#tokyo": "c574cca64e441dc3a414ef8047e8054d",
"#japan": "6c3db58db1c49d7e974971f675a66c89",
"#sydney": "57fe5284a5b905835193868d9bdbe1e9",
"#australia": "eadb84fa1da64c44b77c40fd11b9d78e",
"#melbourne": "4d73731d9450ccb9673eb923c0f40af2",
"#brisbane": "e4bd09784621decac600d0d4d857e3b2",
"#toronto": "e0d3774dd1da4dc5c55d8bd731555334",
"#vancouver": "16d6034be448ab86d11858cfa4c57c9c",
"#canada": "8373bb1055f34164c6dd2663927cb6d9",
"#montreal": "0c4c03b5fbea5b80f89e2a2a16ed3f40",
"#calgary": "bbbb1ad23fe1cdd7739667418204a57d",
"#mexico": "1e3806b6eccf8114ff7fc27ed8f84b0b",
"#cdmx": "042268f5d791d342d8c50c065ef0c50b",
"#brasil": "47d6242d2c1dd0f1e0eee4f0df64b2c1",
"#brazil": "aac6e31471f27feac8da78793bed9690",
"#ham": "5270db3979da687fa133fec6684cd952",
"#radio": "266f225baced1b2a868dcc8e9c69a304",
"#mesh": "5b664cde0b08b220612113db980650f3",
"#lora": "0749ea267c6be7b54ca1dbbae7dba0aa",
"#meshtastic": "73a2e13ff0dea9ed19b24b2ab753650f",
"#meshcore": "2fa78a5aef618e7c2a78f0ab5c8869b3",
"#offgrid": "aaa26662bebf122262692d0bca61dcef",
"#prepper": "1eec1a7df7080a392cb490473c4a9920",
"#preppers": "4877173813de9668b9ef33adbc1b8f37",
"#survival": "e1f465ea51df09fc901389758f1c5f01",
"#shtf": "9321638017bd7f42ca4468726cd06893",
"#emcomm": "a9a49340642dfc4c562d7849b7c8a258",
"#ares": "44419e394cde859e45710f288db939a8",
"#cert": "828d37872695b8b47e537164fb1570cf",
"#skywarn": "46e8175d5a3b373985eb471a3ad479f8",
"#hike": "a8f9964431372ed34db9088d03362f6f",
"#hiking": "2370a013053e384e5f18918bc2b26baf",
"#camping": "c011b7c2abf33748cd9bd5a78c2b4955",
"#outdoors": "b4c10e8ecfe10ef66cae6299ae29d488",
"#trail": "9b7f5ade7e2bff2eaaeebfd0333401a5",
"#bike": "22a682f2c0f3011aab4510c533278413",
"#cycling": "795980fbfe059133a2fc47c2c210f127",
"#wardrive": "4076c315c1ef385fa93f066027320fe5",
"#wardriving": "e3c26491e9cd321e3a6be50d57d54acf",
"#security": "b7123ea6c2dcbf7332a198ddd6612da9",
"#infosec": "364aa6797508df1cf248c8983ec968bd",
"#cyber": "bc435860170598cb9b1c7cc6938c8be5",
"#hacker": "98010d08107a5bc0b7f41ce3c93cba99",
"#hackers": "bb6c2edeb9a25b4f77398a687acfdb85",
"#maker": "04cf78295deab8c76fa3236f8a87612e",
"#makers": "4d8febe1979910912d7f8d11ca8ce689",
"#diy": "2947bea0668269732f29808d2b9c8fed",
"#electronics": "34d8645bda0ba7e4a7c78a1f9f3ed1ac",
"#arduino": "ce8c596922eff9274f3b1e19ad296754",
"#raspberry": "469bc476d8263b64b6f90ca868b04b91",
"#esp32": "9f8aac4c48973b07bbdc47acbaea3e8e",
"#linux": "02c74eda5d8ec8b9bb945049fb2f55c6",
"#unix": "a4c8cd0f130f2f9801d1afa2d0a52a2f",
"#tech": "5177b749eefbfab3c90a21b4e2518c5f",
"#code": "2a8a567349cce15a48fd5d81709424f4",
"#coding": "becdff6841b6f36610e97ac89c3a40ea",
"#programming": "bce55c792ec60d79530a2eec9a8ede14",
"#yo": "51f93a1e79f96333fe5d0c8eb3bed7c3",
"#sup": "153ec8634ac55727e006e37c39b29f16",
"#hello": "dc9fe3652402447479779b06609a22a5",
"#hi": "e411034bd14b17b5e39a76b5a5b4f348",
"#hey": "3972b5f0fe9a438f260ffc1d125eefdb",
"#random": "bda30db2910b90b1bc5a608a1b5f0ee4",
"#offtopic": "4bfd513d655d6e435bd8f4d6b863e500",
"#memes": "5b36bb8722a8c1741127266299439cdd",
"#fun": "ecfceab52a3d730051d2ce6adea30f78",
"#music": "b025ab29b0a5f56fb68a474741d7a2cc",
"#gaming": "3802c79121f195ea50ad9ab2aa2c402a",
"#games": "ddffe2cfbf037caed279d02c41b74f5f",
"#sports": "e8ee81f3aabf105d9ba2d2d4bd94fe4a",
"#food": "7b4f27d6b5bbf5f8eb3bfc6f43770fdd",
"#beer": "8fbe47b032102949554ac78fcd583560",
"#coffee": "82cd4bd9e7dda8cae0854281246cc64f",
"#queer": "5754476f162d93bbee3de0efba136860",
"#lgbtq": "7d71d54a2bb4bbd7352322a59126d7fb",
"#pride": "c732b12b15a5bca3cff2e39b7480baca",
"#bookclub": "b803ab3fbb867737ab5b7d32914d7e67",
"#books": "b2fdc9c9313dad4515527cade7d67021",
"#reading": "46d531186b6148726a97a62324f8a356",
"#film": "35ed875bfa83f29298e81f83c3c56c1e",
"#movies": "15cea29e9e62887c767cabec8c601ebe",
"#dogs": "e956e2b054b795c129a5daab4aad0be1",
"#cats": "4aeaf541d243ea9f84abc406b7eba360",
"#pets": "6f36ecba946c2eba3a7a9c694c53f23a",
"#1": "0b0fa0b2280a09639e2059e56c8fa932",
"#2": "b6b52c0e41dd6e18a31636deb586175b",
"#3": "ea46599a238699be9409316928670559",
"#4": "7e3cfd9c828a75671a34898112caa743",
"#5": "7aff87c72ca0f13d288b3418c89f67e9",
"#42": "2bccaf40951009e4203e2065b2a4bde0",
"#69": "0285b036b9837b5babac54668e623ce0",
"#420": "d4346dd20f9a85256cff48f33f46de0a",
"#1337": "817d01d43c5960c1ceaf9a2467182675",
"#a": "302d59c4f9e75750166105ea1d8b3673",
"#b": "dd883973c3c017ed51c9e10fba7bca0b",
"#c": "3732c64f873466d50e0badb3f8d79faf",
"#aa": "e147f36926b7b509af9b41b65304dc30",
"#ab": "7ee8192c80507041253e255dcc7e6f87",
"#abc": "00e16e1d31c0ba2b3b3d17583bb2ac3b",
"#norcal": "f2438510715f5d9d55eb4370664330f5",
"#nocal": "819836f0049f5dca9596cd681d0cbab8",
"#bay": "80e5fadb907564764eb09d2667a02638",
"#eastbay": "4c2e48f600e4952346441278ac363432",
"#southbay": "3c9e372b38917334d1091419f32bed8a",
"#northbay": "8dfb7427cc1e5abab25bc16e8ae4373c",
"#peninsula": "7f2ce5480359431d2f3ca259bf8bef68",
"#marin": "a084727e9d2d2afcc73f49055c6f7764",
"#pnw": "98059014c708581fbf0a398cfe8a486d",
"#pacnw": "49cc714305cf0a61817a01114414d490",
"#cascadia": "1313c4078af5c36040bec10115c04806",
"#midwest": "cf7910dacee35da8b90da21a0e37fea7",
"#northeast": "3b1ca0ae6003193eb9f91984eefef5dc",
"#southeast": "7b4392ed3c3fdde98cdb167c2f0f2c8b",
"#southwest": "e17c45726653035f7a90a6b57b5a0d57",
"#socalmesh": "df9e74198a7c334964f18f200a065e33",
"#sfmesh": "89454fcff893b5a2ecc16d886e9cf3b3",
"#nycmesh": "021be2e194650cc5d3ea77618eea817f",
"#atlmesh": "50b8266c71b3d3ee0253d462b34f6b2b",
"#wx": "472dd8595b8fd0ab542b3e86a379a620",
"#fire": "3d74a070077293ab66baf3aa724349ff",
"#earthquake": "0c7082c04a1a90502a5f32fc6a9f6524",
"#flood": "f2a0fd0abe4c9fc6865b5f8eebf319d6",
"#tsunami": "153ddc83452935d8486ab3d34dd6d313",
"#storm": "113761b9e31a5c30e0ee4fc78dc7310b",
"#alert": "678c7d2e08c019e113ace03eaaa128ad",
"#alerts": "b8212240d8b433b54db46906738e2094",
"#sos": "9ed2c78bcd68ac7ce2a2fc3bb4045114",
"#rescue": "8fefdc46995fd86cf1265a96e25e9be1",
"#missing": "bb627c3e98fb103e54b203a11d2c1a8c",
"#evac": "c20a9bf0eabefaf3dd3553bb5df19b61",
"#shelter": "b11ebee14de380147b2f0b613c82ee84",
"#default": "66e7fd8b7b4caa5dc98e752d43044d30",
"#main": "512ba51f98c27b93cd2ff6fbc2c0fad1",
"#primary": "3e417c7ce555fa7dcb705a94cbb358e4",
"#secondary": "69abb13534f87ebfedc0d92797da1fbe",
"#backup": "1fa50c46f15c60363567cae7982b8394",
"#private": "bd072d2fbd62a89db08b2a9e6976cc36",
"#secret": "be657c0527e122bf93bc735999cd7e0d",
"#hidden": "f78143af9f1168c243729b1bf6bb3235",
"#invite": "d46e531ee7d3b591fcf2dfc9e23e63e1",
"#repeater": "289991a3077903263f2d31493887c651",
"#repeaters": "89db441e2814dccf0dbd2e8cc5f501a3",
"#node": "85cdc068443a7bf5b9435423c40dbefd",
"#nodes": "d2b5d06216710d4dbef3de9d08168a53",
"#gateway": "73feacb0c27f83b3d2db143823efb891",
"#server": "11c4e843fc066c8bfe01719cfca1fb1e",
"#gisborne": "d8b45a1eb52d0bd45655d5f2a72d571f",
"#wellington": "c2da57d74f78996ad71bbe3e22446f16",
"#auckland": "7c6f1c71a5a3d6823a1d7bfd2a349ac8",
"#nz": "eb87ee8817ba71315ac7be9c733b523a",
"#newzealand": "1870a961e4aa06f62b02b835efcacb71",
"#spain": "49217e19fa0d5c28bd02fd6b688dd11d",
"#madrid": "8886480d4b99f6882328d9068d0c6235",
"#barcelona": "919a4a5d9522320ca9c95dceb92a5544",
"#italy": "f8d4dd36f6b9476eb4cec18a1536b17d",
"#rome": "4c3729aca56d948088bd25750e8d7d33",
"#milan": "71cd9f729336b7a1110f77bb93c43e9e",
"#sweden": "d6bcdf00baf5d2d981846d2b47ba5b42",
"#stockholm": "889483e6b54ca5bf8cbb2af23e1ab7f0",
"#norway": "0ed3e16f327a787d5ff4c4496bb4c4a9",
"#oslo": "1a06f287bc45a8cf14a304f898cc1fea",
"#finland": "cc012bb6718f447824c8ba7cd81b7fbd",
"#helsinki": "6bd215dc2f6f8833a309eba8d4ba57d7",
"#denmark": "7bb81ff9d3eb29f091d1d64e044b2a79",
"#copenhagen": "76c1331a0d13d081988a379c60ad59bb",
"#poland": "e63d27631c0f74aca88a6b91efcc7067",
"#warsaw": "86afa156c7f0567c97ea03df07482888",
"#czech": "0a15067478a2b8e74177c8fc68e4001e",
"#prague": "564cb31b3895393f22f0f50354389334",
"#austria": "faaa5ef01081222e319a8205357321f4",
"#vienna": "8e52cd2b9ff13fb0030fd41714edc95e",
"#switzerland": "8ad1ce57ad257627090ed28413c1f0b7",
"#zurich": "95a7261009cdcb13d22e8f8d532f3ba5",
"#portugal": "11f13d9d06c892574a277337967a7267",
"#lisbon": "6c49a07fd953c5856df538d5dce0b19e",
"#ireland": "1b2a12acc5db1517d9d407946756b1da",
"#dublin": "8792638977132bc05a1f72d6bb913694",
"#scotland": "f4fce403cfd56f7089920d065718f29c",
"#edinburgh": "72d70a8e87b0e7f8072239d68ffccc9e",
"#wales": "809573d8134fa262d284400a788f63d9",
"#india": "bedb569c4d55038e801985e87bc311cb",
"#mumbai": "408b4cecfb253c8150cadd5da8925b1f",
"#delhi": "4974162f580211f17187d1a16cab2514",
"#bangalore": "f87f1fcf72618fbb5d36642847859df0",
"#korea": "38ea37fe7de4691145f8e200a3fc6976",
"#seoul": "d6e3685ad1ce9d943c34594321eb3d75",
"#taiwan": "489ff602625eb18ae5f457fb70e149cd",
"#taipei": "257f2ea07b93df20b8ef8a69459cc541",
"#singapore": "524771d953b40e8880e00d7250f02c42",
"#malaysia": "a51ef9684d30551eb7fe4faa00c4dd64",
"#thailand": "6ddc1b15fd53b35a8c1a7e8bb720d5ac",
"#bangkok": "68b4467f7e1a99705143a6a5ebcfb8e8",
"#vietnam": "9a6514b712cbc8a1be0663591c6a6e13",
"#indonesia": "2be2d4470d8cc641ce69dfe6497a2842",
"#jakarta": "5b931a38c29a24ec8395c23421248138",
"#philippines": "74d160fb0ad7867295730d41351dd21a",
"#manila": "48a840132b292a2dcde0ef0d10c3149b",
"#southafrica": "35bb5bcec03c0c7ba256bdc948108a1c",
"#capetown": "a16101e1fa482e43dcb4b35ab836b3f3",
"#nairobi": "44cf6c94cdcb61d576c8855935997260",
"#kenya": "5bde28964c3008a6741f593d3b70c78e",
"#nigeria": "9a20897b3b223ee02ba9eebc43ac2300",
"#lagos": "c1d000aa764a45ba0d992d0289137991",
"#argentina": "22304ee269f9623972776a4d1d306afd",
"#buenosaires": "43ece797139ce4051cf62568a6a28c2b",
"#chile": "15f352f255947e485b845652791f3354",
"#santiago": "9d7f9df716281124aa16d27a45b2ff5f",
"#colombia": "bea223a8c1d13ed9638ee000ea3a6aca",
"#bogota": "6d0864985b64350ce4cbfebf4979e970",
"#peru": "7e6fc347bf29a4c128ac3156865bd521",
"#lima": "5f167ce354eca08ab742463df10ef255"
}
+11 -126
View File
@@ -1,141 +1,26 @@
{
"port": 3000,
"apiKey": "your-secret-api-key-here",
"https": {
"cert": "/path/to/cert.pem",
"key": "/path/to/key.pem"
},
"branding": {
"siteName": "MeshCore Analyzer",
"tagline": "Real-time MeshCore LoRa mesh network analyzer",
"logoUrl": null,
"faviconUrl": null
},
"theme": {
"accent": "#4a9eff",
"accentHover": "#6db3ff",
"navBg": "#0f0f23",
"navBg2": "#1a1a2e",
"statusGreen": "#45644c",
"statusYellow": "#b08b2d",
"statusRed": "#b54a4a"
},
"nodeColors": {
"repeater": "#dc2626",
"companion": "#2563eb",
"room": "#16a34a",
"sensor": "#d97706",
"observer": "#8b5cf6"
},
"home": {
"heroTitle": "MeshCore Analyzer",
"heroSubtitle": "Find your nodes to start monitoring them.",
"steps": [
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" },
{ "emoji": "📊", "title": "Analyze", "description": "Understand your network's health" }
],
"checklist": [
{ "question": "How do I add my node?", "answer": "Search for your node name or paste your public key." },
{ "question": "What regions are covered?", "answer": "Check the map page to see active observers and nodes." }
],
"footerLinks": [
{ "label": "📦 Packets", "url": "#/packets" },
{ "label": "🗺️ Network Map", "url": "#/map" },
{ "label": "🔴 Live", "url": "#/live" },
{ "label": "📡 All Nodes", "url": "#/nodes" },
{ "label": "💬 Channels", "url": "#/channels" }
]
},
"mqtt": {
"broker": "mqtt://localhost:1883",
"topic": "meshcore/+/+/packets"
},
"mqttSources": [
{
"name": "local",
"broker": "mqtt://localhost:1883",
"topics": [
"meshcore/+/+/packets",
"meshcore/#"
]
},
{
"name": "lincomatic",
"broker": "mqtts://mqtt.lincomatic.com:8883",
"username": "your-username",
"password": "your-password",
"rejectUnauthorized": false,
"topics": [
"meshcore/SJC/#",
"meshcore/SFO/#",
"meshcore/OAK/#",
"meshcore/MRY/#"
],
"iataFilter": [
"SJC",
"SFO",
"OAK",
"MRY"
]
}
],
"channelKeys": {
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
"#sf": "a32c1fcfda0def959c305e4cd803def1",
"#wardrive": "4076c315c1ef385fa93f066027320fe5",
"#yo": "51f93a1e79f96333fe5d0c8eb3bed7c3",
"#bot": "eb50a1bcb3e4e5d7bf69a57c9dada211",
"#queer": "5754476f162d93bbee3de0efba136860",
"#bookclub": "b803ab3fbb867737ab5b7d32914d7e67",
"#shtf": "9321638017bd7f42ca4468726cd06893"
},
"hashChannels": [
"#LongFast",
"#test",
"#sf",
"#wardrive",
"#yo",
"#bot",
"#queer",
"#bookclub",
"#shtf"
],
"defaultRegion": "SJC",
"mapDefaults": {
"center": [
37.45,
-122.0
],
"zoom": 9
},
"regions": {
"SJC": "San Jose, US",
"SFO": "San Francisco, US",
"OAK": "Oakland, US",
"MRY": "Monterey, US"
},
"cacheTTL": {
"stats": 10,
"nodeDetail": 300,
"nodeHealth": 300,
"nodeList": 90,
"bulkHealth": 600,
"networkStatus": 600,
"observers": 300,
"channels": 15,
"channelMessages": 10,
"analyticsRF": 1800,
"analyticsTopology": 1800,
"analyticsChannels": 1800,
"analyticsHashSizes": 3600,
"analyticsSubpaths": 3600,
"analyticsSubpathDetail": 3600,
"nodeAnalytics": 60,
"nodeSearch": 10,
"invalidationDebounce": 30,
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
},
"liveMap": {
"propagationBufferMs": 5000,
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
},
"packetStore": {
"maxMemoryMB": 1024,
"estimatedPacketBytes": 450,
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. All packets loaded on startup, served from RAM."
"MRY": "Monterey, US",
"LAR": "Los Angeles, US"
}
}
+130 -463
View File
@@ -10,21 +10,28 @@ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('wal_autocheckpoint = 0'); // Disable auto-checkpoint — manual checkpoint on timer to avoid random event loop spikes
// --- Migration: drop legacy tables (replaced by transmissions + observations in v2.3.0) ---
// Drop paths first (has FK to packets)
const legacyTables = ['paths', 'packets'];
for (const t of legacyTables) {
const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(t);
if (exists) {
console.log(`[migration] Dropping legacy table: ${t}`);
db.exec(`DROP TABLE IF EXISTS ${t}`);
}
}
// --- Schema ---
db.exec(`
CREATE TABLE IF NOT EXISTS packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
timestamp TEXT NOT NULL,
observer_id TEXT,
observer_name TEXT,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
hash TEXT,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
path_json TEXT,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS nodes (
public_key TEXT PRIMARY KEY,
name TEXT,
@@ -42,259 +49,30 @@ db.exec(`
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0,
model TEXT,
firmware TEXT,
client_version TEXT,
radio TEXT,
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor INTEGER
packet_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
packet_id INTEGER REFERENCES packets(id),
hop_index INTEGER,
node_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp);
CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(hash);
CREATE INDEX IF NOT EXISTS idx_packets_payload_type ON packets(payload_type);
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
CREATE INDEX IF NOT EXISTS idx_observers_last_seen ON observers(last_seen);
CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
`);
// --- Determine schema version ---
let schemaVersion = db.pragma('user_version', { simple: true }) || 0;
// Migrate from old schema_version table to pragma user_version
if (schemaVersion === 0) {
try {
const row = db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
if (row && row.version >= 3) {
db.pragma(`user_version = ${row.version}`);
schemaVersion = row.version;
db.exec('DROP TABLE IF EXISTS schema_version');
}
} catch {}
}
// Detect v3 schema by column presence (handles crash between migration and version write)
if (schemaVersion === 0) {
try {
const cols = db.pragma('table_info(observations)').map(c => c.name);
if (cols.includes('observer_idx') && !cols.includes('observer_id')) {
db.pragma('user_version = 3');
schemaVersion = 3;
console.log('[migration-v3] Detected already-migrated schema, set user_version = 3');
}
} catch {}
}
// --- v3 migration: lean observations table ---
function needsV3Migration() {
if (schemaVersion >= 3) return false;
// Check if observations table exists with old observer_id TEXT column
const obsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations'").get();
if (!obsExists) return false;
const cols = db.pragma('table_info(observations)').map(c => c.name);
return cols.includes('observer_id');
}
function runV3Migration() {
const startTime = Date.now();
console.log('[migration-v3] Starting observations table optimization...');
// a. Backup DB
const backupPath = dbPath + `.pre-v3-backup-${Date.now()}`;
try {
console.log(`[migration-v3] Backing up DB to ${backupPath}...`);
fs.copyFileSync(dbPath, backupPath);
console.log(`[migration-v3] Backup complete (${Date.now() - startTime}ms)`);
} catch (e) {
console.error(`[migration-v3] Backup failed, aborting migration: ${e.message}`);
return false;
}
try {
// b. Create lean table
let stepStart = Date.now();
db.exec(`
CREATE TABLE observations_v3 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
)
`);
console.log(`[migration-v3] Created observations_v3 table (${Date.now() - stepStart}ms)`);
// c. Migrate data
stepStart = Date.now();
const result = db.prepare(`
INSERT INTO observations_v3 (id, transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp)
SELECT o.id, o.transmission_id, obs.rowid, o.direction, o.snr, o.rssi, o.score, o.path_json,
CAST(strftime('%s', o.timestamp) AS INTEGER)
FROM observations o
LEFT JOIN observers obs ON obs.id = o.observer_id
`).run();
console.log(`[migration-v3] Migrated ${result.changes} rows (${Date.now() - stepStart}ms)`);
// d. Drop view, old table, rename
stepStart = Date.now();
db.exec('DROP VIEW IF EXISTS packets_v');
db.exec('DROP TABLE observations');
db.exec('ALTER TABLE observations_v3 RENAME TO observations');
console.log(`[migration-v3] Replaced observations table (${Date.now() - stepStart}ms)`);
// f. Create indexes
stepStart = Date.now();
db.exec(`
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
`);
console.log(`[migration-v3] Created indexes (${Date.now() - stepStart}ms)`);
// g. Set schema version
db.pragma('user_version = 3');
schemaVersion = 3;
// h. Rebuild view (done below in common code)
// i. VACUUM + checkpoint
stepStart = Date.now();
db.exec('VACUUM');
db.pragma('wal_checkpoint(TRUNCATE)');
console.log(`[migration-v3] VACUUM + checkpoint complete (${Date.now() - stepStart}ms)`);
console.log(`[migration-v3] Migration complete! Total time: ${Date.now() - startTime}ms`);
return true;
} catch (e) {
console.error(`[migration-v3] Migration failed: ${e.message}`);
console.error('[migration-v3] Restore from backup if needed: ' + dbPath + '.pre-v3-backup');
// Try to clean up v3 table if it exists
try { db.exec('DROP TABLE IF EXISTS observations_v3'); } catch {}
return false;
}
}
const isV3 = schemaVersion >= 3;
if (!isV3 && needsV3Migration()) {
runV3Migration();
}
// If user_version < 3 and no migration happened (fresh DB or migration skipped), create old-style table
if (schemaVersion < 3) {
const obsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations'").get();
if (!obsExists) {
// Fresh DB — create v3 schema directly
db.exec(`
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
);
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
`);
db.pragma('user_version = 3');
schemaVersion = 3;
} else {
// Old-style observations table exists but migration wasn't run (or failed)
// Ensure indexes exist for old schema
db.exec(`
CREATE INDEX IF NOT EXISTS idx_observations_hash ON observations(hash);
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX IF NOT EXISTS idx_observations_observer_id ON observations(observer_id);
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
`);
// Dedup cleanup for old schema
try {
db.exec(`DROP INDEX IF EXISTS idx_observations_dedup`);
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_dedup ON observations(hash, observer_id, COALESCE(path_json, ''))`);
db.exec(`DELETE FROM observations WHERE id NOT IN (SELECT MIN(id) FROM observations GROUP BY hash, observer_id, COALESCE(path_json, ''))`);
} catch {}
}
}
// --- Create/rebuild packets_v view ---
db.exec('DROP VIEW IF EXISTS packets_v');
if (schemaVersion >= 3) {
db.exec(`
CREATE VIEW packets_v AS
SELECT o.id, t.raw_hex,
datetime(o.timestamp, 'unixepoch') AS timestamp,
obs.id AS observer_id, obs.name AS observer_name,
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
t.payload_type, t.payload_version, o.path_json, t.decoded_json,
t.created_at
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
`);
} else {
db.exec(`
CREATE VIEW packets_v AS
SELECT o.id, t.raw_hex, o.timestamp, o.observer_id, o.observer_name,
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
t.payload_type, t.payload_version, o.path_json, t.decoded_json,
t.created_at
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
`);
}
// --- Migrations for existing DBs ---
const observerCols = db.pragma('table_info(observers)').map(c => c.name);
for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv', 'uptime_secs', 'noise_floor']) {
if (!observerCols.includes(col)) {
const type = ['battery_mv', 'uptime_secs', 'noise_floor'].includes(col) ? 'INTEGER' : 'TEXT';
db.exec(`ALTER TABLE observers ADD COLUMN ${col} ${type}`);
console.log(`[migration] Added observers.${col}`);
}
}
// --- Cleanup corrupted nodes on startup ---
// Remove nodes with obviously invalid data (short pubkeys, control chars in names, etc.)
{
const cleaned = db.prepare(`
DELETE FROM nodes WHERE
length(public_key) < 16
OR public_key GLOB '*[^0-9a-fA-F]*'
OR (lat IS NOT NULL AND (lat < -90 OR lat > 90))
OR (lon IS NOT NULL AND (lon < -180 OR lon > 180))
`).run();
if (cleaned.changes > 0) console.log(`[cleanup] Removed ${cleaned.changes} corrupted node(s) from DB`);
}
// --- Prepared statements ---
const stmts = {
insertPacket: db.prepare(`
INSERT INTO packets (raw_hex, timestamp, observer_id, observer_name, direction, snr, rssi, score, hash, route_type, payload_type, payload_version, path_json, decoded_json)
VALUES (@raw_hex, @timestamp, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @hash, @route_type, @payload_type, @payload_version, @path_json, @decoded_json)
`),
insertPath: db.prepare(`INSERT INTO paths (packet_id, hop_index, node_hash) VALUES (?, ?, ?)`),
upsertNode: db.prepare(`
INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (@public_key, @name, @role, @lat, @lon, @last_seen, @first_seen, 1)
@@ -307,167 +85,57 @@ const stmts = {
advert_count = advert_count + 1
`),
upsertObserver: db.prepare(`
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
VALUES (@id, @name, @iata, @last_seen, @first_seen, 1, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES (@id, @name, @iata, @last_seen, @first_seen, 1)
ON CONFLICT(id) DO UPDATE SET
name = COALESCE(@name, name),
iata = COALESCE(@iata, iata),
last_seen = @last_seen,
packet_count = packet_count + 1,
model = COALESCE(@model, model),
firmware = COALESCE(@firmware, firmware),
client_version = COALESCE(@client_version, client_version),
radio = COALESCE(@radio, radio),
battery_mv = COALESCE(@battery_mv, battery_mv),
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
noise_floor = COALESCE(@noise_floor, noise_floor)
packet_count = packet_count + 1
`),
updateObserverStatus: db.prepare(`
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
VALUES (@id, @name, @iata, @last_seen, @first_seen, 0, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
ON CONFLICT(id) DO UPDATE SET
name = COALESCE(@name, name),
iata = COALESCE(@iata, iata),
last_seen = @last_seen,
model = COALESCE(@model, model),
firmware = COALESCE(@firmware, firmware),
client_version = COALESCE(@client_version, client_version),
radio = COALESCE(@radio, radio),
battery_mv = COALESCE(@battery_mv, battery_mv),
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
noise_floor = COALESCE(@noise_floor, noise_floor)
`),
getPacket: db.prepare(`SELECT * FROM packets_v WHERE id = ?`),
getPacket: db.prepare(`SELECT * FROM packets WHERE id = ?`),
getPathsForPacket: db.prepare(`SELECT * FROM paths WHERE packet_id = ? ORDER BY hop_index`),
getNode: db.prepare(`SELECT * FROM nodes WHERE public_key = ?`),
getRecentPacketsForNode: db.prepare(`
SELECT * FROM packets_v WHERE decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ?
SELECT * FROM packets WHERE decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ?
ORDER BY timestamp DESC LIMIT 20
`),
getObservers: db.prepare(`SELECT * FROM observers ORDER BY last_seen DESC`),
countPackets: db.prepare(`SELECT COUNT(*) as count FROM observations`),
countPackets: db.prepare(`SELECT COUNT(*) as count FROM packets`),
countNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
countObservers: db.prepare(`SELECT COUNT(*) as count FROM observers`),
countRecentPackets: schemaVersion >= 3
? db.prepare(`SELECT COUNT(*) as count FROM observations WHERE timestamp > CAST(strftime('%s', ?) AS INTEGER)`)
: db.prepare(`SELECT COUNT(*) as count FROM observations WHERE timestamp > ?`),
getTransmissionByHash: db.prepare(`SELECT id, first_seen FROM transmissions WHERE hash = ?`),
insertTransmission: db.prepare(`
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
VALUES (@raw_hex, @hash, @first_seen, @route_type, @payload_type, @payload_version, @decoded_json)
`),
updateTransmissionFirstSeen: db.prepare(`UPDATE transmissions SET first_seen = @first_seen WHERE id = @id`),
insertObservation: schemaVersion >= 3
? db.prepare(`
INSERT OR IGNORE INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp)
VALUES (@transmission_id, @observer_idx, @direction, @snr, @rssi, @score, @path_json, @timestamp)
`)
: db.prepare(`
INSERT OR IGNORE INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
VALUES (@transmission_id, @hash, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @path_json, @timestamp)
`),
getObserverRowid: db.prepare(`SELECT rowid FROM observers WHERE id = ?`),
countRecentPackets: db.prepare(`SELECT COUNT(*) as count FROM packets WHERE timestamp > ?`),
};
// --- In-memory observer map (observer_id text → rowid integer) ---
const observerIdToRowid = new Map();
if (schemaVersion >= 3) {
const rows = db.prepare('SELECT id, rowid FROM observers').all();
for (const r of rows) observerIdToRowid.set(r.id, r.rowid);
}
// --- In-memory dedup set for v3 ---
const dedupSet = new Map(); // key → timestamp (for cleanup)
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
function cleanupDedupSet() {
const cutoff = Date.now() - DEDUP_TTL_MS;
for (const [key, ts] of dedupSet) {
if (ts < cutoff) dedupSet.delete(key);
}
}
// Periodic cleanup every 60s
setInterval(cleanupDedupSet, 60000).unref();
function resolveObserverIdx(observerId) {
if (!observerId) return null;
let rowid = observerIdToRowid.get(observerId);
if (rowid !== undefined) return rowid;
// Try DB lookup (observer may have been inserted elsewhere)
const row = stmts.getObserverRowid.get(observerId);
if (row) {
observerIdToRowid.set(observerId, row.rowid);
return row.rowid;
}
return null;
}
// --- Helper functions ---
function insertTransmission(data) {
const hash = data.hash;
if (!hash) return null;
function insertPacket(data) {
const d = {
raw_hex: data.raw_hex,
timestamp: data.timestamp || new Date().toISOString(),
observer_id: data.observer_id || null,
observer_name: data.observer_name || null,
direction: data.direction || null,
snr: data.snr ?? null,
rssi: data.rssi ?? null,
score: data.score ?? null,
hash: data.hash || null,
route_type: data.route_type ?? null,
payload_type: data.payload_type ?? null,
payload_version: data.payload_version ?? null,
path_json: data.path_json || null,
decoded_json: data.decoded_json || null,
};
return stmts.insertPacket.run(d).lastInsertRowid;
}
const timestamp = data.timestamp || new Date().toISOString();
let transmissionId;
const existing = stmts.getTransmissionByHash.get(hash);
if (existing) {
transmissionId = existing.id;
if (timestamp < existing.first_seen) {
stmts.updateTransmissionFirstSeen.run({ id: transmissionId, first_seen: timestamp });
function insertPath(packetId, hops) {
const tx = db.transaction((hops) => {
for (let i = 0; i < hops.length; i++) {
stmts.insertPath.run(packetId, i, hops[i]);
}
} else {
const result = stmts.insertTransmission.run({
raw_hex: data.raw_hex || '',
hash,
first_seen: timestamp,
route_type: data.route_type ?? null,
payload_type: data.payload_type ?? null,
payload_version: data.payload_version ?? null,
decoded_json: data.decoded_json || null,
});
transmissionId = result.lastInsertRowid;
}
let obsResult;
if (schemaVersion >= 3) {
const observerIdx = resolveObserverIdx(data.observer_id);
const epochTs = typeof timestamp === 'number' ? timestamp : Math.floor(new Date(timestamp).getTime() / 1000);
// In-memory dedup check
const dedupKey = `${transmissionId}|${observerIdx}|${data.path_json || ''}`;
if (dedupSet.has(dedupKey)) {
return { transmissionId, observationId: 0 };
}
obsResult = stmts.insertObservation.run({
transmission_id: transmissionId,
observer_idx: observerIdx,
direction: data.direction || null,
snr: data.snr ?? null,
rssi: data.rssi ?? null,
score: data.score ?? null,
path_json: data.path_json || null,
timestamp: epochTs,
});
dedupSet.set(dedupKey, Date.now());
} else {
obsResult = stmts.insertObservation.run({
transmission_id: transmissionId,
hash,
observer_id: data.observer_id || null,
observer_name: data.observer_name || null,
direction: data.direction || null,
snr: data.snr ?? null,
rssi: data.rssi ?? null,
score: data.score ?? null,
path_json: data.path_json || null,
timestamp,
});
}
return { transmissionId, observationId: obsResult.lastInsertRowid };
});
tx(hops);
}
function upsertNode(data) {
@@ -491,36 +159,6 @@ function upsertObserver(data) {
iata: data.iata || null,
last_seen: data.last_seen || now,
first_seen: data.first_seen || now,
model: data.model || null,
firmware: data.firmware || null,
client_version: data.client_version || null,
radio: data.radio || null,
battery_mv: data.battery_mv || null,
uptime_secs: data.uptime_secs || null,
noise_floor: data.noise_floor || null,
});
// Update in-memory map for v3
if (schemaVersion >= 3 && !observerIdToRowid.has(data.id)) {
const row = stmts.getObserverRowid.get(data.id);
if (row) observerIdToRowid.set(data.id, row.rowid);
}
}
function updateObserverStatus(data) {
const now = new Date().toISOString();
stmts.updateObserverStatus.run({
id: data.id,
name: data.name || null,
iata: data.iata || null,
last_seen: data.last_seen || now,
first_seen: data.first_seen || now,
model: data.model || null,
firmware: data.firmware || null,
client_version: data.client_version || null,
radio: data.radio || null,
battery_mv: data.battery_mv || null,
uptime_secs: data.uptime_secs || null,
noise_floor: data.noise_floor || null,
});
}
@@ -532,20 +170,15 @@ function getPackets({ limit = 50, offset = 0, type, route, hash, since } = {}) {
if (hash) { where.push('hash = @hash'); params.hash = hash; }
if (since) { where.push('timestamp > @since'); params.since = since; }
const clause = where.length ? 'WHERE ' + where.join(' AND ') : '';
const rows = db.prepare(`SELECT * FROM packets_v ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
const total = db.prepare(`SELECT COUNT(*) as count FROM packets_v ${clause}`).get(params).count;
const rows = db.prepare(`SELECT * FROM packets ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
const total = db.prepare(`SELECT COUNT(*) as count FROM packets ${clause}`).get(params).count;
return { rows, total };
}
function getTransmission(id) {
try {
return db.prepare('SELECT * FROM transmissions WHERE id = ?').get(id) || null;
} catch { return null; }
}
function getPacket(id) {
const packet = stmts.getPacket.get(id);
if (!packet) return null;
packet.paths = stmts.getPathsForPacket.all(id);
return packet;
}
@@ -579,23 +212,57 @@ function getObservers() {
function getStats() {
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
// Try to get transmission count from normalized schema
let totalTransmissions = null;
try {
totalTransmissions = db.prepare('SELECT COUNT(*) as count FROM transmissions').get().count;
} catch {}
return {
totalPackets: totalTransmissions || stmts.countPackets.get().count,
totalTransmissions,
totalObservations: stmts.countPackets.get().count,
totalPackets: stmts.countPackets.get().count,
totalNodes: stmts.countNodes.get().count,
totalObservers: stmts.countObservers.get().count,
packetsLastHour: stmts.countRecentPackets.get(oneHourAgo).count,
};
}
function seed() {
if (stmts.countPackets.get().count > 0) return false;
const now = new Date().toISOString();
const rawHex = '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172';
upsertObserver({ id: 'obs-sjc-001', name: 'User Observer', iata: 'SJC', last_seen: now, first_seen: now });
const pktId = insertPacket({
raw_hex: rawHex,
timestamp: now,
observer_id: 'obs-sjc-001',
observer_name: 'User Observer',
direction: 'rx',
snr: 10.5,
rssi: -85,
score: 42,
hash: 'seed-test-hash',
route_type: 1,
payload_type: 4,
payload_version: 1,
path_json: JSON.stringify(['A1B2', 'C3D4']),
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Kpa Roof Solar', role: 'repeater', lat: 37.31468, lon: -121.8921 }),
});
insertPath(pktId, ['A1B2', 'C3D4']);
upsertNode({
public_key: 'kpa-roof-solar-pubkey',
name: 'Kpa Roof Solar',
role: 'repeater',
lat: 37.31468,
lon: -121.8921,
last_seen: now,
first_seen: now,
});
return true;
}
// --- Run directly ---
if (require.main === module) {
const seeded = seed();
console.log(seeded ? 'Database seeded with test data.' : 'Database already has data, skipping seed.');
console.log('Stats:', getStats());
}
@@ -628,7 +295,7 @@ function getNodeHealth(pubkey) {
const observers = db.prepare(`
SELECT observer_id, observer_name,
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, COUNT(*) as packetCount
FROM packets_v
FROM packets
WHERE ${whereClause} AND observer_id IS NOT NULL
GROUP BY observer_id
ORDER BY packetCount DESC
@@ -636,20 +303,20 @@ function getNodeHealth(pubkey) {
// Stats
const packetsToday = db.prepare(`
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause} AND timestamp > @since
SELECT COUNT(*) as count FROM packets WHERE ${whereClause} AND timestamp > @since
`).get({ ...params, since: todayISO }).count;
const avgStats = db.prepare(`
SELECT AVG(snr) as avgSnr FROM packets_v WHERE ${whereClause}
SELECT AVG(snr) as avgSnr FROM packets WHERE ${whereClause}
`).get(params);
const lastHeard = db.prepare(`
SELECT MAX(timestamp) as lastHeard FROM packets_v WHERE ${whereClause}
SELECT MAX(timestamp) as lastHeard FROM packets WHERE ${whereClause}
`).get(params).lastHeard;
// Avg hops from path_json
const pathRows = db.prepare(`
SELECT path_json FROM packets_v WHERE ${whereClause} AND path_json IS NOT NULL
SELECT path_json FROM packets WHERE ${whereClause} AND path_json IS NOT NULL
`).all(params);
let totalHops = 0, hopCount = 0;
@@ -662,12 +329,12 @@ function getNodeHealth(pubkey) {
const avgHops = hopCount > 0 ? Math.round(totalHops / hopCount) : 0;
const totalPackets = db.prepare(`
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause}
SELECT COUNT(*) as count FROM packets WHERE ${whereClause}
`).get(params).count;
// Recent 10 packets
const recentPackets = db.prepare(`
SELECT * FROM packets_v WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
SELECT * FROM packets WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
`).all(params);
return {
@@ -698,31 +365,31 @@ function getNodeAnalytics(pubkey, days) {
// Activity timeline
const activityTimeline = db.prepare(`
SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as bucket, COUNT(*) as count
FROM packets_v WHERE ${timeWhere} GROUP BY bucket ORDER BY bucket
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_v WHERE ${timeWhere} AND snr IS NOT NULL ORDER BY timestamp
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_v WHERE ${timeWhere} GROUP BY payload_type
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_v WHERE ${timeWhere} AND observer_id IS NOT NULL
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_v WHERE ${timeWhere} AND path_json IS NOT NULL
SELECT path_json FROM packets WHERE ${timeWhere} AND path_json IS NOT NULL
`).all(params);
const hopCounts = {};
@@ -744,7 +411,7 @@ function getNodeAnalytics(pubkey, days) {
// Peer interactions from decoded_json
const decodedRows = db.prepare(`
SELECT decoded_json, timestamp FROM packets_v WHERE ${timeWhere} AND decoded_json IS NOT NULL
SELECT decoded_json, timestamp FROM packets WHERE ${timeWhere} AND decoded_json IS NOT NULL
`).all(params);
const peerMap = {};
@@ -770,11 +437,11 @@ function getNodeAnalytics(pubkey, days) {
const uptimeHeatmap = db.prepare(`
SELECT CAST(strftime('%w', timestamp) AS INTEGER) as dayOfWeek,
CAST(strftime('%H', timestamp) AS INTEGER) as hour, COUNT(*) as count
FROM packets_v WHERE ${timeWhere} GROUP BY dayOfWeek, hour
FROM packets WHERE ${timeWhere} GROUP BY dayOfWeek, hour
`).all(params);
// Computed stats
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets_v WHERE ${timeWhere}`).get(params).count;
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;
@@ -786,7 +453,7 @@ function getNodeAnalytics(pubkey, days) {
// Longest silence
const timestamps = db.prepare(`
SELECT timestamp FROM packets_v WHERE ${timeWhere} ORDER BY timestamp
SELECT timestamp FROM packets WHERE ${timeWhere} ORDER BY timestamp
`).all(params).map(r => new Date(r.timestamp).getTime());
let longestSilenceMs = 0, longestSilenceStart = null;
@@ -826,4 +493,4 @@ function getNodeAnalytics(pubkey, days) {
};
}
module.exports = { db, schemaVersion, observerIdToRowid, resolveObserverIdx, insertTransmission, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, searchNodes, getNodeHealth, getNodeAnalytics };
module.exports = { db, insertPacket, insertPath, upsertNode, upsertObserver, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
+28 -85
View File
@@ -33,13 +33,9 @@ const PAYLOAD_TYPES = {
0x03: 'ACK',
0x04: 'ADVERT',
0x05: 'GRP_TXT',
0x06: 'GRP_DATA',
0x07: 'ANON_REQ',
0x08: 'PATH',
0x09: 'TRACE',
0x0A: 'MULTIPART',
0x0B: 'CONTROL',
0x0F: 'RAW_CUSTOM',
};
// Route types that carry transport codes (nextHop + lastHop, 2 bytes each)
@@ -80,24 +76,24 @@ function decodePath(pathByte, buf, offset) {
// --- Payload decoders ---
/** REQ / RESPONSE / TXT_MSG: dest(1) + src(1) + MAC(2) + encrypted (PAYLOAD_VER_1, per Mesh.cpp) */
/** REQ / RESPONSE / TXT_MSG: dest(6) + src(6) + MAC(4) + encrypted */
function decodeEncryptedPayload(buf) {
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
if (buf.length < 16) return { error: 'too short', raw: buf.toString('hex') };
return {
destHash: buf.subarray(0, 1).toString('hex'),
srcHash: buf.subarray(1, 2).toString('hex'),
mac: buf.subarray(2, 4).toString('hex'),
encryptedData: buf.subarray(4).toString('hex'),
destHash: buf.subarray(0, 6).toString('hex'),
srcHash: buf.subarray(6, 12).toString('hex'),
mac: buf.subarray(12, 16).toString('hex'),
encryptedData: buf.subarray(16).toString('hex'),
};
}
/** ACK: dest(1) + src(1) + ack_hash(4) (per Mesh.cpp) */
/** ACK: dest(6) + src(6) + extra(6) */
function decodeAck(buf) {
if (buf.length < 6) return { error: 'too short', raw: buf.toString('hex') };
if (buf.length < 18) return { error: 'too short', raw: buf.toString('hex') };
return {
destHash: buf.subarray(0, 1).toString('hex'),
srcHash: buf.subarray(1, 2).toString('hex'),
extraHash: buf.subarray(2, 6).toString('hex'),
destHash: buf.subarray(0, 6).toString('hex'),
srcHash: buf.subarray(6, 12).toString('hex'),
extraHash: buf.subarray(12, 18).toString('hex'),
};
}
@@ -113,14 +109,12 @@ function decodeAdvert(buf) {
if (appdata.length > 0) {
const flags = appdata[0];
const advType = flags & 0x0F; // lower nibble is enum type, not individual bits
result.flags = {
raw: flags,
type: advType,
chat: advType === 1,
repeater: advType === 2,
room: advType === 3,
sensor: advType === 4,
chat: !!(flags & 0x01),
repeater: !!(flags & 0x02),
room: !!(flags & 0x04),
sensor: !!(flags & 0x08),
hasLocation: !!(flags & 0x10),
hasName: !!(flags & 0x80),
};
@@ -174,23 +168,23 @@ function decodeGrpTxt(buf, channelKeys) {
/** ANON_REQ: dest(6) + ephemeral_pubkey(32) + MAC(4) + encrypted */
function decodeAnonReq(buf) {
if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') };
if (buf.length < 42) return { error: 'too short', raw: buf.toString('hex') };
return {
destHash: buf.subarray(0, 1).toString('hex'),
ephemeralPubKey: buf.subarray(1, 33).toString('hex'),
mac: buf.subarray(33, 35).toString('hex'),
encryptedData: buf.subarray(35).toString('hex'),
destHash: buf.subarray(0, 6).toString('hex'),
ephemeralPubKey: buf.subarray(6, 38).toString('hex'),
mac: buf.subarray(38, 42).toString('hex'),
encryptedData: buf.subarray(42).toString('hex'),
};
}
/** PATH: dest(6) + src(6) + MAC(4) + path_data */
function decodePath_payload(buf) {
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
if (buf.length < 16) return { error: 'too short', raw: buf.toString('hex') };
return {
destHash: buf.subarray(0, 1).toString('hex'),
srcHash: buf.subarray(1, 2).toString('hex'),
mac: buf.subarray(2, 4).toString('hex'),
pathData: buf.subarray(4).toString('hex'),
destHash: buf.subarray(0, 6).toString('hex'),
srcHash: buf.subarray(6, 12).toString('hex'),
mac: buf.subarray(12, 16).toString('hex'),
pathData: buf.subarray(16).toString('hex'),
};
}
@@ -271,62 +265,11 @@ function decodePacket(hexString, channelKeys) {
};
}
// --- ADVERT validation ---
const VALID_ROLES = new Set(['repeater', 'companion', 'room', 'sensor']);
/**
* Validate decoded ADVERT data before upserting into the DB.
* Returns { valid: true } or { valid: false, reason: string }.
*/
function validateAdvert(advert) {
if (!advert || advert.error) return { valid: false, reason: advert?.error || 'null advert' };
// pubkey must be at least 16 hex chars (8 bytes) and not all zeros
const pk = advert.pubKey || '';
if (pk.length < 16) return { valid: false, reason: `pubkey too short (${pk.length} hex chars)` };
if (/^0+$/.test(pk)) return { valid: false, reason: 'pubkey is all zeros' };
// lat/lon must be in valid ranges if present
if (advert.lat != null) {
if (!Number.isFinite(advert.lat) || advert.lat < -90 || advert.lat > 90) {
return { valid: false, reason: `invalid lat: ${advert.lat}` };
}
}
if (advert.lon != null) {
if (!Number.isFinite(advert.lon) || advert.lon < -180 || advert.lon > 180) {
return { valid: false, reason: `invalid lon: ${advert.lon}` };
}
}
// name must not contain control chars (except space) or be garbage
if (advert.name != null) {
// eslint-disable-next-line no-control-regex
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(advert.name)) {
return { valid: false, reason: 'name contains control characters' };
}
// Reject names that are mostly non-printable or suspiciously long
if (advert.name.length > 64) {
return { valid: false, reason: `name too long (${advert.name.length} chars)` };
}
}
// role derivation check — flags byte should produce a known role
if (advert.flags) {
const role = advert.flags.repeater ? 'repeater' : advert.flags.room ? 'room' : advert.flags.sensor ? 'sensor' : 'companion';
if (!VALID_ROLES.has(role)) return { valid: false, reason: `unknown role: ${role}` };
}
// timestamp: decoded but not currently used for node storage — skip validation
return { valid: true };
}
module.exports = { decodePacket, validateAdvert, ROUTE_TYPES, PAYLOAD_TYPES, VALID_ROLES };
module.exports = { decodePacket, ROUTE_TYPES, PAYLOAD_TYPES };
// --- Tests ---
if (require.main === module) {
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Kpa Roof Solar" ===');
const pkt1 = decodePacket(
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
);
@@ -342,7 +285,7 @@ if (require.main === module) {
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
assert(pkt1.payload.name === 'Kpa Roof Solar', 'name should be "Kpa Roof Solar"');
console.log('✅ Test 1 passed\n');
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
-11
View File
@@ -1,11 +0,0 @@
# Default Caddyfile — reverse proxy to Node app
# Override by mounting your own: -v ./Caddyfile:/etc/caddy/Caddyfile
#
# For automatic HTTPS, replace :80 with your domain:
# analyzer.example.com {
# reverse_proxy localhost:3000
# }
:80 {
reverse_proxy localhost:3000
}
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
# Copy example config if no config.json exists at app root (not bind-mounted)
if [ ! -f /app/config.json ]; then
echo "[entrypoint] No config.json found, copying from config.example.json"
cp /app/config.example.json /app/config.json
fi
# theme.json: check data/ volume (admin-editable on host)
if [ -f /app/data/theme.json ]; then
ln -sf /app/data/theme.json /app/theme.json
fi
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
-10
View File
@@ -1,10 +0,0 @@
# Mosquitto config for MeshCore Analyzer
listener 1883 0.0.0.0
allow_anonymous true
persistence true
persistence_location /var/lib/mosquitto/
# Logging
log_dest stdout
log_type warning
log_type error
-36
View File
@@ -1,36 +0,0 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:mosquitto]
command=/usr/sbin/mosquitto -c /etc/mosquitto/mosquitto.conf
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:meshcore-analyzer]
command=node /app/server.js
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=NODE_ENV="production"
[program:caddy]
command=/usr/sbin/caddy run --config /etc/caddy/Caddyfile
environment=XDG_DATA_HOME="/data"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
-215
View File
@@ -1,215 +0,0 @@
# Customizing Your Instance
## Quick Start
1. Open your analyzer in a browser
2. Go to **Tools → Customize**
3. Change colors, branding, home page content
4. Click **💾 Download theme.json**
5. Put the file next to your `config.json` on the server
6. Refresh the page — done
No restart needed. The server picks up changes to `theme.json` on every page load.
## Where Does theme.json Go?
**Next to config.json.** However you deployed, put them side by side.
**Docker:**
```bash
# Add to your docker run command:
-v /path/to/theme.json:/app/theme.json:ro
# Or if you bind-mount the data directory:
# Just put theme.json in that directory
```
**Bare metal / PM2 / systemd:**
```bash
# Same directory as server.js and config.json
cp theme.json /path/to/meshcore-analyzer/
```
Check the server logs on startup — it tells you where it's looking:
```
[theme] Loaded from /app/theme.json
```
or:
```
[theme] No theme.json found. Place it next to config.json or in data/ to customize.
```
## What Can You Customize?
### Branding
```json
{
"branding": {
"siteName": "Bay Area Mesh",
"tagline": "Community LoRa mesh network",
"logoUrl": "/my-logo.svg",
"faviconUrl": "/my-favicon.png"
}
}
```
Logo replaces the 🍄 emoji in the nav bar (renders at 24px height). Favicon replaces the browser tab icon. Use a URL path for files in the `public/` folder, or a full URL for external images.
### Theme Colors (Light Mode)
```json
{
"theme": {
"accent": "#ff6b6b",
"navBg": "#1a1a2e",
"navText": "#ffffff",
"background": "#f4f5f7",
"text": "#1a1a2e",
"statusGreen": "#22c55e",
"statusYellow": "#eab308",
"statusRed": "#ef4444"
}
}
```
### Theme Colors (Dark Mode)
```json
{
"themeDark": {
"accent": "#57f2a5",
"navBg": "#0a0a1a",
"background": "#0f0f23",
"text": "#e2e8f0"
}
}
```
Only include colors you want to change — everything else stays default.
### All Available Theme Keys
| Key | What It Controls |
|-----|-----------------|
| `accent` | Buttons, links, active tabs, badges, charts |
| `accentHover` | Hover state for accent elements |
| `navBg` | Nav bar background (gradient start) |
| `navBg2` | Nav bar gradient end |
| `navText` | Nav bar text and links |
| `navTextMuted` | Inactive nav links, stats |
| `background` | Main page background |
| `text` | Primary text color |
| `textMuted` | Labels, timestamps, secondary text |
| `statusGreen` | Healthy/online indicators |
| `statusYellow` | Warning/degraded indicators |
| `statusRed` | Error/offline indicators |
| `border` | Dividers, table borders |
| `surface1` | Card backgrounds |
| `surface2` | Nested panels |
| `cardBg` | Detail panels, modals |
| `contentBg` | Content area behind cards |
| `detailBg` | Side panels, packet detail |
| `inputBg` | Text inputs, dropdowns |
| `rowStripe` | Alternating table rows |
| `rowHover` | Table row hover |
| `selectedBg` | Selected/active rows |
| `font` | Body font stack |
| `mono` | Monospace font (hex, hashes, code) |
### Node Role Colors
```json
{
"nodeColors": {
"repeater": "#dc2626",
"companion": "#2563eb",
"room": "#16a34a",
"sensor": "#d97706",
"observer": "#8b5cf6"
}
}
```
Affects map markers, packet path badges, node lists, and legends.
### Packet Type Colors
```json
{
"typeColors": {
"ADVERT": "#22c55e",
"GRP_TXT": "#3b82f6",
"TXT_MSG": "#f59e0b",
"ACK": "#6b7280",
"REQUEST": "#a855f7",
"RESPONSE": "#06b6d4",
"TRACE": "#ec4899",
"PATH": "#14b8a6",
"ANON_REQ": "#f43f5e"
}
}
```
Affects packet badges, feed dots, map markers, and chart colors.
### Home Page Content
```json
{
"home": {
"heroTitle": "Welcome to Bay Area Mesh",
"heroSubtitle": "Find your nodes to start monitoring them.",
"steps": [
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" }
],
"checklist": [
{ "question": "How do I add my node?", "answer": "Search by name or paste your public key." }
],
"footerLinks": [
{ "label": "📦 Packets", "url": "#/packets" },
{ "label": "🗺️ Map", "url": "#/map" }
]
}
}
```
Step descriptions and checklist answers support Markdown (`**bold**`, `*italic*`, `` `code` ``, `[links](url)`).
## User vs Admin Themes
- **Admin theme** (`theme.json`): Default for all users. Edit the file, refresh.
- **User theme** (browser): Each user can override the admin theme via Tools → Customize → "Save as my theme". Stored in localStorage, only affects that browser.
User themes take priority over admin themes. Users can reset their personal theme to go back to the admin default.
## Full Example
```json
{
"branding": {
"siteName": "Bay Area MeshCore",
"tagline": "Community mesh monitoring for the Bay Area",
"logoUrl": "https://example.com/logo.svg"
},
"theme": {
"accent": "#2563eb",
"statusGreen": "#16a34a",
"statusYellow": "#ca8a04",
"statusRed": "#dc2626"
},
"themeDark": {
"accent": "#60a5fa",
"navBg": "#0a0a1a",
"background": "#111827"
},
"nodeColors": {
"repeater": "#ef4444",
"observer": "#a855f7"
},
"home": {
"heroTitle": "Bay Area MeshCore",
"heroSubtitle": "Real-time monitoring for our community mesh network.",
"steps": [
{ "emoji": "💬", "title": "Join our Discord", "description": "Get help and connect with local operators." },
{ "emoji": "📡", "title": "Advertise your node", "description": "Send an ADVERT so the network can see you." },
{ "emoji": "🗺️", "title": "Check the map", "description": "Find repeaters near you." }
]
}
}
```
-475
View File
@@ -1,475 +0,0 @@
# Deploying MeshCore Analyzer
Get MeshCore Analyzer running with automatic HTTPS on your own server.
## Table of Contents
- [What You'll End Up With](#what-youll-end-up-with)
- [What You Need Before Starting](#what-you-need-before-starting)
- [Installing Docker](#installing-docker)
- [Quick Start](#quick-start)
- [Connecting an Observer](#connecting-an-observer)
- [HTTPS Options](#https-options)
- [MQTT Security](#mqtt-security)
- [Database Backups](#database-backups)
- [Updating](#updating)
- [Customization](#customization)
- [Troubleshooting](#troubleshooting)
- [Architecture Overview](#architecture-overview)
## What You'll End Up With
- MeshCore Analyzer running at `https://your-domain.com`
- Automatic HTTPS certificates (via Let's Encrypt + Caddy)
- Built-in MQTT broker for receiving packets from observers
- SQLite database for packet storage (auto-created)
- Everything in a single Docker container
## What You Need Before Starting
### A server
A computer that's always on and connected to the internet:
- **Cloud VM** — DigitalOcean, Linode, Vultr, AWS, Azure, etc. A $5-6/month VPS works. Pick **Ubuntu 22.04 or 24.04**.
- **Raspberry Pi** — Works, just slower to build.
- **Home PC/laptop** — Works if your ISP doesn't block ports 80/443 (many residential ISPs do).
You'll need **SSH access** to your server. Cloud providers give you instructions when you create the VM.
### A domain name
A domain (like `analyzer.example.com`) pointed at your server's IP:
- Buy one (~$10/year) from Namecheap, Cloudflare, etc.
- Or use a free subdomain from [DuckDNS](https://www.duckdns.org/) or [FreeDNS](https://freedns.afraid.org/)
After getting a domain, create an **A record** pointing to your server's IP address. Your domain provider's dashboard will have a "DNS" section for this.
**Important:** DNS must be configured and propagated *before* you start the container. Caddy will try to provision certificates on startup and fail if the domain doesn't resolve. Verify with `dig analyzer.example.com` — it should show your server's IP.
### Open ports
Your server's firewall must allow:
- **Port 80** — needed for HTTPS certificate provisioning (Let's Encrypt ACME challenge)
- **Port 443** — HTTPS traffic
Cloud providers: find "Security Groups" or "Firewall" in the dashboard, add inbound rules for TCP 80 and 443 from 0.0.0.0/0.
Ubuntu firewall:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
```
## Installing Docker
Docker packages an app and all its dependencies into a container — an isolated environment with everything it needs to run. You don't install Node.js, Mosquitto, or Caddy separately; they're all included in the container.
SSH into your server and run:
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
# Allow your user to run Docker without sudo
sudo usermod -aG docker $USER
```
**Log out and SSH back in** (the group change needs a new session), then verify:
```bash
docker --version
# Should print: Docker version 24.x.x or newer
```
## Quick Start
The easiest way — use the management script:
```bash
git clone https://github.com/Kpa-clawbot/meshcore-analyzer.git
cd meshcore-analyzer
./manage.sh setup
```
It walks you through everything: checks Docker, creates config, asks for your domain, checks DNS, builds, and starts.
After setup, manage with:
```bash
./manage.sh status # Check if everything's running
./manage.sh logs # View logs
./manage.sh backup # Backup the database
./manage.sh update # Pull latest + rebuild + restart
./manage.sh mqtt-test # Check if MQTT data is flowing
./manage.sh help # All commands
```
### Manual setup
```mermaid
flowchart LR
A[Clone repo] --> B[Create config] --> C[Create Caddyfile] --> D[Build & run] --> E[Open site]
style E fill:#22c55e,color:#000
```
### 1. Download the code
```bash
git clone https://github.com/Kpa-clawbot/meshcore-analyzer.git
cd meshcore-analyzer
```
### 2. Create your config
```bash
cp config.example.json config.json
nano config.json
```
Change the `apiKey` to any random string. The rest of the defaults work out of the box.
```jsonc
{
"apiKey": "change-me-to-something-random",
...
}
```
Save: `Ctrl+O`, `Enter`, `Ctrl+X`.
### 3. Set up your domain for HTTPS
```bash
mkdir -p caddy-config
nano caddy-config/Caddyfile
```
Enter your domain (replace `analyzer.example.com` with yours):
```
analyzer.example.com {
reverse_proxy localhost:3000
}
```
Save and close. Caddy handles certificates, renewals, and HTTP→HTTPS redirects automatically.
### 4. Build and run
```bash
docker build -t meshcore-analyzer .
docker run -d \
--name meshcore-analyzer \
--restart unless-stopped \
-p 80:80 \
-p 443:443 \
-v $(pwd)/config.json:/app/config.json:ro \
-v $(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro \
-v meshcore-data:/app/data \
-v caddy-data:/data/caddy \
meshcore-analyzer
```
What each flag does:
| Flag | Purpose |
|------|---------|
| `-d` | Run in background |
| `--restart unless-stopped` | Auto-restart on crash or reboot |
| `-p 80:80 -p 443:443` | Expose web ports |
| `-v .../config.json:...ro` | Your config (read-only) |
| `-v .../Caddyfile:...` | Your domain config |
| `-v meshcore-data:/app/data` | Database storage (persists across restarts) |
| `-v caddy-data:/data/caddy` | HTTPS certificate storage |
### 5. Verify
Open `https://your-domain.com`. You should see the analyzer home page.
Check the logs:
```bash
docker logs meshcore-analyzer
```
Expected output:
```
MeshCore Analyzer running on http://localhost:3000
MQTT [local] connected to mqtt://localhost:1883
[pre-warm] 12 endpoints in XXXms
```
The container runs its own MQTT broker (Mosquitto) internally — that `localhost:1883` connection is inside the container, not exposed to the internet.
## Connecting an Observer
The analyzer receives packets from observers via MQTT.
### Option A: Use a public broker
Add a remote broker to `mqttSources` in your `config.json`:
```json
{
"name": "public-broker",
"broker": "mqtts://mqtt.lincomatic.com:8883",
"username": "your-username",
"password": "your-password",
"rejectUnauthorized": false,
"topics": ["meshcore/SJC/#", "meshcore/SFO/#"]
}
```
Restart: `docker restart meshcore-analyzer`
### Option B: Run your own observer
You need a MeshCore repeater connected via USB or BLE to a computer running [meshcoretomqtt](https://github.com/Cisien/meshcoretomqtt). Point it at your analyzer's MQTT broker.
⚠️ If your observer is remote (not on the same machine), you'll need to expose port 1883. **Read the MQTT Security section first.**
## HTTPS Options
### Automatic (recommended) — Caddy + Let's Encrypt
This is what the Quick Start sets up. Caddy handles everything. Requirements:
- Domain pointed at your server
- Ports 80 + 443 open
- No other web server (Apache, nginx) running on those ports
### Bring your own certificate
If you already have a certificate (from Cloudflare, your organization, etc.), tell Caddy to use it instead of Let's Encrypt:
```
analyzer.example.com {
tls /path/to/cert.pem /path/to/key.pem
reverse_proxy localhost:3000
}
```
Mount the cert files into the container:
```bash
docker run ... \
-v /path/to/cert.pem:/certs/cert.pem:ro \
-v /path/to/key.pem:/certs/key.pem:ro \
...
```
And update the Caddyfile paths to `/certs/cert.pem` and `/certs/key.pem`.
### Cloudflare Tunnel (no open ports needed)
If you can't open ports 80/443 (residential ISP, restrictive firewall), use a [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/). It creates an outbound connection from your server to Cloudflare — no inbound ports needed. Your Caddyfile becomes:
```
:80 {
reverse_proxy localhost:3000
}
```
And Cloudflare handles HTTPS at the edge.
### Behind an existing reverse proxy (nginx, Traefik, etc.)
If you already run a reverse proxy, skip Caddy entirely and proxy directly to the Node.js port:
```bash
docker run -d \
--name meshcore-analyzer \
--restart unless-stopped \
-p 3000:3000 \
-v $(pwd)/config.json:/app/config.json:ro \
-v meshcore-data:/app/data \
meshcore-analyzer
```
Then configure your existing proxy to forward traffic to `localhost:3000`.
### HTTP only (development / local network)
For local testing or a LAN-only setup, use the default Caddyfile that ships in the image (serves on port 80, no HTTPS):
```bash
docker run -d \
--name meshcore-analyzer \
--restart unless-stopped \
-p 80:80 \
-v $(pwd)/config.json:/app/config.json:ro \
-v meshcore-data:/app/data \
meshcore-analyzer
```
## MQTT Security
The container runs Mosquitto on port 1883 with **anonymous access by default**. This is safe as long as the port isn't exposed outside the container.
The Quick Start docker run command above does **not** expose port 1883. Only add `-p 1883:1883` if you need remote observers to connect directly.
### If you need to expose MQTT
**Option 1: Firewall** — Only allow specific IPs:
```bash
sudo ufw allow from 203.0.113.10 to any port 1883 # Your observer's IP
```
**Option 2: Add authentication** — Edit `docker/mosquitto.conf` before building:
```
allow_anonymous false
password_file /etc/mosquitto/passwd
```
After starting the container, create users:
```bash
docker exec -it meshcore-analyzer mosquitto_passwd -c /etc/mosquitto/passwd myuser
```
**Option 3: Use TLS** — For production, configure Mosquitto with TLS certificates. See the [Mosquitto docs](https://mosquitto.org/man/mosquitto-conf-5.html).
### Recommended approach for remote observers
Don't expose 1883 at all. Instead, have your observers publish to a shared public MQTT broker (like lincomatic's), and configure your analyzer to subscribe to that broker in `mqttSources`. The analyzer makes an outbound connection — no inbound ports needed.
## Database Backups
Packet data is stored in `meshcore.db` inside the data volume.
**Using manage.sh (easiest):**
```bash
./manage.sh backup # Saves to ./backups/meshcore-TIMESTAMP.db
./manage.sh backup ~/my-backup.db # Custom path
./manage.sh restore ./backups/some-file.db # Restore (backs up current DB first)
```
**Local directory mount (recommended):**
If you used `-v ./analyzer-data:/app/data` instead of a Docker volume, the database is just `./analyzer-data/meshcore.db` — back it up however you like.
**Automated daily backup (cron):**
```bash
crontab -e
# Add:
0 3 * * * cd /path/to/meshcore-analyzer && ./manage.sh backup
```
## Updating
```bash
./manage.sh update
```
Pulls latest code, rebuilds the image, restarts the container. Data is preserved.
Data is preserved in the Docker volumes.
**Tip:** Save your `docker run` command in a script (`run.sh`) so you don't have to remember all the flags.
## Customization
### Branding
In `config.json`:
```json
{
"branding": {
"siteName": "Bay Area Mesh",
"tagline": "Community LoRa network for the Bay Area",
"logoUrl": "https://example.com/logo.png",
"faviconUrl": "https://example.com/favicon.ico"
}
}
```
### Themes
Create a `theme.json` in your data directory to customize colors. See [CUSTOMIZATION.md](./CUSTOMIZATION.md) for all options.
### Map defaults
Center the map on your area in `config.json`:
```json
{
"mapDefaults": {
"center": [37.45, -122.0],
"zoom": 9
}
}
```
## Troubleshooting
| Problem | Likely cause | Fix |
|---------|-------------|-----|
| Site shows "connection refused" | Container not running | `docker ps` to check, `docker logs meshcore-analyzer` for errors |
| HTTPS not working | Port 80 blocked | Open port 80 — Caddy needs it for ACME challenges |
| "too many certificates" error | Let's Encrypt rate limit (5/domain/week) | Use a different subdomain, bring your own cert, or wait a week |
| Certificate won't provision | DNS not pointed at server | `dig your-domain` must show your server IP before starting |
| No packets appearing | No observer connected | `docker exec meshcore-analyzer mosquitto_sub -t 'meshcore/#' -C 1 -W 10` — if silent, no data is coming in |
| Container crashes on startup | Bad JSON in config | `python3 -c "import json; json.load(open('config.json'))"` to validate |
| "address already in use" | Another web server on 80/443 | Stop it: `sudo systemctl stop nginx apache2` |
| Slow on Raspberry Pi | First build is slow | Normal — subsequent builds use cache. Runtime performance is fine. |
## Architecture Overview
### Traffic flow
```mermaid
flowchart LR
subgraph Internet
U[Browser] -->|HTTPS :443| C
O1[Observer 1] -->|MQTT :1883| M
O2[Observer 2] -->|MQTT :1883| M
LE[Let's Encrypt] -->|HTTP :80| C
end
subgraph Docker Container
C[Caddy] -->|proxy :3000| N[Node.js]
M[Mosquitto] --> N
N --> DB[(SQLite)]
N -->|WebSocket| U
end
style C fill:#22c55e,color:#000
style M fill:#3b82f6,color:#fff
style N fill:#f59e0b,color:#000
style DB fill:#8b5cf6,color:#fff
```
### Container internals
```mermaid
flowchart TD
S[supervisord] --> C[Caddy]
S --> M[Mosquitto]
S --> N[Node.js server]
C -->|reverse proxy + auto HTTPS| N
M -->|MQTT messages| N
N --> API[REST API]
N --> WS[WebSocket — live feed]
N --> MQTT[MQTT client — ingests packets]
N --> DB[(SQLite — data/meshcore.db)]
style S fill:#475569,color:#fff
style C fill:#22c55e,color:#000
style M fill:#3b82f6,color:#fff
style N fill:#f59e0b,color:#000
```
### Data flow
```mermaid
sequenceDiagram
participant R as LoRa Repeater
participant O as Observer
participant M as Mosquitto
participant N as Node.js
participant B as Browser
R->>O: Radio packet (915 MHz)
O->>M: MQTT publish (raw hex + SNR + RSSI)
M->>N: Subscribe callback
N->>N: Decode, store in SQLite + memory
N->>B: WebSocket broadcast
B->>N: REST API requests
N->>B: JSON responses
```
-324
View File
@@ -1,324 +0,0 @@
# Hash Prefix Disambiguation in MeshCore Analyzer
## Section 1: Executive Summary
### What Are Hash Prefixes?
MeshCore is a LoRa mesh network where every packet records the nodes it passed through (its "path"). To save bandwidth on a constrained radio link, each hop in the path is stored as a **truncated hash** of the node's public key — typically just **1 byte** (2 hex characters), though the firmware supports 13 bytes per hop.
With 1-byte hashes, there are only 256 possible values. In any mesh with more than ~20 nodes, **collisions are inevitable** — multiple nodes share the same prefix.
### How Disambiguation Works
When displaying a packet's path (e.g., `A3 → 7F → B1`), the system must figure out *which* node each prefix refers to. The algorithm is the same everywhere:
1. **Prefix lookup** — Find all known nodes whose public key starts with the hop's hex prefix
2. **Trivial case** — If exactly one match, use it
3. **Regional filtering** (server `/api/resolve-hops` only) — If the packet came from a known geographic region (via observer IATA code), filter candidates to nodes near that region
4. **Forward pass** — Walk the path left-to-right; for each ambiguous hop, pick the candidate closest to the previous resolved hop
5. **Backward pass** — Walk right-to-left for any still-unresolved hops, using the next hop as anchor
6. **Sanity check** — Flag hops that are geographically implausible (>~200 km from both neighbors) as `unreliable`
### When It Matters
- **Packet path display** (packets page, node detail, live feed) — every path shown to users goes through disambiguation
- **Topology analysis** (analytics subpaths) — route patterns rely on correctly identifying repeaters
- **Map route overlay** — drawing lines between hops on a map requires resolved coordinates
- **Auto-learning** — the server creates stub node records for unknown 2+ byte hop prefixes
### Known Limitations
- **1-byte prefixes are inherently lossy** — 256 possible values for potentially thousands of nodes. Regional filtering helps but can't solve all collisions.
- **Nodes without GPS** — If no candidates have coordinates, geographic disambiguation can't help; the first candidate wins arbitrarily.
- **Regional filtering is server-only** — The `/api/resolve-hops` endpoint has observer-based fallback filtering (for GPS-less nodes seen by regional observers). The client-side `HopResolver` only does geographic regional filtering.
- **Stale prefix index** — The server caches the prefix index on the `allNodes` array object. It's cleared on node upsert but could theoretically serve stale data briefly.
### How the Two-Pass Algorithm Works
A packet path arrives as truncated hex prefixes. Some resolve to one node (unique), some match multiple (ambiguous). Two passes guarantee every hop gets resolved:
```mermaid
flowchart LR
subgraph raw["① Candidate Lookup"]
direction LR
r1(("A3<br>3 matches")):::ambig
r2(("7F<br>1 match")):::known
r3(("B1<br>2 matches")):::ambig
r4(("E4<br>1 match")):::known
r5(("A3<br>4 matches")):::ambig
end
r1---r2---r3---r4---r5
classDef known fill:#166534,color:#fff,stroke:#22c55e
classDef ambig fill:#991b1b,color:#fff,stroke:#ef4444
```
```mermaid
flowchart LR
subgraph fwd["② Forward Pass → pick nearest to previous resolved hop"]
direction LR
f1(("A3<br>skip ❌")):::ambig
f2(("7F<br>anchor")):::known
f3(("B1→✅<br>nearest 7F")):::resolved
f4(("E4<br>anchor")):::known
f5(("A3→✅<br>nearest E4")):::resolved
end
f1-- "→" ---f2-- "→" ---f3-- "→" ---f4-- "→" ---f5
classDef known fill:#166534,color:#fff,stroke:#22c55e
classDef ambig fill:#991b1b,color:#fff,stroke:#ef4444
classDef resolved fill:#1e40af,color:#fff,stroke:#3b82f6
```
```mermaid
flowchart RL
subgraph bwd["③ Backward Pass ← catch hops the forward pass missed"]
direction RL
b5(("A3 ✅")):::known
b4(("E4 ✅")):::known
b3(("B1 ✅")):::known
b2(("7F<br>anchor")):::known
b1(("A3→✅<br>nearest 7F")):::resolved
end
b5-- "←" ---b4-- "←" ---b3-- "←" ---b2-- "←" ---b1
classDef known fill:#166534,color:#fff,stroke:#22c55e
classDef resolved fill:#1e40af,color:#fff,stroke:#3b82f6
```
**Forward** resolves hops that have a known node to their left. **Backward** catches the ones at the start of the path that had no left anchor. After both passes, every hop either resolved to a specific node or has no candidates at all.
---
## Section 2: Technical Details
### 2.1 Firmware: How Hops Are Encoded
From `firmware/src/MeshCore.h`:
```c
#define PATH_HASH_SIZE 1 // Default: 1 byte per hop
#define MAX_HASH_SIZE 8 // Maximum hash size for dedup tables
```
The **path_length byte** in each packet encodes both hop count and hash size:
- **Bits 05**: hop count (063)
- **Bits 67**: hash size minus 1 (`0b00` = 1 byte, `0b01` = 2 bytes, `0b10` = 3 bytes)
From `firmware/docs/packet_format.md`: the path section is `hop_count × hash_size` bytes, with a maximum of 64 bytes (`MAX_PATH_SIZE`). Each hop hash is the first N bytes of the node's public key hash.
The `sendFlood()` function accepts `path_hash_size` parameter (default 1), allowing nodes to use larger hashes when configured.
### 2.2 Decoder: Extracting Hops
`decoder.js``decodePath(pathByte, buf, offset)`:
```javascript
const hashSize = (pathByte >> 6) + 1; // 1-4 bytes per hash
const hashCount = pathByte & 0x3F; // 0-63 hops
```
Each hop is extracted as `hashSize` bytes of hex. The decoder is straightforward and doesn't do any disambiguation — it outputs raw hex prefixes.
### 2.3 Server-Side Disambiguation
There are **three** disambiguation implementations on the server:
#### 2.3.1 `disambiguateHops()` — in both `server.js` (line 498) and `server-helpers.js` (line 149)
The primary workhorse. Used by most API endpoints. Algorithm:
1. **Build prefix index** (cached on the `allNodes` array):
- For each node, index its public key at 1-byte (2 hex), 2-byte (4 hex), and 3-byte (6 hex) prefix lengths
- `_prefixIdx[prefix]` → array of matching nodes
- `_prefixIdxName[prefix]` → first matching node (name fallback)
2. **First pass — candidate matching**:
- Look up `prefixIdx[hop]`; filter to nodes with valid coordinates
- 1 match with coords → resolved
- Multiple matches with coords → ambiguous (keep candidate list)
- 0 matches with coords → fall back to `prefixIdxName` for name only
3. **Forward pass**: Walk left→right, sort ambiguous candidates by distance to last known position, pick closest.
4. **Backward pass**: Walk right→left, same logic with next known position.
5. **Sanity check**: Mark hops as `unreliable` if they're >MAX_HOP_DIST (default 1.8° ≈ 200 km) from both neighbors. Clear their lat/lon.
**Callsites** (all in `server.js`):
| Line | Context |
|------|---------|
| 2480 | `/api/paths` — group and resolve path display |
| 2659 | `/api/analytics/topology` — topology graph |
| 2720 | `/api/analytics/subpaths` — route pattern analysis |
| 2788 | `/api/node/:key` — node detail page, packet paths |
| 2822 | `/api/node/:key` — parent path resolution |
#### 2.3.2 `/api/resolve-hops` endpoint (server.js line 1944)
The most sophisticated version — used by the client-side packets page as fallback (though `HopResolver` handles most cases now). Additional features beyond `disambiguateHops()`:
- **Regional filtering**: Uses observer IATA codes to determine packet region
- **Layer 1 (Geographic)**: If candidate has GPS, check distance to IATA region center (≤300 km)
- **Layer 2 (Observer-based)**: If candidate has no GPS, check if its adverts were seen by regional observers
- **Origin/observer anchoring**: Accepts `originLat/originLon` (sender position) as forward anchor and derives observer position as backward anchor
- **Linear scan for candidates**: Uses `allNodes.filter(startsWith)` instead of prefix index (slower but always fresh)
#### 2.3.3 Inline `resolveHop()` in analytics endpoints
Two analytics endpoints (`/api/analytics/topology` line 1432 and `/api/analytics/hash-issues` line 1699) define local `resolveHop()` closures that do simple prefix matching without the full forward/backward pass — they resolve hops individually without path context.
### 2.4 `autoLearnHopNodes()` (server.js line 569)
When packets arrive, this function checks each hop:
- Skips 1-byte hops (too ambiguous to learn from)
- For 2+ byte hops not already in the DB, creates a stub node record with `role: 'repeater'`
- Uses an in-memory `hopNodeCache` Set to avoid redundant DB queries
Called during:
- MQTT packet ingestion (line 686)
- HTTP packet submission (line 1019)
### 2.5 Client-Side: `HopResolver` (public/hop-resolver.js)
A client-side IIFE (`window.HopResolver`) that mirrors the server's algorithm to avoid HTTP round-trips. Key differences from server:
| Aspect | Server `disambiguateHops()` | Server `/api/resolve-hops` | Client `HopResolver` |
|--------|---------------------------|---------------------------|---------------------|
| Prefix index | Cached on allNodes array | Linear filter | Built in `init()` |
| Regional filtering | None | IATA geo + observer-based | IATA geo only |
| Origin anchor | None | Yes (from query params) | Yes (from params) |
| Observer anchor | None | Yes (derived from DB) | Yes (from params) |
| Sanity check | unreliable + clear coords | unreliable only | unreliable flag |
| Distance function | `geoDist()` (Euclidean) | `dist()` (Euclidean) | `dist()` (Euclidean) |
**Initialization**: `HopResolver.init(nodes, { observers, iataCoords })` — builds prefix index for 13 byte prefixes.
**Resolution**: `HopResolver.resolve(hops, originLat, originLon, observerLat, observerLon, observerId)` — runs the same 3-phase algorithm (candidates → forward → backward → sanity check).
### 2.6 Client-Side: `resolveHopPositions()` in live.js (line 1562)
The live feed page has its **own independent implementation** that doesn't use `HopResolver`. It:
- Filters from `nodeData` (live feed's own node cache)
- Uses the same forward/backward/sanity algorithm
- Also prepends the sender as position anchor
- Includes "ghost hop" rendering for unresolved hops
### 2.7 Where Disambiguation Is Applied (All Callsites)
**Server-side:**
| File | Function/Line | What |
|------|--------------|------|
| server.js:498 | `disambiguateHops()` | Core algorithm |
| server.js:569 | `autoLearnHopNodes()` | Stub node creation for 2+ byte hops |
| server.js:1432 | inline `resolveHop()` | Analytics topology tab |
| server.js:1699 | inline `resolveHop()` | Analytics hash-issues tab |
| server.js:1944 | `/api/resolve-hops` | Full resolution with regional filtering |
| server.js:2480 | paths endpoint | Path grouping |
| server.js:2659 | topology endpoint | Topology graph |
| server.js:2720 | subpaths endpoint | Route patterns |
| server.js:2788 | node detail | Packet paths for a node |
| server.js:2822 | node detail | Parent paths |
| server-helpers.js:149 | `disambiguateHops()` | Extracted copy (used by tests) |
**Client-side:**
| File | Function | What |
|------|---------|------|
| hop-resolver.js | `HopResolver.resolve()` | Main client resolver |
| packets.js:121+ | `resolveHops()` wrapper | Packets page path display |
| packets.js:1388 | Direct `HopResolver.resolve()` | Packet detail pane with sender context |
| live.js:1562 | `resolveHopPositions()` | Live feed path lines (independent impl) |
| map.js:273+ | Route overlay | Map path drawing (uses server-resolved data) |
| analytics.js | subpaths display | Renders server-resolved names |
### 2.8 Consistency Analysis
**Core algorithm**: All implementations use the same 3-phase approach (candidate lookup → forward pass → backward pass → sanity check). The logic is consistent.
**Discrepancies found:**
1. **`server.js disambiguateHops()` vs `server-helpers.js disambiguateHops()`**: These are near-identical copies. The server-helpers version is extracted for testing. Both use `geoDist()` (Euclidean approximation). No functional discrepancy.
2. **`/api/resolve-hops` vs `disambiguateHops()`**: The API endpoint is significantly more capable — it has regional filtering (IATA geo + observer-based) and origin/observer anchoring. Endpoints that use `disambiguateHops()` directly (paths, topology, subpaths, node detail) **do not benefit from regional filtering**, which may produce different results for the same hops.
3. **`live.js resolveHopPositions()` vs `HopResolver`**: The live feed reimplements disambiguation independently. It lacks:
- Regional/IATA filtering
- Origin/observer GPS anchoring (it does use sender position as anchor, but differently)
- The prefix index optimization (uses linear `Array.filter()`)
4. **Inline `resolveHop()` in analytics**: These resolve hops individually without path context (no forward/backward pass). A hop ambiguous between two nodes will always get the first match rather than the geographically consistent one.
5. **`disambiguateHops()` only considers nodes with coordinates** for the candidate list. Nodes without GPS are filtered out in the first pass. The `/api/resolve-hops` endpoint also returns no-GPS nodes in its candidate list and uses observer-based region filtering as fallback.
### 2.9 Edge Cases
| Edge Case | Behavior |
|-----------|----------|
| **No candidates** | Hop displayed as raw hex prefix |
| **All candidates lack GPS** | `disambiguateHops()`: name from `prefixIdxName` (first indexed), no position. `HopResolver`: first candidate wins |
| **Ambiguous after both passes** | First candidate in list wins (effectively random without position data) |
| **Mixed hash sizes in same path** | Each hop is whatever length the decoder extracted. Prefix index handles variable lengths (indexed at 1, 2, 3 byte prefixes) |
| **Self-loops in subpaths** | Same prefix appearing twice likely means a collision, not an actual loop. Analytics UI flags these with 🔄 and offers "hide collisions" checkbox |
| **Unknown observers** | Regional filtering falls back to no filtering; all candidates considered |
| **0,0 coordinates** | Explicitly excluded everywhere (`!(lat === 0 && lon === 0)`) |
### 2.10 Visual Decollision on the Map (Different System)
The **map label deconfliction** in `map.js` (`deconflictLabels()`, line 367+) is a completely different system. It handles **visual overlap** of hop labels on the Leaflet map — when two resolved hops are at nearby coordinates, their text labels would overlap. The function offsets labels to prevent visual collision using bounding-box checks.
This is **not related** to hash prefix disambiguation — it operates on already-resolved, positioned hops and only affects label rendering, not which node a prefix maps to.
Similarly, the map's "cluster" mode (`L.markerClusterGroup`) groups nearby node markers visually and is unrelated to hash disambiguation.
### 2.11 Data Flow Diagram
```
FIRMWARE (LoRa)
Packet with 1-3 byte hop hashes
┌─────────────────┐
│ MQTT Broker(s) │
└────────┬────────┘
┌──────────────────────┐
│ server.js │
│ ┌────────────────┐ │
│ │ decoder.js │ │ Extract raw hop hex prefixes
│ └───────┬────────┘ │
│ │ │
│ ▼ │
│ autoLearnHopNodes() │ Create stub nodes for 2+ byte hops
│ │ │
│ packet-store.js │ Store packet with raw hops
└──────────┬──────────┘
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
REST API calls WebSocket /api/resolve-hops
│ broadcast │
│ │ │
▼ │ ▼
disambiguateHops() │ Regional filtering +
(no regional filter) │ geo disambiguation
│ │ │
▼ ▼ ▼
┌────────────────────────────────────┐
│ BROWSER │
│ │
│ packets.js ──► HopResolver.resolve()
│ (geo + IATA regional filtering) │
│ │
│ live.js ──► resolveHopPositions() │
│ (geo only, independent impl) │
│ │
│ map.js ──► deconflictLabels() │
│ (visual label offsets only) │
│ │
│ analytics.js ──► server-resolved │
└─────────────────────────────────────┘
```
### 2.12 Hash Size Detection
Separate from disambiguation but closely related: the system tracks which hash size each node uses. `server-helpers.js` has `updateHashSizeForPacket()` and `rebuildHashSizeMap()` which extract the hash_size from the path_length byte. This feeds the analytics "hash issues" tab which detects nodes that flip-flop between hash sizes (a firmware behavior that complicates analysis).
-90
View File
@@ -1,90 +0,0 @@
// IATA airport coordinates for regional node filtering
// Used by resolve-hops to determine if a node is geographically near an observer's region
const IATA_COORDS = {
// US West Coast
SJC: { lat: 37.3626, lon: -121.9290 },
SFO: { lat: 37.6213, lon: -122.3790 },
OAK: { lat: 37.7213, lon: -122.2208 },
SEA: { lat: 47.4502, lon: -122.3088 },
PDX: { lat: 45.5898, lon: -122.5951 },
LAX: { lat: 33.9425, lon: -118.4081 },
SAN: { lat: 32.7338, lon: -117.1933 },
SMF: { lat: 38.6954, lon: -121.5908 },
MRY: { lat: 36.5870, lon: -121.8430 },
EUG: { lat: 44.1246, lon: -123.2119 },
RDD: { lat: 40.5090, lon: -122.2934 },
MFR: { lat: 42.3742, lon: -122.8735 },
FAT: { lat: 36.7762, lon: -119.7181 },
SBA: { lat: 34.4262, lon: -119.8405 },
RNO: { lat: 39.4991, lon: -119.7681 },
BOI: { lat: 43.5644, lon: -116.2228 },
LAS: { lat: 36.0840, lon: -115.1537 },
PHX: { lat: 33.4373, lon: -112.0078 },
SLC: { lat: 40.7884, lon: -111.9778 },
// US Mountain/Central
DEN: { lat: 39.8561, lon: -104.6737 },
DFW: { lat: 32.8998, lon: -97.0403 },
IAH: { lat: 29.9844, lon: -95.3414 },
AUS: { lat: 30.1975, lon: -97.6664 },
MSP: { lat: 44.8848, lon: -93.2223 },
// US East Coast
ATL: { lat: 33.6407, lon: -84.4277 },
ORD: { lat: 41.9742, lon: -87.9073 },
JFK: { lat: 40.6413, lon: -73.7781 },
EWR: { lat: 40.6895, lon: -74.1745 },
BOS: { lat: 42.3656, lon: -71.0096 },
MIA: { lat: 25.7959, lon: -80.2870 },
IAD: { lat: 38.9531, lon: -77.4565 },
CLT: { lat: 35.2144, lon: -80.9473 },
DTW: { lat: 42.2124, lon: -83.3534 },
MCO: { lat: 28.4312, lon: -81.3081 },
BNA: { lat: 36.1263, lon: -86.6774 },
RDU: { lat: 35.8801, lon: -78.7880 },
// Canada
YVR: { lat: 49.1967, lon: -123.1815 },
YYZ: { lat: 43.6777, lon: -79.6248 },
YYC: { lat: 51.1215, lon: -114.0076 },
YEG: { lat: 53.3097, lon: -113.5800 },
YOW: { lat: 45.3225, lon: -75.6692 },
// Europe
LHR: { lat: 51.4700, lon: -0.4543 },
CDG: { lat: 49.0097, lon: 2.5479 },
FRA: { lat: 50.0379, lon: 8.5622 },
AMS: { lat: 52.3105, lon: 4.7683 },
MUC: { lat: 48.3537, lon: 11.7750 },
SOF: { lat: 42.6952, lon: 23.4062 },
// Asia/Pacific
NRT: { lat: 35.7720, lon: 140.3929 },
HND: { lat: 35.5494, lon: 139.7798 },
ICN: { lat: 37.4602, lon: 126.4407 },
SYD: { lat: -33.9461, lon: 151.1772 },
MEL: { lat: -37.6690, lon: 144.8410 },
};
// Haversine distance in km
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// Default radius for "near region" — LoRa max realistic range ~300km
const DEFAULT_REGION_RADIUS_KM = 300;
/**
* Check if a node is geographically within radius of an IATA region center.
* Returns { near: boolean, distKm: number } or null if can't determine.
*/
function nodeNearRegion(nodeLat, nodeLon, iata, radiusKm = DEFAULT_REGION_RADIUS_KM) {
const center = IATA_COORDS[iata];
if (!center) return null;
if (nodeLat == null || nodeLon == null || (nodeLat === 0 && nodeLon === 0)) return null;
const distKm = haversineKm(nodeLat, nodeLon, center.lat, center.lon);
return { near: distKm <= radiusKm, distKm: Math.round(distKm) };
}
module.exports = { IATA_COORDS, haversineKm, nodeNearRegion, DEFAULT_REGION_RADIUS_KM };
-660
View File
@@ -1,660 +0,0 @@
#!/bin/bash
# MeshCore Analyzer — Setup & Management Helper
# Usage: ./manage.sh [command]
#
# Idempotent: safe to cancel and re-run at any point.
# Each step checks what's already done and skips it.
set -e
CONTAINER_NAME="meshcore-analyzer"
IMAGE_NAME="meshcore-analyzer"
DATA_VOLUME="meshcore-data"
CADDY_VOLUME="caddy-data"
STATE_FILE=".setup-state"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { printf '%b\n' "${GREEN}${NC} $1"; }
warn() { printf '%b\n' "${YELLOW}${NC} $1"; }
err() { printf '%b\n' "${RED}${NC} $1"; }
info() { printf '%b\n' "${CYAN}${NC} $1"; }
step() { printf '%b\n' "\n${BOLD}[$1/$TOTAL_STEPS] $2${NC}"; }
confirm() {
read -p " $1 [y/N] " -n 1 -r
echo
[[ $REPLY =~ ^[Yy]$ ]]
}
# State tracking — marks completed steps so re-runs skip them
mark_done() { echo "$1" >> "$STATE_FILE"; }
is_done() { [ -f "$STATE_FILE" ] && grep -qx "$1" "$STATE_FILE" 2>/dev/null; }
# ─── Setup Wizard ─────────────────────────────────────────────────────────
TOTAL_STEPS=6
cmd_setup() {
echo ""
echo "═══════════════════════════════════════"
echo " MeshCore Analyzer Setup"
echo "═══════════════════════════════════════"
echo ""
if [ -f "$STATE_FILE" ]; then
info "Resuming previous setup. Delete ${STATE_FILE} to start over."
echo ""
fi
# ── Step 1: Check Docker ──
step 1 "Checking Docker"
if ! command -v docker &> /dev/null; then
err "Docker is not installed."
echo ""
echo " Install it:"
echo " curl -fsSL https://get.docker.com | sh"
echo " sudo usermod -aG docker \$USER"
echo ""
echo " Then log out, log back in, and run ./manage.sh setup again."
exit 1
fi
# Check if user can actually run Docker
if ! docker info &> /dev/null; then
err "Docker is installed but your user can't run it."
echo ""
echo " Fix: sudo usermod -aG docker \$USER"
echo " Then log out, log back in, and try again."
exit 1
fi
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
mark_done "docker"
# ── Step 2: Config ──
step 2 "Configuration"
if [ -f config.json ]; then
log "config.json exists."
# Sanity check the JSON
if ! python3 -c "import json; json.load(open('config.json'))" 2>/dev/null && \
! node -e "JSON.parse(require('fs').readFileSync('config.json'))" 2>/dev/null; then
err "config.json has invalid JSON. Fix it and re-run setup."
exit 1
fi
log "config.json is valid JSON."
else
info "Creating config.json from example..."
cp config.example.json config.json
# Generate a random API key
if command -v openssl &> /dev/null; then
API_KEY=$(openssl rand -hex 16)
else
API_KEY=$(head -c 32 /dev/urandom | xxd -p | head -c 32)
fi
# Replace the placeholder API key
if command -v sed &> /dev/null; then
sed -i "s/your-secret-api-key-here/${API_KEY}/" config.json
fi
log "Created config.json with random API key."
echo ""
echo " You can customize config.json later (map center, branding, etc)."
echo " Edit with: nano config.json"
echo ""
fi
mark_done "config"
# ── Step 3: Domain & HTTPS ──
step 3 "Domain & HTTPS"
if [ -f caddy-config/Caddyfile ]; then
EXISTING_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
if [ "$EXISTING_DOMAIN" = ":80" ]; then
log "Caddyfile exists (HTTP only, no HTTPS)."
else
log "Caddyfile exists for ${EXISTING_DOMAIN}"
fi
else
mkdir -p caddy-config
echo ""
echo " How do you want to handle HTTPS?"
echo ""
echo " 1) I have a domain pointed at this server (automatic HTTPS)"
echo " 2) I'll use Cloudflare Tunnel or my own proxy (HTTP only)"
echo " 3) Just HTTP for now, I'll set up HTTPS later"
echo ""
read -p " Choose [1/2/3]: " -n 1 -r
echo ""
case $REPLY in
1)
read -p " Enter your domain (e.g., analyzer.example.com): " DOMAIN
if [ -z "$DOMAIN" ]; then
err "No domain entered. Re-run setup to try again."
exit 1
fi
echo "${DOMAIN} {
reverse_proxy localhost:3000
}" > caddy-config/Caddyfile
log "Caddyfile created for ${DOMAIN}"
# Validate DNS
info "Checking DNS..."
RESOLVED_IP=$(dig +short "$DOMAIN" 2>/dev/null | grep -E '^[0-9]+\.' | head -1)
MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "unknown")
if [ -z "$RESOLVED_IP" ]; then
warn "${DOMAIN} doesn't resolve yet."
warn "Create an A record pointing to ${MY_IP}"
warn "HTTPS won't work until DNS propagates (1-60 min)."
echo ""
if ! confirm "Continue anyway?"; then
echo " Run ./manage.sh setup again when DNS is ready."
exit 0
fi
elif [ "$RESOLVED_IP" = "$MY_IP" ]; then
log "DNS resolves correctly: ${DOMAIN}${MY_IP}"
else
warn "${DOMAIN} resolves to ${RESOLVED_IP} but this server is ${MY_IP}"
warn "HTTPS provisioning will fail if the domain doesn't point here."
if ! confirm "Continue anyway?"; then
echo " Fix DNS and run ./manage.sh setup again."
exit 0
fi
fi
# Check port 80
if command -v curl &> /dev/null; then
if curl -s --connect-timeout 3 "http://localhost:80" &>/dev/null || \
curl -s --connect-timeout 3 "http://${MY_IP}:80" &>/dev/null 2>&1; then
warn "Something is already listening on port 80."
warn "Stop it first: sudo systemctl stop nginx apache2"
fi
fi
;;
2|3)
echo ':80 {
reverse_proxy localhost:3000
}' > caddy-config/Caddyfile
log "Caddyfile created (HTTP only on port 80)."
if [ "$REPLY" = "2" ]; then
echo " Point your Cloudflare Tunnel or proxy to this server's port 80."
fi
;;
*)
warn "Invalid choice. Defaulting to HTTP only."
echo ':80 {
reverse_proxy localhost:3000
}' > caddy-config/Caddyfile
;;
esac
fi
mark_done "caddyfile"
# ── Step 4: Build ──
step 4 "Building Docker image"
# Check if image exists and source hasn't changed
IMAGE_EXISTS=$(docker images -q "$IMAGE_NAME" 2>/dev/null)
if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then
log "Image already built."
if confirm "Rebuild? (only needed if you updated the code)"; then
docker build -t "$IMAGE_NAME" .
log "Image rebuilt."
fi
else
info "This takes 1-2 minutes the first time..."
docker build -t "$IMAGE_NAME" .
log "Image built."
fi
mark_done "build"
# ── Step 5: Start container ──
step 5 "Starting container"
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
log "Container already running."
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
# Exists but stopped — check if it needs recreating (new image)
info "Container exists but is stopped. Starting..."
docker start "$CONTAINER_NAME"
log "Started."
else
# Determine ports
PORTS="-p 80:80 -p 443:443"
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
if [ "$CADDYFILE_DOMAIN" = ":80" ]; then
PORTS="-p 80:80"
fi
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
$PORTS \
-v "$(pwd)/config.json:/app/config.json:ro" \
-v "$(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "${DATA_VOLUME}:/app/data" \
-v "${CADDY_VOLUME}:/data/caddy" \
"$IMAGE_NAME"
log "Container started."
fi
mark_done "container"
# ── Step 6: Verify ──
step 6 "Verifying"
info "Waiting for startup..."
sleep 5
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
# Check if Node.js is responding
HEALTHY=false
for i in 1 2 3; do
if docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
HEALTHY=true
break
fi
sleep 2
done
if $HEALTHY; then
log "All services running."
else
warn "Container is running but Node.js hasn't responded yet."
warn "Check logs: ./manage.sh logs"
fi
echo ""
echo "═══════════════════════════════════════"
echo " Setup complete!"
echo "═══════════════════════════════════════"
echo ""
if [ "$CADDYFILE_DOMAIN" != ":80" ] && [ -n "$CADDYFILE_DOMAIN" ]; then
echo " 🌐 https://${CADDYFILE_DOMAIN}"
else
MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || echo "your-server-ip")
echo " 🌐 http://${MY_IP}"
fi
echo ""
echo " Next steps:"
echo " • Connect an observer to start receiving packets"
echo " • Customize branding in config.json"
echo " • Set up backups: ./manage.sh backup"
echo ""
echo " Useful commands:"
echo " ./manage.sh status Check health"
echo " ./manage.sh logs View logs"
echo " ./manage.sh backup Full backup (DB + config + theme)"
echo " ./manage.sh update Update to latest version"
echo ""
else
err "Container failed to start."
echo ""
echo " Check what went wrong:"
echo " docker logs ${CONTAINER_NAME}"
echo ""
echo " Common fixes:"
echo " • Invalid config.json — check JSON syntax"
echo " • Port conflict — stop other web servers"
echo " • Re-run: ./manage.sh setup"
echo ""
exit 1
fi
mark_done "verify"
}
# ─── Start / Stop / Restart ──────────────────────────────────────────────
cmd_start() {
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
warn "Already running."
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
docker start "$CONTAINER_NAME"
log "Started."
else
err "Container doesn't exist. Run './manage.sh setup' first."
exit 1
fi
}
cmd_stop() {
docker stop "$CONTAINER_NAME" 2>/dev/null && log "Stopped." || warn "Not running."
}
cmd_restart() {
docker restart "$CONTAINER_NAME" 2>/dev/null && log "Restarted." || err "Not running. Use './manage.sh start'."
}
# ─── Status ───────────────────────────────────────────────────────────────
cmd_status() {
echo ""
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
log "Container is running."
echo ""
docker ps --filter "name=${CONTAINER_NAME}" --format " Status: {{.Status}}"
docker ps --filter "name=${CONTAINER_NAME}" --format " Ports: {{.Ports}}"
echo ""
info "Service health:"
# Node.js
if docker exec "$CONTAINER_NAME" wget -qO /dev/null http://localhost:3000/api/stats 2>/dev/null; then
STATS=$(docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats 2>/dev/null)
PACKETS=$(echo "$STATS" | grep -oP '"totalPackets":\K[0-9]+' 2>/dev/null || echo "?")
NODES=$(echo "$STATS" | grep -oP '"totalNodes":\K[0-9]+' 2>/dev/null || echo "?")
log " Node.js — ${PACKETS} packets, ${NODES} nodes"
else
err " Node.js — not responding"
fi
# Mosquitto
if docker exec "$CONTAINER_NAME" pgrep mosquitto &>/dev/null; then
log " Mosquitto — running"
else
err " Mosquitto — not running"
fi
# Caddy
if docker exec "$CONTAINER_NAME" pgrep caddy &>/dev/null; then
log " Caddy — running"
else
err " Caddy — not running"
fi
# Disk usage
DB_SIZE=$(docker exec "$CONTAINER_NAME" du -h /app/data/meshcore.db 2>/dev/null | cut -f1)
if [ -n "$DB_SIZE" ]; then
echo ""
info "Database size: ${DB_SIZE}"
fi
else
err "Container is not running."
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo " Start with: ./manage.sh start"
else
echo " Set up with: ./manage.sh setup"
fi
fi
echo ""
}
# ─── Logs ─────────────────────────────────────────────────────────────────
cmd_logs() {
docker logs -f "$CONTAINER_NAME" --tail "${1:-100}"
}
# ─── Update ───────────────────────────────────────────────────────────────
cmd_update() {
info "Pulling latest code..."
git pull
info "Rebuilding image..."
docker build -t "$IMAGE_NAME" .
# Capture the run config before removing
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
PORTS="-p 80:80 -p 443:443"
if [ "$CADDYFILE_DOMAIN" = ":80" ]; then
PORTS="-p 80:80"
fi
info "Restarting with new image..."
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rm "$CONTAINER_NAME" 2>/dev/null || true
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
$PORTS \
-v "$(pwd)/config.json:/app/config.json:ro" \
-v "$(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "${DATA_VOLUME}:/app/data" \
-v "${CADDY_VOLUME}:/data/caddy" \
"$IMAGE_NAME"
log "Updated and restarted. Data preserved."
}
# ─── Backup ───────────────────────────────────────────────────────────────
cmd_backup() {
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="${1:-./backups/meshcore-${TIMESTAMP}}"
mkdir -p "$BACKUP_DIR"
info "Backing up to ${BACKUP_DIR}/"
# Database
DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
if [ -f "$DB_PATH" ]; then
cp "$DB_PATH" "$BACKUP_DIR/meshcore.db"
log "Database ($(du -h "$BACKUP_DIR/meshcore.db" | cut -f1))"
elif docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
docker cp "${CONTAINER_NAME}:/app/data/meshcore.db" "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
log "Database (via docker cp)" || warn "Could not backup database"
else
warn "Database not found (container not running?)"
fi
# Config
if [ -f config.json ]; then
cp config.json "$BACKUP_DIR/config.json"
log "config.json"
fi
# Caddyfile
if [ -f caddy-config/Caddyfile ]; then
cp caddy-config/Caddyfile "$BACKUP_DIR/Caddyfile"
log "Caddyfile"
fi
# Theme
THEME_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
if [ -f "$THEME_PATH" ]; then
cp "$THEME_PATH" "$BACKUP_DIR/theme.json"
log "theme.json"
elif [ -f theme.json ]; then
cp theme.json "$BACKUP_DIR/theme.json"
log "theme.json"
fi
# Summary
TOTAL=$(du -sh "$BACKUP_DIR" | cut -f1)
FILES=$(ls "$BACKUP_DIR" | wc -l)
echo ""
log "Backup complete: ${FILES} files, ${TOTAL} total → ${BACKUP_DIR}/"
}
# ─── Restore ──────────────────────────────────────────────────────────────
cmd_restore() {
if [ -z "$1" ]; then
err "Usage: ./manage.sh restore <backup-dir-or-db-file>"
if [ -d "./backups" ]; then
echo ""
echo " Available backups:"
ls -dt ./backups/meshcore-* 2>/dev/null | head -10 | while read d; do
if [ -d "$d" ]; then
echo " $d/ ($(ls "$d" | wc -l) files)"
elif [ -f "$d" ]; then
echo " $d ($(du -h "$d" | cut -f1))"
fi
done
fi
exit 1
fi
# Accept either a directory (full backup) or a single .db file
if [ -d "$1" ]; then
DB_FILE="$1/meshcore.db"
CONFIG_FILE="$1/config.json"
CADDY_FILE="$1/Caddyfile"
THEME_FILE="$1/theme.json"
elif [ -f "$1" ]; then
DB_FILE="$1"
CONFIG_FILE=""
CADDY_FILE=""
THEME_FILE=""
else
err "Not found: $1"
exit 1
fi
if [ ! -f "$DB_FILE" ]; then
err "No meshcore.db found in $1"
exit 1
fi
echo ""
info "Will restore from: $1"
[ -f "$DB_FILE" ] && echo " • Database"
[ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ] && echo " • config.json"
[ -n "$CADDY_FILE" ] && [ -f "$CADDY_FILE" ] && echo " • Caddyfile"
[ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ] && echo " • theme.json"
echo ""
if ! confirm "Continue? (current state will be backed up first)"; then
echo " Aborted."
exit 0
fi
# Backup current state first
info "Backing up current state..."
cmd_backup "./backups/meshcore-pre-restore-$(date +%Y%m%d-%H%M%S)"
docker stop "$CONTAINER_NAME" 2>/dev/null || true
# Restore database
DEST_DB=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
if [ -d "$(dirname "$DEST_DB")" ]; then
cp "$DB_FILE" "$DEST_DB"
else
docker cp "$DB_FILE" "${CONTAINER_NAME}:/app/data/meshcore.db"
fi
log "Database restored"
# Restore config if present
if [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then
cp "$CONFIG_FILE" ./config.json
log "config.json restored"
fi
# Restore Caddyfile if present
if [ -n "$CADDY_FILE" ] && [ -f "$CADDY_FILE" ]; then
mkdir -p caddy-config
cp "$CADDY_FILE" caddy-config/Caddyfile
log "Caddyfile restored"
fi
# Restore theme if present
if [ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ]; then
DEST_THEME=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
if [ -d "$(dirname "$DEST_THEME")" ]; then
cp "$THEME_FILE" "$DEST_THEME"
fi
log "theme.json restored"
fi
docker start "$CONTAINER_NAME"
log "Restored and restarted."
}
# ─── MQTT Test ────────────────────────────────────────────────────────────
cmd_mqtt_test() {
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
err "Container not running. Start with: ./manage.sh start"
exit 1
fi
info "Listening for MQTT messages (10 second timeout)..."
MSG=$(docker exec "$CONTAINER_NAME" mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
if [ -n "$MSG" ]; then
log "Received MQTT message:"
echo " $MSG" | head -c 200
echo ""
else
warn "No messages received in 10 seconds."
echo ""
echo " This means no observer is publishing packets."
echo " See the deployment guide for connecting observers."
fi
}
# ─── Reset ────────────────────────────────────────────────────────────────
cmd_reset() {
echo ""
warn "This will remove the container, image, and setup state."
warn "Your config.json, Caddyfile, and data volume are NOT deleted."
echo ""
if ! confirm "Continue?"; then
echo " Aborted."
exit 0
fi
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rm "$CONTAINER_NAME" 2>/dev/null || true
docker rmi "$IMAGE_NAME" 2>/dev/null || true
rm -f "$STATE_FILE"
log "Reset complete. Run './manage.sh setup' to start over."
echo " Data volume preserved. To delete it: docker volume rm ${DATA_VOLUME}"
}
# ─── Help ─────────────────────────────────────────────────────────────────
cmd_help() {
echo ""
echo "MeshCore Analyzer — Management Script"
echo ""
echo "Usage: ./manage.sh <command>"
echo ""
printf '%b\n' " ${BOLD}Setup${NC}"
echo " setup First-time setup wizard (safe to re-run)"
echo " reset Remove container + image (keeps data + config)"
echo ""
printf '%b\n' " ${BOLD}Run${NC}"
echo " start Start the container"
echo " stop Stop the container"
echo " restart Restart the container"
echo " status Show health, stats, and service status"
echo " logs [N] Follow logs (last N lines, default 100)"
echo ""
printf '%b\n' " ${BOLD}Maintain${NC}"
echo " update Pull latest code, rebuild, restart (keeps data)"
echo " backup [dir] Full backup: database + config + theme (default: ./backups/timestamped/)"
echo " restore <d> Restore from backup dir or .db file (backs up current first)"
echo " mqtt-test Check if MQTT data is flowing"
echo ""
}
# ─── Main ─────────────────────────────────────────────────────────────────
case "${1:-help}" in
setup) cmd_setup ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
status) cmd_status ;;
logs) cmd_logs "$2" ;;
update) cmd_update ;;
backup) cmd_backup "$2" ;;
restore) cmd_restore "$2" ;;
mqtt-test) cmd_mqtt_test ;;
reset) cmd_reset ;;
help|*) cmd_help ;;
esac
+2 -1991
View File
File diff suppressed because it is too large Load Diff
+2 -10
View File
@@ -1,13 +1,10 @@
{
"name": "meshcore-analyzer",
"version": "2.6.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": {
"test": "npx c8 --reporter=text --reporter=text-summary sh test-all.sh",
"test:unit": "node test-packet-filter.js && node test-aging.js && node test-regional-filter.js",
"test:coverage": "npx c8 --reporter=text --reporter=html sh test-all.sh",
"test:full-coverage": "sh scripts/combined-coverage.sh"
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
@@ -18,10 +15,5 @@
"express": "^5.2.1",
"mqtt": "^5.15.0",
"ws": "^8.19.0"
},
"devDependencies": {
"nyc": "^18.0.0",
"playwright": "^1.58.2",
"supertest": "^7.2.2"
}
}
-744
View File
@@ -1,744 +0,0 @@
'use strict';
/**
* In-memory packet store loads transmissions + observations from SQLite on startup,
* serves reads from RAM, writes to both RAM + SQLite.
* M3: Restructured around transmissions (deduped by hash) with observations.
* Caps memory at configurable limit (default 1GB).
*/
class PacketStore {
constructor(dbModule, config = {}) {
this.dbModule = dbModule; // The full db module (has .db, .insertTransmission, .getPacket)
this.db = dbModule.db; // Raw better-sqlite3 instance for queries
this.maxBytes = (config.maxMemoryMB || 1024) * 1024 * 1024;
this.estPacketBytes = config.estimatedPacketBytes || 450;
this.maxPackets = Math.floor(this.maxBytes / this.estPacketBytes);
// SQLite-only mode: skip RAM loading, all reads go to DB
this.sqliteOnly = process.env.NO_MEMORY_STORE === '1';
// Primary storage: transmissions sorted by first_seen DESC (newest first)
// Each transmission looks like a packet for backward compat
this.packets = [];
// Indexes
this.byId = new Map(); // observation_id → observation object (backward compat for packet detail links)
this.byTxId = new Map(); // transmission_id → transmission object
this.byHash = new Map(); // hash → transmission object (1:1)
this.byObserver = new Map(); // observer_id → [observation objects]
this.byNode = new Map(); // pubkey → [transmission objects] (deduped)
// Track which hashes are indexed per node pubkey (avoid dupes in byNode)
this._nodeHashIndex = new Map(); // pubkey → Set<hash>
this._advertByObserver = new Map(); // pubkey → Set<observer_id> (ADVERT-only, for region filtering)
this.loaded = false;
this.stats = { totalLoaded: 0, totalObservations: 0, evicted: 0, inserts: 0, queries: 0 };
}
/** Load all packets from SQLite into memory */
load() {
if (this.sqliteOnly) {
console.log('[PacketStore] SQLite-only mode (NO_MEMORY_STORE=1) — all reads go to database');
this.loaded = true;
return this;
}
const t0 = Date.now();
// Check if normalized schema exists
const hasTransmissions = this.db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='transmissions'"
).get();
if (hasTransmissions) {
this._loadNormalized();
} else {
this._loadLegacy();
}
this.stats.totalLoaded = this.packets.length;
this.loaded = true;
const elapsed = Date.now() - t0;
console.log(`[PacketStore] Loaded ${this.packets.length} transmissions (${this.stats.totalObservations} observations) in ${elapsed}ms (${Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024)}MB est)`);
return this;
}
/** Load from normalized transmissions + observations tables */
_loadNormalized() {
// Detect v3 schema (observer_idx instead of observer_id in observations)
const obsCols = this.db.pragma('table_info(observations)').map(c => c.name);
const isV3 = obsCols.includes('observer_idx');
const sql = isV3
? `SELECT t.id AS transmission_id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id AS observation_id, obs.id AS observer_id, obs.name AS observer_name, o.direction,
o.snr, o.rssi, o.score, o.path_json, datetime(o.timestamp, 'unixepoch') AS obs_timestamp
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
ORDER BY t.first_seen DESC, o.timestamp DESC`
: `SELECT t.id AS transmission_id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id AS observation_id, o.observer_id, o.observer_name, o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp AS obs_timestamp
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
ORDER BY t.first_seen DESC, o.timestamp DESC`;
const rows = this.db.prepare(sql).all();
for (const row of rows) {
if (this.packets.length >= this.maxPackets && !this.byHash.has(row.hash)) break;
let tx = this.byHash.get(row.hash);
if (!tx) {
tx = {
id: row.transmission_id,
raw_hex: row.raw_hex,
hash: row.hash,
first_seen: row.first_seen,
timestamp: row.first_seen,
route_type: row.route_type,
payload_type: row.payload_type,
decoded_json: row.decoded_json,
observations: [],
observation_count: 0,
// Filled from first observation for backward compat
observer_id: null,
observer_name: null,
snr: null,
rssi: null,
path_json: null,
direction: null,
};
this.byHash.set(row.hash, tx);
this.byHash.set(row.hash, tx);
this.packets.push(tx);
this.byTxId.set(tx.id, tx);
this._indexByNode(tx);
}
if (row.observation_id != null) {
const obs = {
id: row.observation_id,
observer_id: row.observer_id,
observer_name: row.observer_name,
direction: row.direction,
snr: row.snr,
rssi: row.rssi,
score: row.score,
path_json: row.path_json,
timestamp: row.obs_timestamp,
// Carry transmission fields for backward compat
hash: row.hash,
raw_hex: row.raw_hex,
payload_type: row.payload_type,
decoded_json: row.decoded_json,
route_type: row.route_type,
};
// Dedup: skip if same observer + same path already loaded
const isDupeLoad = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
if (isDupeLoad) continue;
tx.observations.push(obs);
tx.observation_count++;
// Fill first observation data into transmission for backward compat
if (tx.observer_id == null && obs.observer_id) {
tx.observer_id = obs.observer_id;
tx.observer_name = obs.observer_name;
tx.snr = obs.snr;
tx.rssi = obs.rssi;
tx.path_json = obs.path_json;
tx.direction = obs.direction;
}
// byId maps observation IDs for packet detail links
this.byId.set(obs.id, obs);
// byObserver
if (obs.observer_id) {
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
this.byObserver.get(obs.observer_id).push(obs);
}
this.stats.totalObservations++;
}
}
// Post-load: set each transmission's display path to the LONGEST observation path
// (most representative of mesh topology — short paths are just nearby observers)
for (const tx of this.packets) {
if (tx.observations.length > 0) {
let best = tx.observations[0];
let bestLen = 0;
try { bestLen = JSON.parse(best.path_json || '[]').length; } catch {}
for (let i = 1; i < tx.observations.length; i++) {
let len = 0;
try { len = JSON.parse(tx.observations[i].path_json || '[]').length; } catch {}
if (len > bestLen) { best = tx.observations[i]; bestLen = len; }
}
tx.observer_id = best.observer_id;
tx.observer_name = best.observer_name;
tx.snr = best.snr;
tx.rssi = best.rssi;
tx.path_json = best.path_json;
tx.direction = best.direction;
}
}
// Post-load: build ADVERT-by-observer index (needs all observations loaded first)
for (const tx of this.packets) {
if (tx.payload_type === 4 && tx.decoded_json) {
try {
const d = JSON.parse(tx.decoded_json);
if (d.pubKey) this._indexAdvertObservers(d.pubKey, tx);
} catch {}
}
}
console.log(`[PacketStore] ADVERT observer index: ${this._advertByObserver.size} nodes tracked`);
}
/** Fallback: load from legacy packets table */
_loadLegacy() {
const rows = this.db.prepare(
'SELECT * FROM packets_v ORDER BY timestamp DESC'
).all();
for (const row of rows) {
if (this.packets.length >= this.maxPackets) break;
this._indexLegacy(row);
}
}
/** Index a legacy packet row (old flat structure) — builds transmission + observation */
_indexLegacy(pkt) {
let tx = this.byHash.get(pkt.hash);
if (!tx) {
tx = {
id: pkt.id,
raw_hex: pkt.raw_hex,
hash: pkt.hash,
first_seen: pkt.timestamp,
timestamp: pkt.timestamp,
route_type: pkt.route_type,
payload_type: pkt.payload_type,
decoded_json: pkt.decoded_json,
observations: [],
observation_count: 0,
observer_id: pkt.observer_id,
observer_name: pkt.observer_name,
snr: pkt.snr,
rssi: pkt.rssi,
path_json: pkt.path_json,
direction: pkt.direction,
};
this.byHash.set(pkt.hash, tx);
this.byHash.set(pkt.hash, tx);
this.packets.push(tx);
this.byTxId.set(tx.id, tx);
this._indexByNode(tx);
}
if (pkt.timestamp < tx.first_seen) {
tx.first_seen = pkt.timestamp;
tx.timestamp = pkt.timestamp;
}
// Update display path if new observation has longer path
let newPathLen = 0, curPathLen = 0;
try { newPathLen = JSON.parse(pkt.path_json || '[]').length; } catch {}
try { curPathLen = JSON.parse(tx.path_json || '[]').length; } catch {}
if (newPathLen > curPathLen) {
tx.observer_id = pkt.observer_id;
tx.observer_name = pkt.observer_name;
tx.path_json = pkt.path_json;
}
const obs = {
id: pkt.id,
observer_id: pkt.observer_id,
observer_name: pkt.observer_name,
direction: pkt.direction,
snr: pkt.snr,
rssi: pkt.rssi,
score: pkt.score,
path_json: pkt.path_json,
timestamp: pkt.timestamp,
hash: pkt.hash,
raw_hex: pkt.raw_hex,
payload_type: pkt.payload_type,
decoded_json: pkt.decoded_json,
route_type: pkt.route_type,
};
// Dedup: skip if same observer + same path already recorded for this transmission
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
if (isDupe) return tx;
tx.observations.push(obs);
tx.observation_count++;
this.byId.set(pkt.id, obs);
if (pkt.observer_id) {
if (!this.byObserver.has(pkt.observer_id)) this.byObserver.set(pkt.observer_id, []);
this.byObserver.get(pkt.observer_id).push(obs);
}
this.stats.totalObservations++;
}
/** Extract node pubkeys from decoded_json and index transmission in byNode */
_indexByNode(tx) {
if (!tx.decoded_json) return;
try {
const decoded = JSON.parse(tx.decoded_json);
const keys = new Set();
if (decoded.pubKey) keys.add(decoded.pubKey);
if (decoded.destPubKey) keys.add(decoded.destPubKey);
if (decoded.srcPubKey) keys.add(decoded.srcPubKey);
for (const k of keys) {
if (!this._nodeHashIndex.has(k)) this._nodeHashIndex.set(k, new Set());
if (this._nodeHashIndex.get(k).has(tx.hash)) continue;
this._nodeHashIndex.get(k).add(tx.hash);
if (!this.byNode.has(k)) this.byNode.set(k, []);
this.byNode.get(k).push(tx);
}
} catch {}
}
/** Track which observers saw an ADVERT from a given pubkey */
_indexAdvertObservers(pubkey, tx) {
if (!this._advertByObserver.has(pubkey)) this._advertByObserver.set(pubkey, new Set());
const s = this._advertByObserver.get(pubkey);
for (const obs of tx.observations) {
if (obs.observer_id) s.add(obs.observer_id);
}
}
/** Get node pubkeys whose ADVERTs were seen by any of the given observer IDs */
getNodesByAdvertObservers(observerIds) {
const result = new Set();
for (const [pubkey, observers] of this._advertByObserver) {
for (const obsId of observerIds) {
if (observers.has(obsId)) { result.add(pubkey); break; }
}
}
return result;
}
/** Remove oldest transmissions when over memory limit */
_evict() {
while (this.packets.length > this.maxPackets) {
const old = this.packets.pop();
this.byHash.delete(old.hash);
this.byHash.delete(old.hash);
this.byTxId.delete(old.id);
// Remove observations from byId and byObserver
for (const obs of old.observations) {
this.byId.delete(obs.id);
if (obs.observer_id && this.byObserver.has(obs.observer_id)) {
const arr = this.byObserver.get(obs.observer_id).filter(o => o.id !== obs.id);
if (arr.length) this.byObserver.set(obs.observer_id, arr); else this.byObserver.delete(obs.observer_id);
}
}
// Skip node index cleanup (expensive, low value)
this.stats.evicted++;
}
}
/** Insert a new packet (to both memory and SQLite) */
insert(packetData) {
// Write to normalized tables and get the transmission ID
const txResult = this.dbModule.insertTransmission ? this.dbModule.insertTransmission(packetData) : null;
const transmissionId = txResult ? txResult.transmissionId : null;
const observationId = txResult ? txResult.observationId : null;
// Build row directly from packetData — avoids view ID mismatch issues
const row = {
id: observationId,
raw_hex: packetData.raw_hex,
hash: packetData.hash,
timestamp: packetData.timestamp,
route_type: packetData.route_type,
payload_type: packetData.payload_type,
payload_version: packetData.payload_version,
decoded_json: packetData.decoded_json,
observer_id: packetData.observer_id,
observer_name: packetData.observer_name,
snr: packetData.snr,
rssi: packetData.rssi,
path_json: packetData.path_json,
direction: packetData.direction,
};
if (!this.sqliteOnly) {
// Update or create transmission in memory
let tx = this.byHash.get(row.hash);
if (!tx) {
tx = {
id: transmissionId || row.id,
raw_hex: row.raw_hex,
hash: row.hash,
first_seen: row.timestamp,
timestamp: row.timestamp,
route_type: row.route_type,
payload_type: row.payload_type,
decoded_json: row.decoded_json,
observations: [],
observation_count: 0,
observer_id: row.observer_id,
observer_name: row.observer_name,
snr: row.snr,
rssi: row.rssi,
path_json: row.path_json,
direction: row.direction,
};
this.byHash.set(row.hash, tx);
this.byHash.set(row.hash, tx);
this.packets.unshift(tx); // newest first
this.byTxId.set(tx.id, tx);
this._indexByNode(tx);
} else {
// Update first_seen if earlier
if (row.timestamp < tx.first_seen) {
tx.first_seen = row.timestamp;
tx.timestamp = row.timestamp;
}
// Update display path if new observation has longer path
let newPathLen = 0, curPathLen = 0;
try { newPathLen = JSON.parse(row.path_json || '[]').length; } catch {}
try { curPathLen = JSON.parse(tx.path_json || '[]').length; } catch {}
if (newPathLen > curPathLen) {
tx.observer_id = row.observer_id;
tx.observer_name = row.observer_name;
tx.path_json = row.path_json;
}
}
// Add observation
const obs = {
id: row.id,
observer_id: row.observer_id,
observer_name: row.observer_name,
direction: row.direction,
snr: row.snr,
rssi: row.rssi,
score: row.score,
path_json: row.path_json,
timestamp: row.timestamp,
hash: row.hash,
raw_hex: row.raw_hex,
payload_type: row.payload_type,
decoded_json: row.decoded_json,
route_type: row.route_type,
};
// Dedup: skip if same observer + same path already recorded for this transmission
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
if (!isDupe) {
tx.observations.push(obs);
tx.observation_count++;
}
// Update transmission's display fields if this is first observation
if (tx.observations.length === 1) {
tx.observer_id = obs.observer_id;
tx.observer_name = obs.observer_name;
tx.snr = obs.snr;
tx.rssi = obs.rssi;
tx.path_json = obs.path_json;
}
this.byId.set(obs.id, obs);
if (obs.observer_id) {
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
this.byObserver.get(obs.observer_id).push(obs);
}
this.stats.totalObservations++;
// Update ADVERT observer index for live ingestion
if (tx.payload_type === 4 && obs.observer_id && tx.decoded_json) {
try {
const d = JSON.parse(tx.decoded_json);
if (d.pubKey) {
if (!this._advertByObserver.has(d.pubKey)) this._advertByObserver.set(d.pubKey, new Set());
this._advertByObserver.get(d.pubKey).add(obs.observer_id);
}
} catch {}
}
this._evict();
this.stats.inserts++;
}
return observationId || transmissionId;
}
/**
* Find ALL packets referencing a node by pubkey index + name + pubkey text search.
* Returns unique transmissions (deduped).
* @param {string} nodeIdOrName - pubkey or friendly name
* @param {Array} [fromPackets] - packet array to filter (defaults to this.packets)
* @returns {{ packets: Array, pubkey: string, nodeName: string }}
*/
findPacketsForNode(nodeIdOrName, fromPackets) {
let pubkey = nodeIdOrName;
let nodeName = nodeIdOrName;
// Always resolve to get both pubkey and name
try {
const row = this.db.prepare("SELECT public_key, name FROM nodes WHERE public_key = ? OR name = ? LIMIT 1").get(nodeIdOrName, nodeIdOrName);
if (row) { pubkey = row.public_key; nodeName = row.name || nodeIdOrName; }
} catch {}
// Combine: index hits + text search
const indexed = this.byNode.get(pubkey);
const hashSet = indexed ? new Set(indexed.map(t => t.hash)) : new Set();
const source = fromPackets || this.packets;
const packets = source.filter(t =>
hashSet.has(t.hash) ||
(t.decoded_json && (t.decoded_json.includes(nodeName) || t.decoded_json.includes(pubkey)))
);
return { packets, pubkey, nodeName };
}
/** Count transmissions and observations for a node */
countForNode(pubkey) {
const txs = this.byNode.get(pubkey) || [];
let observations = 0;
for (const tx of txs) observations += tx.observation_count;
return { transmissions: txs.length, observations };
}
/** Query packets with filters — all from memory (or SQLite in fallback mode) */
query({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node, order = 'DESC' } = {}) {
this.stats.queries++;
if (this.sqliteOnly) return this._querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order });
let results = this.packets;
// Use indexes for single-key filters when possible
if (hash && !type && !route && !region && !observer && !since && !until && !node) {
const tx = this.byHash.get(hash);
results = tx ? [tx] : [];
} else if (observer && !type && !route && !region && !hash && !since && !until && !node) {
// For observer filter, find unique transmissions where any observation matches
results = this._transmissionsForObserver(observer);
} else if (node && !type && !route && !region && !observer && !hash && !since && !until) {
results = this.findPacketsForNode(node).packets;
} else {
// Apply filters sequentially
if (type !== undefined) {
const t = Number(type);
results = results.filter(p => p.payload_type === t);
}
if (route !== undefined) {
const r = Number(route);
results = results.filter(p => p.route_type === r);
}
if (observer) results = this._transmissionsForObserver(observer, results);
if (hash) {
const h = hash.toLowerCase();
const tx = this.byHash.get(h);
results = tx ? results.filter(p => p.hash === h) : [];
}
if (since) results = results.filter(p => p.timestamp > since);
if (until) results = results.filter(p => p.timestamp < until);
if (region) {
const regionObservers = new Set();
try {
const obs = this.db.prepare('SELECT id FROM observers WHERE iata = ?').all(region);
obs.forEach(o => regionObservers.add(o.id));
} catch {}
results = results.filter(p =>
p.observations.some(o => regionObservers.has(o.observer_id))
);
}
if (node) {
results = this.findPacketsForNode(node, results).packets;
}
}
const total = results.length;
// Sort
if (order === 'ASC') {
results = results.slice().sort((a, b) => {
if (a.timestamp < b.timestamp) return -1;
if (a.timestamp > b.timestamp) return 1;
return 0;
});
}
// Default DESC — packets array is already sorted newest-first
// Paginate
const paginated = results.slice(Number(offset), Number(offset) + Number(limit));
return { packets: paginated, total };
}
/** Find unique transmissions that have at least one observation from given observer */
_transmissionsForObserver(observerId, fromTransmissions) {
if (fromTransmissions) {
return fromTransmissions.filter(tx =>
tx.observations.some(o => o.observer_id === observerId)
);
}
// Use byObserver index: get observations, then unique transmissions
const obs = this.byObserver.get(observerId) || [];
const seen = new Set();
const result = [];
for (const o of obs) {
if (!seen.has(o.hash)) {
seen.add(o.hash);
const tx = this.byHash.get(o.hash);
if (tx) result.push(tx);
}
}
return result;
}
/** Query with groupByHash — now trivial since packets ARE transmissions */
queryGrouped({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node } = {}) {
this.stats.queries++;
if (this.sqliteOnly) return this._queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node });
// Get filtered transmissions
const { packets: filtered, total: filteredTotal } = this.query({
limit: 999999, offset: 0, type, route, region, observer, hash, since, until, node
});
// Already grouped by hash — just format for backward compat
const sorted = filtered.map(tx => ({
hash: tx.hash,
first_seen: tx.first_seen || tx.timestamp,
count: tx.observation_count,
observer_count: new Set(tx.observations.map(o => o.observer_id).filter(Boolean)).size,
latest: tx.observations.length ? tx.observations.reduce((max, o) => o.timestamp > max ? o.timestamp : max, tx.observations[0].timestamp) : tx.timestamp,
observer_id: tx.observer_id,
observer_name: tx.observer_name,
path_json: tx.path_json,
payload_type: tx.payload_type,
route_type: tx.route_type,
raw_hex: tx.raw_hex,
decoded_json: tx.decoded_json,
observation_count: tx.observation_count,
snr: tx.snr,
rssi: tx.rssi,
})).sort((a, b) => b.latest.localeCompare(a.latest));
const total = sorted.length;
const paginated = sorted.slice(Number(offset), Number(offset) + Number(limit));
return { packets: paginated, total };
}
/** Get timestamps for sparkline */
getTimestamps(since) {
if (this.sqliteOnly) {
return this.db.prepare('SELECT timestamp FROM packets_v WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
}
const results = [];
for (const p of this.packets) {
if (p.timestamp <= since) break;
results.push(p.timestamp);
}
return results.reverse();
}
/** Get a single packet by ID — checks observation IDs first (backward compat) */
getById(id) {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE id = ?').get(id) || null;
return this.byId.get(id) || null;
}
/** Get a transmission by its transmission table ID */
getByTxId(id) {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM transmissions WHERE id = ?').get(id) || null;
return this.byTxId.get(id) || null;
}
/** Get all siblings of a packet (same hash) — returns observations array */
getSiblings(hash) {
const h = hash.toLowerCase();
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE hash = ? ORDER BY timestamp DESC').all(h);
const tx = this.byHash.get(h);
return tx ? tx.observations : [];
}
/** Get all transmissions (backward compat — returns packets array) */
all() {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all();
return this.packets;
}
/** Get all transmissions matching a filter function */
filter(fn) {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all().filter(fn);
return this.packets.filter(fn);
}
/** Memory stats */
getStats() {
return {
...this.stats,
inMemory: this.sqliteOnly ? 0 : this.packets.length,
sqliteOnly: this.sqliteOnly,
maxPackets: this.maxPackets,
estimatedMB: this.sqliteOnly ? 0 : Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024),
maxMB: Math.round(this.maxBytes / 1024 / 1024),
indexes: {
byHash: this.byHash.size,
byObserver: this.byObserver.size,
byNode: this.byNode.size,
advertByObserver: this._advertByObserver.size,
}
};
}
/** SQLite fallback: query with filters */
_querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order }) {
const where = []; const params = [];
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
if (observer) { where.push('observer_id = ?'); params.push(observer); }
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
if (since) { where.push('timestamp > ?'); params.push(since); }
if (until) { where.push('timestamp < ?'); params.push(until); }
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('decoded_json LIKE ?'); params.push('%' + pk + '%'); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets_v ${w}`).get(...params).c;
const packets = this.db.prepare(`SELECT * FROM packets_v ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
return { packets, total };
}
/** SQLite fallback: grouped query */
_queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node }) {
const where = []; const params = [];
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
if (observer) { where.push('observer_id = ?'); params.push(observer); }
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
if (since) { where.push('timestamp > ?'); params.push(since); }
if (until) { where.push('timestamp < ?'); params.push(until); }
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('decoded_json LIKE ?'); params.push('%' + pk + '%'); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const sql = `SELECT hash, COUNT(*) as count, COUNT(DISTINCT observer_id) as observer_count,
MAX(timestamp) as latest, MIN(observer_id) as observer_id, MIN(observer_name) as observer_name,
MIN(path_json) as path_json, MIN(payload_type) as payload_type, MIN(route_type) as route_type,
MIN(raw_hex) as raw_hex, MIN(decoded_json) as decoded_json, MIN(snr) as snr, MIN(rssi) as rssi
FROM packets_v ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
const packets = this.db.prepare(sql).all(...params, limit, offset);
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets_v ${w}`;
const total = this.db.prepare(countSql).get(...params).c;
return { packets, total };
}
}
module.exports = PacketStore;
+69 -259
View File
@@ -3,17 +3,8 @@
(function () {
let _analyticsData = {};
const sf = (v, d) => (v != null ? v.toFixed(d) : ''); // safe toFixed
function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// --- Status color helpers (read from CSS variables for theme support) ---
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
function statusYellow() { return cssVar('--status-yellow') || '#eab308'; }
function statusRed() { return cssVar('--status-red') || '#ef4444'; }
function accentColor() { return cssVar('--accent') || '#4a9eff'; }
function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); }
// --- SVG helpers ---
function sparkSvg(data, color, w = 120, h = 32) {
if (!data.length) return '';
@@ -49,15 +40,7 @@
return svg;
}
function histogram(data, bins, color, w = 800, h = 180) {
// Support pre-computed histogram from server { bins: [{x, w, count}], min, max }
if (data && data.bins && Array.isArray(data.bins)) {
const buckets = data.bins.map(b => b.count);
const labels = data.bins.map(b => b.x.toFixed(1));
return { svg: barChart(buckets, labels, color, w, h), buckets, labels };
}
// Legacy: raw values array
const values = data;
function histogram(values, bins, color, w = 800, h = 180) {
const min = Math.min(...values), max = Math.max(...values);
const step = (max - min) / bins;
const buckets = Array(bins).fill(0);
@@ -74,17 +57,15 @@
<div class="analytics-header">
<h2>📊 Mesh Analytics</h2>
<p class="text-muted">Deep dive into your mesh network data</p>
<div id="analyticsRegionFilter" class="region-filter-container"></div>
<div class="analytics-tabs" id="analyticsTabs">
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="rf">RF / Signal</button>
<button class="tab-btn" data-tab="topology">Topology</button>
<button class="tab-btn" data-tab="channels">Channels</button>
<button class="tab-btn" data-tab="hashsizes">Hash Stats</button>
<button class="tab-btn" data-tab="collisions">Hash Issues</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>
<button class="tab-btn" data-tab="distance">Distance</button>
</div>
</div>
<div id="analyticsContent" class="analytics-content">
@@ -100,25 +81,9 @@
if (!btn) return;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_currentTab = btn.dataset.tab;
renderTab(_currentTab);
renderTab(btn.dataset.tab);
});
// Deep-link: #/analytics?tab=collisions
const hashParams = location.hash.split('?')[1] || '';
const urlTab = new URLSearchParams(hashParams).get('tab');
if (urlTab) {
const tabBtn = analyticsTabs.querySelector(`[data-tab="${urlTab}"]`);
if (tabBtn) {
analyticsTabs.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
tabBtn.classList.add('active');
_currentTab = urlTab;
}
}
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
RegionFilter.onChange(function () { loadAnalytics(); });
// Delegated click/keyboard handler for clickable table rows
const analyticsContent = document.getElementById('analyticsContent');
if (analyticsContent) {
@@ -133,24 +98,16 @@
analyticsContent.addEventListener('keydown', handler);
}
loadAnalytics();
}
let _currentTab = 'overview';
async function loadAnalytics() {
try {
_analyticsData = {};
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const [hashData, rfData, topoData, chanData] = await Promise.all([
api('/analytics/hash-sizes' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/hash-sizes', { ttl: 60000 }),
api('/analytics/rf', { ttl: 60000 }),
api('/analytics/topology', { ttl: 60000 }),
api('/analytics/channels', { ttl: 60000 }),
]);
_analyticsData = { hashData, rfData, topoData, chanData };
renderTab(_currentTab);
renderTab('overview');
} catch (e) {
document.getElementById('analyticsContent').innerHTML =
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${e.message}</div>`;
@@ -169,7 +126,6 @@
case 'collisions': await renderCollisionTab(el, d.hashData); break;
case 'subpaths': await renderSubpaths(el); break;
case 'nodes': await renderNodesTab(el); break;
case 'distance': await renderDistanceTab(el); break;
}
// Auto-apply column resizing to all analytics tables
requestAnimationFrame(() => {
@@ -178,14 +134,6 @@
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
});
});
// Deep-link scroll to section within tab
const sectionId = new URLSearchParams((location.hash.split('?')[1] || '')).get('section');
if (sectionId) {
setTimeout(() => {
const target = document.getElementById(sectionId);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 400);
}
}
// ===================== OVERVIEW =====================
@@ -193,31 +141,27 @@
const rf = d.rfData, topo = d.topoData, ch = d.chanData, hs = d.hashData;
el.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${(rf.totalTransmissions || rf.totalAllPackets || rf.totalPackets).toLocaleString()}</div>
<div class="stat-label">Total Transmissions</div>
<div class="stat-spark">${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}</div>
</div>
<div class="stat-card">
<div class="stat-value">${rf.totalPackets.toLocaleString()}</div>
<div class="stat-label">Observations with Signal</div>
<div class="stat-label">Total Packets</div>
<div class="stat-spark">${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}</div>
</div>
<div class="stat-card">
<div class="stat-value">${topo.uniqueNodes}</div>
<div class="stat-label">Unique Nodes</div>
</div>
<div class="stat-card">
<div class="stat-value">${sf(rf.snr.avg, 1)} dB</div>
<div class="stat-value">${rf.snr.avg.toFixed(1)} dB</div>
<div class="stat-label">Avg SNR</div>
<div class="stat-detail">${sf(rf.snr.min, 1)} to ${sf(rf.snr.max, 1)}</div>
<div class="stat-detail">${rf.snr.min.toFixed(1)} to ${rf.snr.max.toFixed(1)}</div>
</div>
<div class="stat-card">
<div class="stat-value">${sf(rf.rssi.avg, 0)} dBm</div>
<div class="stat-value">${rf.rssi.avg.toFixed(0)} dBm</div>
<div class="stat-label">Avg RSSI</div>
<div class="stat-detail">${rf.rssi.min} to ${rf.rssi.max}</div>
</div>
<div class="stat-card">
<div class="stat-value">${sf(topo.avgHops, 1)}</div>
<div class="stat-value">${topo.avgHops.toFixed(1)}</div>
<div class="stat-label">Avg Hops</div>
<div class="stat-detail">max ${topo.maxHops}</div>
</div>
@@ -275,8 +219,8 @@
// ===================== RF / SIGNAL =====================
function renderRF(el, rf) {
const snrHist = histogram(rf.snrValues, 20, statusGreen());
const rssiHist = histogram(rf.rssiValues, 20, accentColor());
const snrHist = histogram(rf.snrValues, 20, '#22c55e');
const rssiHist = histogram(rf.rssiValues, 20, '#3b82f6');
el.innerHTML = `
<div class="analytics-row">
@@ -285,11 +229,11 @@
<p class="text-muted">Signal-to-Noise Ratio (higher = cleaner signal)</p>
${snrHist.svg}
<div class="rf-stats">
<span>Min: <strong>${sf(rf.snr.min, 1)} dB</strong></span>
<span>Mean: <strong>${sf(rf.snr.avg, 1)} dB</strong></span>
<span>Median: <strong>${sf(rf.snr.median, 1)} dB</strong></span>
<span>Max: <strong>${sf(rf.snr.max, 1)} dB</strong></span>
<span>σ: <strong>${sf(rf.snr.stddev, 1)} dB</strong></span>
<span>Min: <strong>${rf.snr.min.toFixed(1)} dB</strong></span>
<span>Mean: <strong>${rf.snr.avg.toFixed(1)} dB</strong></span>
<span>Median: <strong>${rf.snr.median.toFixed(1)} dB</strong></span>
<span>Max: <strong>${rf.snr.max.toFixed(1)} dB</strong></span>
<span>σ: <strong>${rf.snr.stddev.toFixed(1)} dB</strong></span>
</div>
</div>
<div class="analytics-card flex-1">
@@ -298,10 +242,10 @@
${rssiHist.svg}
<div class="rf-stats">
<span>Min: <strong>${rf.rssi.min} dBm</strong></span>
<span>Mean: <strong>${sf(rf.rssi.avg, 0)} dBm</strong></span>
<span>Mean: <strong>${rf.rssi.avg.toFixed(0)} dBm</strong></span>
<span>Median: <strong>${rf.rssi.median} dBm</strong></span>
<span>Max: <strong>${rf.rssi.max} dBm</strong></span>
<span>σ: <strong>${sf(rf.rssi.stddev, 1)} dBm</strong></span>
<span>σ: <strong>${rf.rssi.stddev.toFixed(1)} dBm</strong></span>
</div>
</div>
</div>
@@ -357,21 +301,20 @@
svg += `<text x="${pad-4}" y="${y+3}" text-anchor="end" font-size="9" fill="var(--text-muted)">${rssi}</text>`;
}
// Quality zones
const _sg = statusGreen(), _sy = statusYellow(), _sr = statusRed();
const zones = [
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: _sg + '20' },
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: _sy + '15' },
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: _sr + '10' },
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: '#22c55e20' },
{ 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="${_sg}" 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="${_sy}" 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="${_sr}" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="${_sr}" stroke-width="0.5" opacity="0.4"/></pattern>`;
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': _sg, 'Good': _sy, 'Weak': _sr };
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);
@@ -398,13 +341,13 @@
let html = '<table class="analytics-table"><thead><tr><th>Type</th><th>Packets</th><th>Avg SNR</th><th>Min</th><th>Max</th><th>Distribution</th></tr></thead><tbody>';
snrByType.forEach(t => {
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
const color = t.avg > 6 ? '#22c55e' : t.avg > 0 ? '#f59e0b' : '#ef4444';
html += `<tr>
<td><strong>${t.name}</strong></td>
<td>${t.count}</td>
<td><strong>${sf(t.avg, 1)} dB</strong></td>
<td>${sf(t.min, 1)}</td>
<td>${sf(t.max, 1)}</td>
<td><strong>${t.avg.toFixed(1)} dB</strong></td>
<td>${t.min.toFixed(1)}</td>
<td>${t.max.toFixed(1)}</td>
<td><div class="hash-bar-track" style="height:14px"><div class="hash-bar-fill" style="width:${barPct}%;background:${color};height:100%"></div></div></td>
</tr>`;
});
@@ -421,7 +364,7 @@
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
return `${x},${y}`;
}).join(' ');
svg += `<polyline points="${snrPts}" fill="none" stroke="${statusGreen()}" stroke-width="2"/>`;
svg += `<polyline points="${snrPts}" fill="none" stroke="#22c55e" stroke-width="2"/>`;
// Packet count as area
const areaPts = data.map((d, i) => {
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
@@ -440,7 +383,7 @@
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${data[i].hour.slice(11)}h</text>`;
}
svg += '</svg>';
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:${statusGreen()}"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:#22c55e"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
return svg;
}
@@ -453,7 +396,7 @@
<p class="text-muted">Number of repeater hops per packet</p>
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
<div class="rf-stats">
<span>Avg: <strong>${sf(topo.avgHops, 1)} hops</strong></span>
<span>Avg: <strong>${topo.avgHops.toFixed(1)} hops</strong></span>
<span>Median: <strong>${topo.medianHops}</strong></span>
<span>Max: <strong>${topo.maxHops}</strong></span>
<span>1-hop direct: <strong>${topo.hopDistribution[0]?.count || 0}</strong></span>
@@ -555,7 +498,7 @@
const x = pad + (d.hops / maxHop) * (w - pad * 2);
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
const r = Math.min(Math.sqrt(d.count) * 1.5, 12);
const color = d.avgSnr > 6 ? statusGreen() : d.avgSnr > 0 ? statusYellow() : statusRed();
const color = d.avgSnr > 6 ? '#22c55e' : d.avgSnr > 0 ? '#f59e0b' : '#ef4444';
svg += `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6"/>`;
svg += `<text x="${x}" y="${y-r-3}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${d.hops}h</text>`;
});
@@ -649,7 +592,7 @@
<tbody>
${ch.channels.map(c => `<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=${c.hash}" tabindex="0" role="row">
<td><strong>${esc(c.name || 'Unknown')}</strong></td>
<td class="mono">${typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash}</td>
<td class="mono">${c.hash}</td>
<td>${c.messages}</td>
<td>${c.senders}</td>
<td>${timeAgo(c.lastActivity)}</td>
@@ -792,64 +735,19 @@
async function renderCollisionTab(el, data) {
el.innerHTML = `
<nav id="hashIssuesToc" style="display:flex;gap:12px;margin-bottom:12px;font-size:13px;flex-wrap:wrap">
<a href="#/analytics?tab=collisions&section=inconsistentHashSection" style="color:var(--accent)"> Inconsistent Sizes</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=collisions&section=hashMatrixSection" style="color:var(--accent)">🔢 Hash Matrix</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=collisions&section=collisionRiskSection" style="color:var(--accent)">💥 Collision Risk</a>
</nav>
<div class="analytics-card" id="inconsistentHashSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0"> Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Nodes sending adverts with varying hash sizes. Caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">bug</a> where automatic adverts ignored the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</p>
<div id="inconsistentHashList"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading</div></div>
</div>
<div class="analytics-card" id="hashMatrixSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">🔢 1-Byte Hash Usage Matrix</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.</p>
<div class="analytics-card">
<h3>1-Byte Hash Usage Matrix</h3>
<p class="text-muted" style="margin:0 0 8px;font-size:0.8em">Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.</p>
<div id="hashMatrix"></div>
</div>
<div class="analytics-card" id="collisionRiskSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">💥 1-Byte Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<div class="analytics-card">
<h3>1-Byte Collision Risk</h3>
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading</div></div>
</div>
`;
let allNodes = [];
try { const nd = await api('/nodes?limit=2000' + RegionFilter.regionQueryString(), { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
// Render inconsistent hash sizes
const inconsistent = allNodes.filter(n => n.hash_size_inconsistent);
const ihEl = document.getElementById('inconsistentHashList');
if (ihEl) {
if (!inconsistent.length) {
ihEl.innerHTML = '<div class="text-muted" style="padding:4px">✅ No inconsistencies detected — all nodes are reporting consistent hash sizes.</div>';
} else {
ihEl.innerHTML = `<table class="analytics-table" style="background:var(--card-bg);border:1px solid var(--border);border-radius:8px;overflow:hidden">
<thead><tr><th>Node</th><th>Role</th><th>Current Hash</th><th>Sizes Seen</th></tr></thead>
<tbody>${inconsistent.map((n, i) => {
const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280';
const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?';
const sizeBadges = (n.hash_sizes_seen || []).map(s => {
const c = s >= 3 ? '#16a34a' : s === 2 ? '#86efac' : '#f97316';
const fg = s === 2 ? '#064e3b' : '#fff';
return '<span class="badge" style="background:' + c + ';color:' + fg + ';font-size:10px;font-family:var(--mono)">' + s + 'B</span>';
}).join(' ');
const stripe = i % 2 === 1 ? 'background:var(--row-stripe)' : '';
return `<tr style="${stripe}">
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" style="font-weight:600;color:var(--accent)">${esc(n.name || n.public_key.slice(0, 12))}</a></td>
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
<td><code style="font-family:var(--mono);font-weight:700">${prefix}</code> <span class="text-muted">(${n.hash_size || '?'}B)</span></td>
<td>${sizeBadges}</td>
</tr>`;
}).join('')}</tbody>
</table>
<p class="text-muted" style="margin:8px 0 0;font-size:0.8em">${inconsistent.length} node${inconsistent.length > 1 ? 's' : ''} affected. Click a node name to see which adverts have different hash sizes.</p>`;
}
}
try { const nd = await api('/nodes?limit=2000', { ttl: 10000 }); allNodes = nd.nodes || []; } catch {}
renderHashMatrix(data.topHops, allNodes);
renderCollisions(data.topHops, allNodes);
}
@@ -1001,13 +899,13 @@
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
badge = '<span class="badge" style="background:#22c55e;color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
} else if (c.classification === 'regional') {
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
badge = '<span class="badge" style="background:#f59e0b;color:#fff" title="Nodes 50200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
badge = '<span class="badge" style="background:#ef4444;color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
@@ -1039,12 +937,11 @@
async function renderSubpaths(el) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
try {
const rq = RegionFilter.regionQueryString();
const [d2, d3, d4, d5] = await Promise.all([
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15' + rq, { ttl: CLIENT_TTL.analyticsRF })
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: 60000 }),
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: 60000 }),
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: 60000 }),
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: 60000 })
]);
function renderTable(data, title) {
@@ -1067,7 +964,7 @@
<td>${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help">🔄</span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
<td>${s.count.toLocaleString()}</td>
<td>${s.pct}%</td>
<td><div style="background:${hasSelfLoop ? 'var(--status-yellow)' : 'var(--accent)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
<td><div style="background:${hasSelfLoop ? '#f59e0b' : 'var(--accent,#3b82f6)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
</tr>`;
}).join('')}
</tbody></table>`;
@@ -1135,7 +1032,7 @@
panel.classList.remove('collapsed');
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
try {
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF });
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: 60000 });
renderSubpathDetail(panel, data);
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
@@ -1167,7 +1064,7 @@
const dLon = (a.lon - b.lon) * 85;
const km = Math.sqrt(dLat*dLat + dLon*dLon);
total += km;
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
const cls = km > 200 ? 'color:#ef4444;font-weight:bold' : km > 50 ? 'color:#f59e0b' : 'color:#22c55e';
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)}${esc(b.name)}</span></div>`);
} else {
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)}${esc(b.name)} (no coords)</span></div>`);
@@ -1220,7 +1117,7 @@
// Render minimap
if (hasMap && typeof L !== 'undefined') {
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
L.tileLayer(getTileUrl(), { maxZoom: 18 }).addTo(map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 18 }).addTo(map);
const latlngs = [];
nodesWithLoc.forEach((n, i) => {
@@ -1229,13 +1126,13 @@
const isEnd = i === 0 || i === nodesWithLoc.length - 1;
L.circleMarker(ll, {
radius: isEnd ? 8 : 5,
color: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
fillColor: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
color: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
fillColor: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
fillOpacity: 0.9, weight: 2
}).bindTooltip(n.name, { permanent: false }).addTo(map);
});
L.polyline(latlngs, { color: statusYellow(), weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
L.polyline(latlngs, { color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
}
}
@@ -1243,11 +1140,10 @@
async function renderNodesTab(el) {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
try {
const rq = RegionFilter.regionQueryString();
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
api('/nodes?limit=200&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/nodes/network-status' + (rq ? '?' + rq.slice(1) : ''), { ttl: CLIENT_TTL.analyticsRF })
api('/nodes?limit=200&sortBy=lastSeen', { ttl: 10000 }),
api('/nodes/bulk-health?limit=50', { ttl: 60000 }),
api('/nodes/network-status', { ttl: 60000 })
]);
const nodes = nodesResp.nodes || nodesResp;
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
@@ -1259,7 +1155,7 @@
const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } }));
// Compute rankings
const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalTransmissions || b.health.stats.totalPackets || 0) - (a.health.stats.totalTransmissions || a.health.stats.totalPackets || 0));
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));
@@ -1274,22 +1170,22 @@
return myKeys.has(n.public_key) ? ' <span style="color:var(--accent);font-size:10px">★ MINE</span>' : '';
}
// ROLE_COLORS from shared roles.js
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:var(--status-green)">${active}</div>
<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:var(--status-yellow)">${degraded}</div>
<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:var(--status-red)">${silent}</div>
<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">
@@ -1315,7 +1211,7 @@
return `<tr>
<td>${nodeLink(n)}</td>
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
<td>${s.totalTransmissions || s.totalPackets || 0}</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>
@@ -1332,7 +1228,7 @@
<td>${i + 1}</td>
<td>${nodeLink(n)}${claimedBadge(n)}</td>
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
<td>${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0}</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('')}
@@ -1388,92 +1284,6 @@
}
}
async function renderDistanceTab(el) {
try {
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
const s = data.summary;
let html = `<div class="analytics-grid">
<div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div>
<div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div>
<div class="stat-card"><div class="stat-value">${s.avgDist} km</div><div class="stat-label">Avg Hop Distance</div></div>
<div class="stat-card"><div class="stat-value">${s.maxDist} km</div><div class="stat-label">Max Hop Distance</div></div>
</div>`;
// Category stats
const cats = data.catStats;
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th>Type</th><th>Count</th><th>Avg (km)</th><th>Median (km)</th><th>Min (km)</th><th>Max (km)</th></tr></thead><tbody>`;
for (const [cat, st] of Object.entries(cats)) {
if (!st.count) continue;
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
}
html += `</tbody></table></div>`;
// Histogram
if (data.distHistogram && data.distHistogram.bins) {
const buckets = data.distHistogram.bins.map(b => b.count);
const labels = data.distHistogram.bins.map(b => b.x.toFixed(1));
html += `<div class="analytics-section"><h3>Hop Distance Distribution</h3>${barChart(buckets, labels, statusGreen())}</div>`;
}
// Distance over time
if (data.distOverTime && data.distOverTime.length > 1) {
html += `<div class="analytics-section"><h3>Average Distance Over Time</h3>${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}</div>`;
}
// Top hops leaderboard
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th>#</th><th>From</th><th>To</th><th>Distance (km)</th><th>Type</th><th>SNR</th><th>Packet</th><th></th></tr></thead><tbody>`;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
const toLink = h.toPk ? `<a href="#/nodes/${encodeURIComponent(h.toPk)}" class="analytics-link">${esc(h.toName)}</a>` : esc(h.toName || '?');
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${h.dist}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
});
html += `</tbody></table></div>`;
// Top paths
if (data.topPaths.length) {
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th>#</th><th>Total Distance (km)</th><th>Hops</th><th>Route</th><th>Packet</th><th></th></tr></thead><tbody>`;
data.topPaths.slice(0, 10).forEach((p, i) => {
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
// Collect all unique pubkeys in path order
const pathPks = [];
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
html += `<tr><td>${i+1}</td><td><strong>${p.totalDist}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
});
html += `</tbody></table></div>`;
}
el.innerHTML = html;
// Wire up map buttons
el.querySelectorAll('.dist-map-hop').forEach(btn => {
btn.addEventListener('click', () => {
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops: [btn.dataset.from, btn.dataset.to] }));
window.location.hash = '#/map?route=1';
});
});
el.querySelectorAll('.dist-map-path').forEach(btn => {
btn.addEventListener('click', () => {
try {
const hops = JSON.parse(btn.dataset.hops);
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops }));
window.location.hash = '#/map?route=1';
} catch {}
});
});
} catch (e) {
el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load distance analytics: ${esc(e.message)}</div>`;
}
}
function destroy() { _analyticsData = {}; }
registerPage('analytics', { init, destroy });
+16 -170
View File
@@ -3,7 +3,7 @@
// --- Route/Payload name maps ---
const ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' };
const PAYLOAD_TYPES = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 6: 'Group Data', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 10: 'Multipart', 11: 'Control', 15: 'Raw Custom' };
const PAYLOAD_TYPES = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
const PAYLOAD_COLORS = { 0: 'req', 1: 'response', 2: 'txt-msg', 3: 'ack', 4: 'advert', 5: 'grp-txt', 7: 'anon-req', 8: 'path', 9: 'trace' };
function routeTypeName(n) { return ROUTE_TYPES[n] || 'UNKNOWN'; }
@@ -14,21 +14,6 @@ function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 };
const _apiCache = new Map();
const _inflight = new Map();
// Client-side TTLs (ms) — loaded from server config, with defaults
const CLIENT_TTL = {
stats: 10000, nodeDetail: 240000, nodeHealth: 240000, nodeList: 90000,
bulkHealth: 300000, networkStatus: 300000, observers: 120000,
channels: 15000, channelMessages: 10000, analyticsRF: 300000,
analyticsTopology: 300000, analyticsChannels: 300000, analyticsHashSizes: 300000,
analyticsSubpaths: 300000, analyticsSubpathDetail: 300000,
nodeAnalytics: 60000, nodeSearch: 10000
};
// Fetch server cache config and use as client TTLs (server values are in seconds)
fetch('/api/config/cache').then(r => r.json()).then(cfg => {
for (const [k, v] of Object.entries(cfg)) {
if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000;
}
}).catch(() => {});
async function api(path, { ttl = 0, bust = false } = {}) {
const t0 = performance.now();
if (!bust && ttl > 0) {
@@ -81,9 +66,8 @@ window.apiPerf = function() {
})).sort((a, b) => b.totalMs - a.totalMs);
console.table(rows);
const hitRate = _apiPerf.calls ? Math.round(_apiPerf.cacheHits / _apiPerf.calls * 100) : 0;
const misses = _apiPerf.calls - _apiPerf.cacheHits;
console.log(`Cache: ${_apiPerf.cacheHits} hits / ${misses} misses (${hitRate}% hit rate)`);
return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (misses || 1)), cacheHits: _apiPerf.cacheHits, cacheMisses: misses, cacheHitRate: hitRate, endpoints: rows };
console.log(`Cache: ${_apiPerf.cacheHits} hits / ${_apiPerf.calls} calls (${hitRate}% hit rate)`);
return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (_apiPerf.calls - _apiPerf.cacheHits || 1)), cacheHits: _apiPerf.cacheHits, hitRate: hitRate + '%', endpoints: rows };
};
function timeAgo(iso) {
@@ -209,14 +193,10 @@ function connectWS() {
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
// Debounce cache invalidation — don't nuke on every packet
if (!api._invalidateTimer) {
api._invalidateTimer = setTimeout(() => {
api._invalidateTimer = null;
invalidateApiCache('/stats');
invalidateApiCache('/nodes');
}, 5000);
}
// Invalidate caches when new data arrives
invalidateApiCache('/stats');
invalidateApiCache('/nodes');
invalidateApiCache('/channels');
wsListeners.forEach(fn => fn(msg));
} catch {}
};
@@ -227,8 +207,8 @@ function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
/* Global escapeHtml — used by multiple pages */
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* Global debounce */
@@ -282,16 +262,6 @@ function navigate() {
basePage = 'node-analytics';
}
// Special route: packet/123 → standalone packet detail page
if (basePage === 'packet' && routeParam) {
basePage = 'packet-detail';
}
// Special route: observers/ID → observer detail page
if (basePage === 'observers' && routeParam) {
basePage = 'observer-detail';
}
// Update nav active state
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
el.classList.toggle('active', el.dataset.route === basePage);
@@ -315,14 +285,6 @@ function navigate() {
}
window.addEventListener('hashchange', navigate);
let _themeRefreshTimer = null;
window.addEventListener('theme-changed', () => {
if (_themeRefreshTimer) clearTimeout(_themeRefreshTimer);
_themeRefreshTimer = setTimeout(() => {
_themeRefreshTimer = null;
window.dispatchEvent(new CustomEvent('theme-refresh'));
}, 300);
});
window.addEventListener('DOMContentLoaded', () => {
connectWS();
@@ -333,43 +295,6 @@ window.addEventListener('DOMContentLoaded', () => {
document.documentElement.setAttribute('data-theme', theme);
darkToggle.textContent = theme === 'dark' ? '🌙' : '☀️';
localStorage.setItem('meshcore-theme', theme);
// Re-apply user theme CSS vars for the correct mode (light/dark)
reapplyUserThemeVars(theme === 'dark');
}
function reapplyUserThemeVars(dark) {
try {
var userTheme = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
if (!userTheme.theme && !userTheme.themeDark) {
// Fall back to server config
var cfg = window.SITE_CONFIG || {};
if (!cfg.theme && !cfg.themeDark) return;
userTheme = cfg;
}
var themeData = dark ? Object.assign({}, userTheme.theme || {}, userTheme.themeDark || {}) : (userTheme.theme || {});
if (!Object.keys(themeData).length) return;
var varMap = {
accent: '--accent', accentHover: '--accent-hover',
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
selectedBg: '--selected-bg', sectionBg: '--section-bg',
font: '--font', mono: '--mono'
};
var root = document.documentElement.style;
for (var key in varMap) {
if (themeData[key]) root.setProperty(varMap[key], themeData[key]);
}
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
// Nav gradient
if (themeData.navBg) {
var nav = document.querySelector('.top-nav');
if (nav) { nav.style.background = ''; void nav.offsetHeight; }
}
} catch (e) { console.error('[theme] reapply error:', e); }
}
// On load: respect saved pref, else OS pref, else light
if (savedTheme) {
@@ -425,9 +350,9 @@ window.addEventListener('DOMContentLoaded', () => {
favDropdown.innerHTML = '<div class="fav-dd-loading">Loading...</div>';
const items = await Promise.all(favs.map(async (pk) => {
try {
const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth });
const h = await api('/nodes/' + pk + '/health', { ttl: 30000 });
const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null;
const status = age === null ? '🔴' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? '🟢' : age < HEALTH_THRESHOLDS.nodeSilentMs ? '🟡' : '🔴';
const status = age === null ? '🔴' : age < 3600000 ? '🟢' : age < 86400000 ? '🟡' : '🔴';
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
+ '<span class="fav-dd-status">' + status + '</span>'
+ '<span class="fav-dd-name">' + (h.node.name || truncate(pk, 12)) + '</span>'
@@ -499,7 +424,7 @@ window.addEventListener('DOMContentLoaded', () => {
const pktList = packets.packets || packets;
if (Array.isArray(pktList)) {
for (const p of pktList.slice(0, 5)) {
html += `<div class="search-result-item" onclick="location.hash='#/packets/${p.packet_hash || p.hash || p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
html += `<div class="search-result-item" onclick="location.hash='#/packets?id=${p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
<span class="search-result-type">Packet</span>${truncate(p.packet_hash || '', 16)} ${payloadTypeName(p.payload_type)}</div>`;
}
}
@@ -513,7 +438,7 @@ window.addEventListener('DOMContentLoaded', () => {
const chList = Array.isArray(channels) ? channels : [];
for (const c of chList) {
if (c.name && c.name.toLowerCase().includes(q.toLowerCase())) {
html += `<div class="search-result-item" onclick="location.hash='#/channels/${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
html += `<div class="search-result-item" onclick="location.hash='#/channels?ch=${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
<span class="search-result-type">Channel</span>${c.name}</div>`;
}
}
@@ -529,7 +454,7 @@ window.addEventListener('DOMContentLoaded', () => {
// --- Nav Stats ---
async function updateNavStats() {
try {
const stats = await api('/stats', { ttl: CLIENT_TTL.stats });
const stats = await api('/stats', { ttl: 5000 });
const el = document.getElementById('navStats');
if (el) {
el.innerHTML = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs`;
@@ -542,87 +467,8 @@ window.addEventListener('DOMContentLoaded', () => {
setInterval(updateNavStats, 15000);
debouncedOnWS(function () { updateNavStats(); });
// --- Theme Customization ---
// Fetch theme config and apply branding/colors before first render
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
window.SITE_CONFIG = cfg;
// User's localStorage preferences take priority over server config
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
if (!userTheme.theme && !userTheme.themeDark) {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
const root = document.documentElement.style;
const varMap = {
accent: '--accent', accentHover: '--accent-hover',
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
selectedBg: '--selected-bg', sectionBg: '--section-bg',
font: '--font', mono: '--mono'
};
for (const [key, cssVar] of Object.entries(varMap)) {
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
}
// Derived vars
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
// Nav gradient
if (themeData.navBg) {
const nav = document.querySelector('.top-nav');
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
}
}
// Apply node color overrides (skip if user has local preferences)
if (cfg.nodeColors && !userTheme.nodeColors) {
for (const [role, color] of Object.entries(cfg.nodeColors)) {
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
}
}
// Apply type color overrides (skip if user has local preferences)
if (cfg.typeColors && !userTheme.typeColors) {
for (const [type, color] of Object.entries(cfg.typeColors)) {
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
}
if (window.syncBadgeColors) window.syncBadgeColors();
}
// Apply branding (skip if user has local preferences)
if (cfg.branding && !userTheme.branding) {
if (cfg.branding.siteName) {
document.title = cfg.branding.siteName;
const brandText = document.querySelector('.brand-text');
if (brandText) brandText.textContent = cfg.branding.siteName;
}
if (cfg.branding.logoUrl) {
const brandIcon = document.querySelector('.brand-icon');
if (brandIcon) {
const img = document.createElement('img');
img.src = cfg.branding.logoUrl;
img.alt = cfg.branding.siteName || 'Logo';
img.style.height = '24px';
img.style.width = 'auto';
brandIcon.replaceWith(img);
}
}
if (cfg.branding.faviconUrl) {
const favicon = document.querySelector('link[rel="icon"]');
if (favicon) favicon.href = cfg.branding.faviconUrl;
}
}
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
if (!location.hash || location.hash === '#/') location.hash = '#/home';
else navigate();
});
if (!location.hash || location.hash === '#/') location.hash = '#/home';
else navigate();
});
/**
-562
View File
@@ -1,562 +0,0 @@
/* === MeshCore Analyzer — audio-lab.js === */
/* Audio Lab: Packet Jukebox for sound debugging & understanding */
'use strict';
(function () {
let styleEl = null;
let loopTimer = null;
let selectedPacket = null;
let baseBPM = 120;
let speedMult = 1;
let highlightTimers = [];
const TYPE_COLORS = window.TYPE_COLORS || {
ADVERT: '#f59e0b', GRP_TXT: '#10b981', TXT_MSG: '#6366f1',
TRACE: '#8b5cf6', REQ: '#ef4444', RESPONSE: '#3b82f6',
ACK: '#6b7280', PATH: '#ec4899', ANON_REQ: '#f97316', UNKNOWN: '#6b7280'
};
const SCALE_NAMES = {
ADVERT: 'C major pentatonic', GRP_TXT: 'A minor pentatonic',
TXT_MSG: 'E natural minor', TRACE: 'D whole tone'
};
const SYNTH_TYPES = {
ADVERT: 'triangle', GRP_TXT: 'sine', TXT_MSG: 'triangle', TRACE: 'sine'
};
const SCALE_INTERVALS = {
ADVERT: { intervals: [0,2,4,7,9], root: 48 },
GRP_TXT: { intervals: [0,3,5,7,10], root: 45 },
TXT_MSG: { intervals: [0,2,3,5,7,8,10], root: 40 },
TRACE: { intervals: [0,2,4,6,8,10], root: 50 },
};
function injectStyles() {
if (styleEl) return;
styleEl = document.createElement('style');
styleEl.textContent = `
.alab { display: flex; height: 100%; overflow: hidden; }
.alab-sidebar { width: 280px; min-width: 200px; border-right: 1px solid var(--border);
overflow-y: auto; padding: 12px; background: var(--surface-1); }
.alab-main { flex: 1; overflow-y: auto; padding: 16px 24px; }
.alab-type-hdr { font-weight: 700; font-size: 13px; padding: 6px 8px; margin-top: 8px;
border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.alab-type-hdr:hover { opacity: 0.8; }
.alab-type-list { padding: 0; }
.alab-pkt { padding: 5px 8px 5px 16px; font-size: 12px; font-family: var(--mono);
cursor: pointer; border-radius: 4px; color: var(--text-muted); }
.alab-pkt:hover { background: var(--hover-bg); }
.alab-pkt.selected { background: var(--selected-bg); color: var(--text); font-weight: 600; }
.alab-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
padding: 12px 16px; background: var(--surface-1); border-radius: 8px; margin-bottom: 16px; border: 1px solid var(--border); }
.alab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface-1); color: var(--text); cursor: pointer; font-size: 13px; }
.alab-btn:hover { background: var(--hover-bg); }
.alab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.alab-speed { padding: 4px 8px; font-size: 12px; border-radius: 4px; border: 1px solid var(--border);
background: var(--surface-1); color: var(--text-muted); cursor: pointer; }
.alab-speed.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.alab-section { background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.alab-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-muted); font-weight: 600; }
.alab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
.alab-stat { font-size: 12px; }
.alab-stat .label { color: var(--text-muted); }
.alab-stat .value { font-weight: 600; font-family: var(--mono); }
.alab-hex { font-family: var(--mono); font-size: 11px; word-break: break-all; line-height: 1.6;
max-height: 80px; overflow: hidden; transition: max-height 0.3s; }
.alab-hex.expanded { max-height: none; }
.alab-hex .sampled { background: var(--accent); color: #fff; border-radius: 2px; padding: 0 1px; }
.alab-note-table { width: 100%; font-size: 12px; border-collapse: collapse; }
.alab-note-table th { text-align: left; font-weight: 600; color: var(--text-muted);
padding: 4px 8px; border-bottom: 1px solid var(--border); font-size: 11px; }
.alab-note-table td { padding: 4px 8px; border-bottom: 1px solid var(--border); font-family: var(--mono); }
.alab-byte-viz { display: flex; align-items: flex-end; height: 60px; gap: 1px; margin-top: 8px; }
.alab-byte-bar { flex: 1; min-width: 2px; border-radius: 1px 1px 0 0; transition: box-shadow 0.1s; }
.alab-byte-bar.playing { box-shadow: 0 0 8px 2px currentColor; transform: scaleY(1.15); }
.alab-hex .playing { background: #ff6b6b !important; color: #fff !important; border-radius: 2px; padding: 0 2px; transition: background 0.1s; }
.alab-note-table tr.playing { background: var(--accent) !important; color: #fff; }
.alab-note-table tr.playing td { color: #fff; }
.alab-map-table { width: 100%; font-size: 13px; border-collapse: collapse; }
.alab-map-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.alab-map-table .map-param { font-weight: 600; white-space: nowrap; width: 110px; }
.alab-map-table .map-value { font-family: var(--mono); font-weight: 700; white-space: nowrap; width: 120px; }
.alab-map-table .map-why { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }
.map-why-inline { display: block; font-size: 10px; color: var(--text-muted); font-family: var(--mono); margin-top: 2px; }
.alab-note-play { background: none; border: 1px solid var(--border); border-radius: 4px; cursor: pointer;
font-size: 10px; padding: 2px 6px; color: var(--text-muted); }
.alab-note-play:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.alab-note-clickable { cursor: pointer; }
.alab-note-clickable:hover { background: var(--hover-bg); }
.alab-empty { text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 15px; }
.alab-slider-group { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
.alab-slider-group input[type=range] { width: 80px; }
.alab-slider-group select { font-size: 12px; padding: 2px 4px; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
@media (max-width: 768px) {
.alab { flex-direction: column; }
.alab-sidebar { width: 100%; max-height: 200px; border-right: none; border-bottom: 1px solid var(--border); }
.alab-main { padding: 12px; }
}
`;
document.head.appendChild(styleEl);
}
function parseHex(hex) {
const bytes = [];
for (let i = 0; i < hex.length; i += 2) {
const b = parseInt(hex.slice(i, i + 2), 16);
if (!isNaN(b)) bytes.push(b);
}
return bytes;
}
function computeMapping(pkt) {
const { buildScale, midiToFreq, mapRange, quantizeToScale } = MeshAudio.helpers;
const rawHex = pkt.raw_hex || '';
const allBytes = parseHex(rawHex);
if (allBytes.length < 3) return null;
const payloadBytes = allBytes.slice(3);
let typeName = 'UNKNOWN';
try { const d = JSON.parse(pkt.decoded_json || '{}'); typeName = d.type || 'UNKNOWN'; } catch {}
const hops = [];
try { const p = JSON.parse(pkt.path_json || '[]'); if (Array.isArray(p)) hops.push(...p); } catch {}
const hopCount = Math.max(1, hops.length);
const obsCount = pkt.observation_count || 1;
const si = SCALE_INTERVALS[typeName] || SCALE_INTERVALS.ADVERT;
const scale = buildScale(si.intervals, si.root);
const scaleName = SCALE_NAMES[typeName] || 'C major pentatonic';
const oscType = SYNTH_TYPES[typeName] || 'triangle';
const noteCount = Math.max(2, Math.min(10, Math.ceil(Math.sqrt(payloadBytes.length))));
const sampledIndices = [];
const sampledBytes = [];
for (let i = 0; i < noteCount; i++) {
const idx = Math.floor((i / noteCount) * payloadBytes.length);
sampledIndices.push(idx);
sampledBytes.push(payloadBytes[idx]);
}
const filterHz = Math.round(mapRange(Math.min(hopCount, 10), 1, 10, 8000, 800));
const volume = Math.min(0.6, 0.15 + (obsCount - 1) * 0.02);
const voiceCount = Math.min(Math.max(1, Math.ceil(Math.log2(obsCount + 1))), 8);
let panValue = 0;
let panSource = 'no location data → center';
try {
const d = JSON.parse(pkt.decoded_json || '{}');
if (d.lon != null) {
panValue = Math.max(-1, Math.min(1, mapRange(d.lon, -125, -65, -1, 1)));
panSource = `lon ${d.lon.toFixed(1)}° → map(-125...-65) → ${panValue.toFixed(2)}`;
}
} catch {}
// Detune description
const detuneDesc = [];
for (let v = 0; v < voiceCount; v++) {
const d = v === 0 ? 0 : (v % 2 === 0 ? 1 : -1) * (v * 5 + 3);
detuneDesc.push((d >= 0 ? '+' : '') + d + '¢');
}
const bpm = MeshAudio.getBPM ? MeshAudio.getBPM() : 120;
const tm = 60 / bpm; // BPM already includes speed multiplier
const notes = sampledBytes.map((byte, i) => {
const midi = quantizeToScale(byte, scale);
const freq = midiToFreq(midi);
const duration = mapRange(byte, 0, 255, 0.05, 0.4) * tm * 1000;
let gap = 0.05 * tm * 1000;
if (i < sampledBytes.length - 1) {
const delta = Math.abs(sampledBytes[i + 1] - byte);
gap = mapRange(delta, 0, 255, 0.03, 0.3) * tm * 1000;
}
return { index: sampledIndices[i], byte, midi, freq: Math.round(freq), duration: Math.round(duration), gap: Math.round(gap) };
});
return {
typeName, allBytes, payloadBytes, sampledIndices, sampledBytes, notes,
noteCount, filterHz, volume: volume.toFixed(3), voiceCount, panValue: panValue.toFixed(2),
oscType, scaleName, hopCount, obsCount,
totalSize: allBytes.length, payloadSize: payloadBytes.length,
color: TYPE_COLORS[typeName] || TYPE_COLORS.UNKNOWN,
panSource, detuneDesc,
};
}
function renderDetail(pkt, app) {
const m = computeMapping(pkt);
if (!m) { document.getElementById('alabDetail').innerHTML = '<div class="alab-empty">No raw hex data for this packet</div>'; return; }
// Hex dump with sampled bytes highlighted
const sampledSet = new Set(m.sampledIndices);
let hexHtml = '';
for (let i = 0; i < m.payloadBytes.length; i++) {
const h = m.payloadBytes[i].toString(16).padStart(2, '0').toUpperCase();
if (sampledSet.has(i)) hexHtml += `<span class="sampled" id="hexByte${i}">${h}</span> `;
else hexHtml += `<span id="hexByte${i}">${h}</span> `;
}
document.getElementById('alabDetail').innerHTML = `
<div class="alab-section">
<h3>📦 Packet Data</h3>
<div class="alab-grid">
<div class="alab-stat"><span class="label">Type</span><br><span class="value" style="color:${m.color}">${m.typeName}</span></div>
<div class="alab-stat"><span class="label">Total Size</span><br><span class="value">${m.totalSize} bytes</span></div>
<div class="alab-stat"><span class="label">Payload Size</span><br><span class="value">${m.payloadSize} bytes</span></div>
<div class="alab-stat"><span class="label">Hops</span><br><span class="value">${m.hopCount}</span></div>
<div class="alab-stat"><span class="label">Observations</span><br><span class="value">${m.obsCount}</span></div>
<div class="alab-stat"><span class="label">Hash</span><br><span class="value">${pkt.hash || ''}</span></div>
</div>
<div style="margin-top:10px">
<div class="alab-hex" id="alabHex" onclick="this.classList.toggle('expanded')" title="Click to expand">${hexHtml}</div>
</div>
</div>
<div class="alab-section">
<h3>🎵 Sound Mapping</h3>
<table class="alab-map-table">
<tr>
<td class="map-param">Instrument</td>
<td class="map-value">${m.oscType}</td>
<td class="map-why">payload_type = ${m.typeName} ${m.oscType} oscillator</td>
</tr>
<tr>
<td class="map-param">Scale</td>
<td class="map-value">${m.scaleName}</td>
<td class="map-why">payload_type = ${m.typeName} ${m.scaleName} (root MIDI ${SCALE_INTERVALS[m.typeName]?.root || 48})</td>
</tr>
<tr>
<td class="map-param">Notes</td>
<td class="map-value">${m.noteCount}</td>
<td class="map-why">${m.payloadSize} = ${Math.sqrt(m.payloadSize).toFixed(1)} = ${m.noteCount} bytes sampled evenly across payload</td>
</tr>
<tr>
<td class="map-param">Filter Cutoff</td>
<td class="map-value">${m.filterHz} Hz</td>
<td class="map-why">${m.hopCount} hops map(1...10 8000...800 Hz) = ${m.filterHz} Hz lowpass more hops = more muffled</td>
</tr>
<tr>
<td class="map-param">Volume</td>
<td class="map-value">${m.volume}</td>
<td class="map-why">min(0.6, 0.15 + (${m.obsCount} obs 1) × 0.02) = ${m.volume} more observers = louder</td>
</tr>
<tr>
<td class="map-param">Voices</td>
<td class="map-value">${m.voiceCount}</td>
<td class="map-why">min(log₂(${m.obsCount} + 1), 8) = ${m.voiceCount} more observers = richer chord</td>
</tr>
<tr>
<td class="map-param">Detune</td>
<td class="map-value">${m.detuneDesc.join(', ')}</td>
<td class="map-why">${m.voiceCount} voices detuned for shimmer wider spread with more voices</td>
</tr>
<tr>
<td class="map-param">Pan</td>
<td class="map-value">${m.panValue}</td>
<td class="map-why">${m.panSource}</td>
</tr>
</table>
</div>
<div class="alab-section">
<h3>🎹 Note Sequence</h3>
<table class="alab-note-table">
<tr><th></th><th>#</th><th>Payload Index</th><th>Byte</th><th> MIDI</th><th> Freq</th><th>Duration (why)</th><th>Gap (why)</th></tr>
${m.notes.map((n, i) => {
const durWhy = `byte ${n.byte} → map(0...255 → 50...400ms) × tempo`;
const gapWhy = i < m.notes.length - 1
? `|${n.byte} ${m.notes[i+1].byte}| = ${Math.abs(m.notes[i+1].byte - n.byte)} → map(0...255 → 30...300ms) × tempo`
: '';
return `<tr id="noteRow${i}" class="alab-note-clickable" data-note-idx="${i}">
<td><button class="alab-note-play" data-note-idx="${i}" title="Play this note"></button></td>
<td>${i + 1}</td>
<td>[${n.index}]</td>
<td>0x${n.byte.toString(16).padStart(2, '0').toUpperCase()} (${n.byte})</td>
<td>${n.midi}</td>
<td>${n.freq} Hz</td>
<td>${n.duration} ms <span class="map-why-inline">${durWhy}</span></td>
<td>${i < m.notes.length - 1 ? n.gap + ' ms <span class="map-why-inline">' + gapWhy + '</span>' : '—'}</td>
</tr>`;}).join('')}
</table>
</div>
<div class="alab-section">
<h3>📊 Byte Visualizer</h3>
<div class="alab-byte-viz" id="alabByteViz"></div>
</div>
`;
// Render byte visualizer
const viz = document.getElementById('alabByteViz');
if (viz) {
for (let i = 0; i < m.payloadBytes.length; i++) {
const bar = document.createElement('div');
bar.className = 'alab-byte-bar';
bar.id = 'byteBar' + i;
const h = Math.max(2, (m.payloadBytes[i] / 255) * 60);
bar.style.height = h + 'px';
bar.style.background = sampledSet.has(i) ? m.color : '#555';
bar.style.opacity = sampledSet.has(i) ? '1' : '0.3';
bar.title = `[${i}] 0x${m.payloadBytes[i].toString(16).padStart(2, '0')} = ${m.payloadBytes[i]}`;
viz.appendChild(bar);
}
}
// Wire up individual note play buttons
document.querySelectorAll('.alab-note-play').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
playOneNote(parseInt(btn.dataset.noteIdx));
});
});
// Also allow clicking anywhere on the row
document.querySelectorAll('.alab-note-clickable').forEach(row => {
row.addEventListener('click', () => playOneNote(parseInt(row.dataset.noteIdx)));
});
}
function clearHighlights() {
highlightTimers.forEach(t => clearTimeout(t));
highlightTimers = [];
document.querySelectorAll('.alab-hex .playing, .alab-note-table .playing, .alab-byte-bar.playing').forEach(el => el.classList.remove('playing'));
}
function highlightPlayback(mapping) {
clearHighlights();
let timeOffset = 0;
mapping.notes.forEach((note, i) => {
// Highlight ON
highlightTimers.push(setTimeout(() => {
// Clear previous note highlights
document.querySelectorAll('.alab-hex .playing, .alab-note-table .playing, .alab-byte-bar.playing').forEach(el => el.classList.remove('playing'));
// Hex byte
const hexEl = document.getElementById('hexByte' + note.index);
if (hexEl) { hexEl.classList.add('playing'); hexEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
// Note row
const rowEl = document.getElementById('noteRow' + i);
if (rowEl) { rowEl.classList.add('playing'); rowEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
// Byte bar
const barEl = document.getElementById('byteBar' + note.index);
if (barEl) barEl.classList.add('playing');
}, timeOffset));
timeOffset += note.duration + (i < mapping.notes.length - 1 ? note.gap : 0);
});
// Clear all at end
highlightTimers.push(setTimeout(clearHighlights, timeOffset + 200));
}
function playOneNote(noteIdx) {
if (!selectedPacket) return;
const m = computeMapping(selectedPacket);
if (!m || !m.notes[noteIdx]) return;
if (window.MeshAudio && !MeshAudio.isEnabled()) MeshAudio.setEnabled(true);
const audioCtx = MeshAudio.getContext();
if (!audioCtx) return;
if (audioCtx.state === 'suspended') audioCtx.resume();
const note = m.notes[noteIdx];
const oscType = SYNTH_TYPES[m.typeName] || 'triangle';
const ADSR = { ADVERT: { a: 0.02, d: 0.3, s: 0.4, r: 0.5 }, GRP_TXT: { a: 0.005, d: 0.15, s: 0.1, r: 0.2 },
TXT_MSG: { a: 0.01, d: 0.2, s: 0.3, r: 0.4 }, TRACE: { a: 0.05, d: 0.4, s: 0.5, r: 0.8 } };
const env = ADSR[m.typeName] || ADSR.ADVERT;
const vol = parseFloat(m.volume) || 0.3;
const dur = note.duration / 1000;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = m.filterHz;
osc.type = oscType;
osc.frequency.value = note.freq;
const now = audioCtx.currentTime + 0.02;
const sustainVol = Math.max(vol * env.s, 0.0001);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(Math.max(vol, 0.0001), now + env.a);
gain.gain.exponentialRampToValueAtTime(sustainVol, now + env.a + env.d);
gain.gain.setTargetAtTime(0.0001, now + dur, env.r / 5);
osc.connect(gain);
gain.connect(filter);
filter.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + dur + env.r + 0.1);
osc.onended = () => { osc.disconnect(); gain.disconnect(); filter.disconnect(); };
// Highlight this note
clearHighlights();
const hexEl = document.getElementById('hexByte' + note.index);
const rowEl = document.getElementById('noteRow' + noteIdx);
const barEl = document.getElementById('byteBar' + note.index);
if (hexEl) hexEl.classList.add('playing');
if (rowEl) rowEl.classList.add('playing');
if (barEl) barEl.classList.add('playing');
highlightTimers.push(setTimeout(clearHighlights, note.duration + 200));
}
function playSelected() {
if (!selectedPacket) return;
if (window.MeshAudio) {
if (!MeshAudio.isEnabled()) MeshAudio.setEnabled(true);
// Build a packet object that sonifyPacket expects
const pkt = {
raw_hex: selectedPacket.raw_hex,
raw: selectedPacket.raw_hex,
observation_count: selectedPacket.observation_count || 1,
decoded: {}
};
try {
const d = JSON.parse(selectedPacket.decoded_json || '{}');
const typeName = d.type || 'UNKNOWN';
pkt.decoded = {
header: { payloadTypeName: typeName },
payload: d,
path: { hops: JSON.parse(selectedPacket.path_json || '[]') }
};
} catch {}
MeshAudio.sonifyPacket(pkt);
// Sync highlights with audio
const m = computeMapping(selectedPacket);
if (m) highlightPlayback(m);
}
}
async function init(app) {
injectStyles();
baseBPM = (MeshAudio && MeshAudio.getBPM) ? MeshAudio.getBPM() : 120;
speedMult = 1;
app.innerHTML = `
<div class="alab">
<div class="alab-sidebar" id="alabSidebar"><div style="color:var(--text-muted);font-size:13px;padding:8px">Loading packets...</div></div>
<div class="alab-main">
<div class="alab-controls" id="alabControls">
<button class="alab-btn" id="alabPlay" title="Play selected packet"> Play</button>
<button class="alab-btn" id="alabLoop" title="Loop playback">🔁 Loop</button>
<span style="font-size:12px;color:var(--text-muted)">Speed:</span>
<button class="alab-speed" data-speed="0.25">0.25x</button>
<button class="alab-speed active" data-speed="1">1x</button>
<button class="alab-speed" data-speed="2">2x</button>
<button class="alab-speed" data-speed="4">4x</button>
<div class="alab-slider-group">
<span>BPM</span>
<input type="range" id="alabBPM" min="30" max="300" value="${baseBPM}">
<span id="alabBPMVal">${baseBPM}</span>
</div>
<div class="alab-slider-group">
<span>Vol</span>
<input type="range" id="alabVol" min="0" max="100" value="${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}">
<span id="alabVolVal">${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}%</span>
</div>
<div class="alab-slider-group">
<span>Voice</span>
<select id="alabVoice">${(MeshAudio && MeshAudio.getVoiceNames ? MeshAudio.getVoiceNames() : ['constellation']).map(v =>
`<option value="${v}" ${(MeshAudio && MeshAudio.getVoiceName && MeshAudio.getVoiceName() === v) ? 'selected' : ''}>${v}</option>`
).join('')}</select>
</div>
</div>
<div id="alabDetail"><div class="alab-empty"> Select a packet from the sidebar to explore its sound</div></div>
</div>
</div>
`;
// Controls
document.getElementById('alabPlay').addEventListener('click', playSelected);
document.getElementById('alabLoop').addEventListener('click', function () {
if (loopTimer) { clearInterval(loopTimer); loopTimer = null; this.classList.remove('active'); return; }
this.classList.add('active');
playSelected();
loopTimer = setInterval(playSelected, 3000);
});
document.querySelectorAll('.alab-speed').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.alab-speed').forEach(b => b.classList.remove('active'));
this.classList.add('active');
speedMult = parseFloat(this.dataset.speed);
if (MeshAudio && MeshAudio.setBPM) MeshAudio.setBPM(baseBPM * speedMult);
if (selectedPacket) renderDetail(selectedPacket, app);
});
});
document.getElementById('alabBPM').addEventListener('input', function () {
baseBPM = parseInt(this.value);
document.getElementById('alabBPMVal').textContent = baseBPM;
if (MeshAudio && MeshAudio.setBPM) MeshAudio.setBPM(baseBPM * speedMult);
if (selectedPacket) renderDetail(selectedPacket, app);
});
document.getElementById('alabVol').addEventListener('input', function () {
const v = parseInt(this.value) / 100;
document.getElementById('alabVolVal').textContent = Math.round(v * 100) + '%';
if (MeshAudio && MeshAudio.setVolume) MeshAudio.setVolume(v);
});
document.getElementById('alabVoice').addEventListener('change', function () {
if (MeshAudio && MeshAudio.setVoice) MeshAudio.setVoice(this.value);
});
// Load buckets
try {
const data = await api('/audio-lab/buckets');
const sidebar = document.getElementById('alabSidebar');
if (!data.buckets || Object.keys(data.buckets).length === 0) {
sidebar.innerHTML = '<div style="color:var(--text-muted);font-size:13px;padding:8px">No packets in memory yet</div>';
return;
}
let html = '';
for (const [type, pkts] of Object.entries(data.buckets)) {
const color = TYPE_COLORS[type] || TYPE_COLORS.UNKNOWN;
html += `<div class="alab-type-hdr" style="background:${color}22;color:${color}" data-type="${type}">
<span>${type}</span><span style="font-size:11px;opacity:0.7">${pkts.length}</span></div>`;
html += `<div class="alab-type-list" data-type-list="${type}">`;
pkts.forEach((p, i) => {
const size = p.raw_hex ? p.raw_hex.length / 2 : 0;
html += `<div class="alab-pkt" data-type="${type}" data-idx="${i}">#${i + 1}${size}B — ${p.observation_count || 1} obs</div>`;
});
html += '</div>';
}
sidebar.innerHTML = html;
// Store buckets for selection
sidebar._buckets = data.buckets;
// Click handlers
sidebar.addEventListener('click', function (e) {
const typeHdr = e.target.closest('.alab-type-hdr');
if (typeHdr) {
const list = sidebar.querySelector(`[data-type-list="${typeHdr.dataset.type}"]`);
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
return;
}
const pktEl = e.target.closest('.alab-pkt');
if (pktEl) {
sidebar.querySelectorAll('.alab-pkt').forEach(el => el.classList.remove('selected'));
pktEl.classList.add('selected');
const type = pktEl.dataset.type;
const idx = parseInt(pktEl.dataset.idx);
selectedPacket = sidebar._buckets[type][idx];
renderDetail(selectedPacket, app);
}
});
} catch (err) {
document.getElementById('alabSidebar').innerHTML = `<div style="color:var(--text-muted);padding:8px">Error loading packets: ${err.message}</div>`;
}
}
function destroy() {
clearHighlights();
if (loopTimer) { clearInterval(loopTimer); loopTimer = null; }
if (styleEl) { styleEl.remove(); styleEl = null; }
selectedPacket = null;
}
registerPage('audio-lab', { init, destroy });
})();
-139
View File
@@ -1,139 +0,0 @@
// Voice v1: "Constellation" — melodic packet sonification
// Original voice: type-based instruments, scale-quantized melody from payload bytes,
// byte-driven note duration and spacing, hop-based filter, observation chord voicing.
(function () {
'use strict';
const { buildScale, midiToFreq, mapRange, quantizeToScale } = MeshAudio.helpers;
// Scales per payload type
const SCALES = {
ADVERT: buildScale([0, 2, 4, 7, 9], 48), // C major pentatonic
GRP_TXT: buildScale([0, 3, 5, 7, 10], 45), // A minor pentatonic
TXT_MSG: buildScale([0, 2, 3, 5, 7, 8, 10], 40),// E natural minor
TRACE: buildScale([0, 2, 4, 6, 8, 10], 50), // D whole tone
};
const DEFAULT_SCALE = SCALES.ADVERT;
// Synth ADSR envelopes per type
const SYNTHS = {
ADVERT: { type: 'triangle', attack: 0.02, decay: 0.3, sustain: 0.4, release: 0.5 },
GRP_TXT: { type: 'sine', attack: 0.005, decay: 0.15, sustain: 0.1, release: 0.2 },
TXT_MSG: { type: 'triangle', attack: 0.01, decay: 0.2, sustain: 0.3, release: 0.4 },
TRACE: { type: 'sine', attack: 0.05, decay: 0.4, sustain: 0.5, release: 0.8 },
};
const DEFAULT_SYNTH = SYNTHS.ADVERT;
function play(audioCtx, masterGain, parsed, opts) {
const { payloadBytes, typeName, hopCount, obsCount, payload, hops } = parsed;
const tm = opts.tempoMultiplier;
const scale = SCALES[typeName] || DEFAULT_SCALE;
const synthConfig = SYNTHS[typeName] || DEFAULT_SYNTH;
// Sample sqrt(len) bytes evenly
const noteCount = Math.max(2, Math.min(10, Math.ceil(Math.sqrt(payloadBytes.length))));
const sampledBytes = [];
for (let i = 0; i < noteCount; i++) {
const idx = Math.floor((i / noteCount) * payloadBytes.length);
sampledBytes.push(payloadBytes[idx]);
}
// Pan from longitude
let panValue = 0;
if (payload.lat !== undefined && payload.lon !== undefined) {
panValue = Math.max(-1, Math.min(1, mapRange(payload.lon, -125, -65, -1, 1)));
} else if (hops.length > 0) {
panValue = (Math.random() - 0.5) * 0.6;
}
// Filter from hops
const filterFreq = mapRange(Math.min(hopCount, 10), 1, 10, 8000, 800);
// Volume from observations
const volume = Math.min(0.6, 0.15 + (obsCount - 1) * 0.02);
// More observers = richer chord: 1→1, 3→2, 8→3, 15→4, 30→5, 60→6
const voiceCount = Math.min(Math.max(1, Math.ceil(Math.log2(obsCount + 1))), 8);
// Audio chain: filter → limiter → panner → master
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = filterFreq;
filter.Q.value = 1;
const limiter = audioCtx.createDynamicsCompressor();
limiter.threshold.value = -6;
limiter.knee.value = 6;
limiter.ratio.value = 12;
limiter.attack.value = 0.001;
limiter.release.value = 0.05;
const panner = audioCtx.createStereoPanner();
panner.pan.value = panValue;
filter.connect(limiter);
limiter.connect(panner);
panner.connect(masterGain);
let timeOffset = audioCtx.currentTime + 0.02; // small lookahead avoids scheduling on "now"
let lastNoteEnd = timeOffset;
for (let i = 0; i < sampledBytes.length; i++) {
const byte = sampledBytes[i];
const freq = midiToFreq(quantizeToScale(byte, scale));
const duration = mapRange(byte, 0, 255, 0.05, 0.4) * tm;
let gap = 0.05 * tm;
if (i < sampledBytes.length - 1) {
const delta = Math.abs(sampledBytes[i + 1] - byte);
gap = mapRange(delta, 0, 255, 0.03, 0.3) * tm;
}
const noteStart = timeOffset;
const noteEnd = noteStart + duration;
const { attack: a, decay: d, sustain: s, release: r } = synthConfig;
for (let v = 0; v < voiceCount; v++) {
const detune = v === 0 ? 0 : (v % 2 === 0 ? 1 : -1) * (v * 5 + 3);
const osc = audioCtx.createOscillator();
const envGain = audioCtx.createGain();
osc.type = synthConfig.type;
osc.frequency.value = freq;
osc.detune.value = detune;
const voiceVol = volume / voiceCount;
const sustainVol = Math.max(voiceVol * s, 0.0001);
// Envelope: start silent, ramp up, decay to sustain, hold, release to silence
// Use exponentialRamp throughout to avoid discontinuities
envGain.gain.setValueAtTime(0.0001, noteStart);
envGain.gain.exponentialRampToValueAtTime(Math.max(voiceVol, 0.0001), noteStart + a);
envGain.gain.exponentialRampToValueAtTime(sustainVol, noteStart + a + d);
// Hold sustain — cancelAndHoldAtTime not universal, so just let it ride
// Release: ramp down from wherever we are
envGain.gain.setTargetAtTime(0.0001, noteEnd, r / 5); // smooth exponential decay
osc.connect(envGain);
envGain.connect(filter);
osc.start(noteStart);
osc.stop(noteEnd + r + 0.1);
osc.onended = () => { osc.disconnect(); envGain.disconnect(); };
}
timeOffset = noteEnd + gap;
lastNoteEnd = noteEnd + (synthConfig.release || 0.2);
}
// Cleanup shared nodes
const cleanupMs = (lastNoteEnd - audioCtx.currentTime + 1) * 1000;
setTimeout(() => {
try { filter.disconnect(); limiter.disconnect(); panner.disconnect(); } catch (e) {}
}, cleanupMs);
return lastNoteEnd - audioCtx.currentTime;
}
MeshAudio.registerVoice('constellation', { name: 'constellation', play });
})();
-214
View File
@@ -1,214 +0,0 @@
// Mesh Audio Engine — public/audio.js
// Core audio infrastructure + swappable voice modules
// Each voice module is a separate file (audio-v1.js, audio-v2.js, etc.)
(function () {
'use strict';
// === Engine State ===
let audioEnabled = false;
let audioCtx = null;
let masterGain = null;
let bpm = 120;
let activeVoices = 0;
const MAX_VOICES = 12;
let currentVoice = null;
let _pendingVolume = 0.3; // active voice module
// === Shared Helpers (available to voice modules) ===
function buildScale(intervals, rootMidi) {
const notes = [];
for (let oct = 0; oct < 3; oct++) {
for (const interval of intervals) {
notes.push(rootMidi + oct * 12 + interval);
}
}
return notes;
}
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function mapRange(value, inMin, inMax, outMin, outMax) {
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}
function quantizeToScale(byteVal, scale) {
const idx = Math.floor((byteVal / 256) * scale.length);
return scale[Math.min(idx, scale.length - 1)];
}
function tempoMultiplier() {
return 120 / bpm;
}
function parsePacketBytes(pkt) {
const rawHex = pkt.raw || pkt.raw_hex || (pkt.packet && pkt.packet.raw_hex) || '';
if (!rawHex || rawHex.length < 6) return null;
const allBytes = [];
for (let i = 0; i < rawHex.length; i += 2) {
const b = parseInt(rawHex.slice(i, i + 2), 16);
if (!isNaN(b)) allBytes.push(b);
}
if (allBytes.length < 3) return null;
const decoded = pkt.decoded || {};
const header = decoded.header || {};
const payload = decoded.payload || {};
const hops = decoded.path?.hops || [];
return {
allBytes,
headerBytes: allBytes.slice(0, 3),
payloadBytes: allBytes.slice(3),
typeName: header.payloadTypeName || 'UNKNOWN',
hopCount: Math.max(1, hops.length),
obsCount: pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1,
payload,
hops,
};
}
// === Engine: Init ===
function initAudio() {
if (audioCtx) {
if (audioCtx.state === 'suspended') audioCtx.resume();
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = _pendingVolume;
masterGain.connect(audioCtx.destination);
}
// === Engine: Sonify ===
function sonifyPacket(pkt) {
if (!audioEnabled || !currentVoice) return;
if (!audioCtx) initAudio();
if (!audioCtx) return;
if (audioCtx.state === 'suspended') {
// Show unlock overlay if not already showing
_showUnlockOverlay();
return; // don't schedule notes on suspended context
}
if (activeVoices >= MAX_VOICES) return;
const parsed = parsePacketBytes(pkt);
if (!parsed || parsed.payloadBytes.length === 0) return;
activeVoices++;
try {
const duration = currentVoice.play(audioCtx, masterGain, parsed, {
bpm, tempoMultiplier: tempoMultiplier(),
});
// Release voice slot after estimated duration
const releaseMs = (duration || 3) * 1000 + 500;
setTimeout(() => { activeVoices = Math.max(0, activeVoices - 1); }, releaseMs);
} catch (e) {
activeVoices = Math.max(0, activeVoices - 1);
console.error('[audio] voice error:', e);
}
}
// === Voice Registration ===
function registerVoice(name, voiceModule) {
// voiceModule must have: { name, play(audioCtx, masterGain, parsed, opts) → durationSec }
if (!window._meshAudioVoices) window._meshAudioVoices = {};
window._meshAudioVoices[name] = voiceModule;
// Auto-select first registered voice if none active
if (!currentVoice) currentVoice = voiceModule;
}
function setVoice(name) {
if (window._meshAudioVoices && window._meshAudioVoices[name]) {
currentVoice = window._meshAudioVoices[name];
localStorage.setItem('live-audio-voice', name);
return true;
}
return false;
}
function getVoiceName() {
return currentVoice ? currentVoice.name : null;
}
function getVoiceNames() {
return Object.keys(window._meshAudioVoices || {});
}
// === Public API ===
function setEnabled(on) {
audioEnabled = on;
if (on) initAudio();
localStorage.setItem('live-audio-enabled', on);
}
function isEnabled() { return audioEnabled; }
function setBPM(val) {
bpm = Math.max(40, Math.min(300, val));
localStorage.setItem('live-audio-bpm', bpm);
}
function getBPM() { return bpm; }
function setVolume(val) {
if (masterGain) masterGain.gain.value = Math.max(0, Math.min(1, val));
localStorage.setItem('live-audio-volume', val);
}
function getVolume() { return masterGain ? masterGain.gain.value : 0.3; }
function restore() {
const saved = localStorage.getItem('live-audio-enabled');
if (saved === 'true') audioEnabled = true;
const savedBpm = localStorage.getItem('live-audio-bpm');
if (savedBpm) bpm = parseInt(savedBpm, 10) || 120;
const savedVol = localStorage.getItem('live-audio-volume');
if (savedVol) _pendingVolume = parseFloat(savedVol) || 0.3;
const savedVoice = localStorage.getItem('live-audio-voice');
if (savedVoice) setVoice(savedVoice);
// If audio was enabled, create context eagerly. If browser suspends it,
// the unlock overlay will appear when the first packet arrives.
if (audioEnabled) {
initAudio();
}
}
let _overlayShown = false;
function _showUnlockOverlay() {
if (_overlayShown) return;
_overlayShown = true;
const overlay = document.createElement('div');
overlay.className = 'audio-unlock-overlay';
overlay.innerHTML = '<div class="audio-unlock-prompt">🔊 Tap to enable audio</div>';
overlay.addEventListener('click', () => {
if (audioCtx) audioCtx.resume();
overlay.remove();
}, { once: true });
document.body.appendChild(overlay);
}
// Export engine + helpers for voice modules
window.MeshAudio = {
sonifyPacket,
setEnabled, isEnabled,
setBPM, getBPM,
setVolume, getVolume,
registerVoice, setVoice, getVoiceName, getVoiceNames,
restore,
getContext() { return audioCtx; },
// Helpers for voice modules
helpers: { buildScale, midiToFreq, mapRange, quantizeToScale },
};
})();
+30 -166
View File
@@ -18,7 +18,7 @@
if (cached && !cached.fetchedAt) return cached; // legacy null entries
}
try {
const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: CLIENT_TTL.channelMessages });
const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: 10000 });
// Try exact match first, then case-insensitive, then contains
const nodes = data.nodes || [];
const match = nodes.find(n => n.name === name)
@@ -40,8 +40,7 @@
tip.id = 'chNodeTooltip';
tip.className = 'ch-node-tooltip';
tip.setAttribute('role', 'tooltip');
const roleKey = node.role || (node.is_repeater ? 'repeater' : node.is_room ? 'room' : node.is_sensor ? 'sensor' : 'companion');
const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
const 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>
<div class="ch-tooltip-role">${role}</div>
@@ -111,11 +110,10 @@
}
try {
const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: CLIENT_TTL.nodeDetail });
const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: 15000 });
const n = detail.node;
const adverts = detail.recentAdverts || [];
const roleKey = n.role || (n.is_repeater ? 'repeater' : n.is_room ? 'room' : n.is_sensor ? 'sensor' : 'companion');
const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
const role = n.is_repeater ? '📡 Repeater' : n.is_room ? '🏠 Room' : n.is_sensor ? '🌡 Sensor' : '📻 Companion';
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : 'unknown';
panel.innerHTML = `<div class="ch-node-panel-header">
@@ -205,14 +203,6 @@
return str.length > len ? str.slice(0, len) + '…' : str;
}
function formatSecondsAgo(sec) {
if (sec < 0) sec = 0;
if (sec < 60) return sec + 's ago';
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
return Math.floor(sec / 86400) + 'd ago';
}
function highlightMentions(text) {
if (!text) return '';
return escapeHtml(text).replace(/@\[([^\]]+)\]/g, function(_, name) {
@@ -221,15 +211,12 @@
});
}
let regionChangeHandler = null;
function init(app, routeParam) {
function init(app) {
app.innerHTML = `<div class="ch-layout">
<div class="ch-sidebar" aria-label="Channel list">
<div class="ch-sidebar-header">
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
</div>
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
<div class="ch-loading">Loading channels</div>
</div>
@@ -248,12 +235,7 @@
</div>
</div>`;
RegionFilter.init(document.getElementById('chRegionFilter'));
regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); });
loadChannels().then(() => {
if (routeParam) selectChannel(routeParam);
});
loadChannels();
// #89: Sidebar resize handle
(function () {
@@ -381,140 +363,21 @@
});
wsHandler = debouncedOnWS(function (msgs) {
var dominated = msgs.filter(function (m) {
var dominated = msgs.some(function (m) {
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
});
if (!dominated.length) return;
var channelListDirty = false;
var messagesDirty = false;
var seenHashes = new Set();
for (var i = 0; i < dominated.length; i++) {
var m = dominated[i];
var payload = m.data?.decoded?.payload;
if (!payload) continue;
var channelName = payload.channel || 'unknown';
var rawText = payload.text || '';
var sender = payload.sender || null;
var displayText = rawText;
// Parse "sender: message" format
if (rawText && !sender) {
var colonIdx = rawText.indexOf(': ');
if (colonIdx > 0 && colonIdx < 50) {
sender = rawText.slice(0, colonIdx);
displayText = rawText.slice(colonIdx + 2);
}
} else if (rawText && sender) {
var colonIdx2 = rawText.indexOf(': ');
if (colonIdx2 > 0 && colonIdx2 < 50) {
displayText = rawText.slice(colonIdx2 + 2);
}
}
if (!sender) sender = 'Unknown';
var ts = new Date().toISOString();
var pktHash = m.data?.hash || m.data?.packet?.hash || null;
var pktId = m.data?.id || null;
var snr = m.data?.snr ?? m.data?.packet?.snr ?? payload.SNR ?? null;
var observer = m.data?.packet?.observer_name || m.data?.observer || null;
// Update channel list entry — only once per unique packet hash
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelName);
if (pktHash) seenHashes.add(pktHash + ':' + channelName);
var ch = channels.find(function (c) { return c.hash === channelName; });
if (ch) {
if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1;
ch.lastActivityMs = Date.now();
ch.lastSender = sender;
ch.lastMessage = truncate(displayText, 100);
channelListDirty = true;
} else if (isFirstObservation) {
// New channel we haven't seen
channels.push({
hash: channelName,
name: channelName,
messageCount: 1,
lastActivityMs: Date.now(),
lastSender: sender,
lastMessage: truncate(displayText, 100),
});
channelListDirty = true;
}
// If this message is for the selected channel, append to messages
if (selectedHash && channelName === selectedHash) {
// Deduplicate by packet hash — same message seen by multiple observers
var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null;
if (existing) {
existing.repeats = (existing.repeats || 1) + 1;
if (observer && existing.observers && existing.observers.indexOf(observer) === -1) {
existing.observers.push(observer);
}
} else {
messages.push({
sender: sender,
text: displayText,
timestamp: ts,
sender_timestamp: payload.sender_timestamp || null,
packetId: pktId,
packetHash: pktHash,
repeats: 1,
observers: observer ? [observer] : [],
hops: payload.path_len || 0,
snr: snr,
});
}
messagesDirty = true;
}
}
if (channelListDirty) {
channels.sort(function (a, b) { return (b.lastActivityMs || 0) - (a.lastActivityMs || 0); });
renderChannelList();
}
if (messagesDirty) {
renderMessages();
// Update header count
var ch2 = channels.find(function (c) { return c.hash === selectedHash; });
var header = document.getElementById('chHeader');
if (header && ch2) {
header.querySelector('.ch-header-text').textContent = (ch2.name || 'Channel ' + selectedHash) + ' — ' + messages.length + ' messages';
}
var msgEl = document.getElementById('chMessages');
if (msgEl && autoScroll) scrollToBottom();
else {
document.getElementById('chScrollBtn')?.classList.remove('hidden');
var liveEl = document.getElementById('chAriaLive');
if (liveEl) liveEl.textContent = 'New message received';
if (dominated) {
loadChannels(true);
if (selectedHash) {
refreshMessages();
}
}
});
// Tick relative timestamps every 1s — iterates channels array, updates DOM text only
timeAgoTimer = setInterval(function () {
var now = Date.now();
for (var i = 0; i < channels.length; i++) {
var ch = channels[i];
if (!ch.lastActivityMs) continue;
var el = document.querySelector('.ch-item-time[data-channel-hash="' + ch.hash + '"]');
if (el) el.textContent = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
}
}, 1000);
}
var timeAgoTimer = null;
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (timeAgoTimer) clearInterval(timeAgoTimer);
timeAgoTimer = null;
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
regionChangeHandler = null;
channels = [];
messages = [];
selectedHash = null;
@@ -526,13 +389,8 @@
async function loadChannels(silent) {
try {
const rp = RegionFilter.getRegionParam();
const qs = rp ? '?region=' + encodeURIComponent(rp) : '';
const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels });
channels = (data.channels || []).map(ch => {
ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0;
return ch;
}).sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
const data = await api('/channels', { ttl: 15000 });
channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || ''));
renderChannelList();
} catch (e) {
if (!silent) {
@@ -547,27 +405,30 @@
if (!el) return;
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
// Sort by message count desc
// Sort: decrypted first (by message count desc), then encrypted (by message count desc)
const sorted = [...channels].sort((a, b) => {
if (a.encrypted !== b.encrypted) return a.encrypted ? 1 : -1;
return (b.messageCount || 0) - (a.messageCount || 0);
});
el.innerHTML = sorted.map(ch => {
const name = ch.name || `Channel ${ch.hash}`;
const color = getChannelColor(ch.hash);
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
const time = ch.lastActivity ? timeAgo(ch.lastActivity) : '';
const preview = ch.lastSender && ch.lastMessage
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
: `${ch.messageCount} messages`;
: ch.encrypted ? `🔒 ${ch.messageCount} encrypted` : `${ch.messageCount} messages`;
const sel = selectedHash === ch.hash ? ' selected' : '';
const lockIcon = ch.encrypted ? ' 🔒' : '';
const encClass = ch.encrypted ? ' ch-item-encrypted' : '';
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
return `<button class="ch-item${sel}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
return `<button class="ch-item${sel}${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">
<span class="ch-item-name">${escapeHtml(name)}</span>
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>
<span class="ch-item-name">${escapeHtml(name)}${lockIcon}</span>
<span class="ch-item-time">${time}</span>
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
</div>
@@ -577,7 +438,6 @@
async function selectChannel(hash) {
selectedHash = hash;
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
renderChannelList();
const ch = channels.find(c => c.hash === hash);
const name = ch?.name || `Channel ${hash}`;
@@ -591,7 +451,7 @@
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
try {
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: 10000 });
messages = data.messages || [];
renderMessages();
scrollToBottom();
@@ -606,7 +466,7 @@
if (!msgEl) return;
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
try {
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: 10000 });
const newMsgs = data.messages || [];
// #92: Use message ID/hash for change detection instead of count + timestamp
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
@@ -634,7 +494,11 @@
const senderLetter = sender.replace(/[^\w]/g, '').charAt(0).toUpperCase() || '?';
let displayText;
displayText = highlightMentions(msg.text || '');
if (msg.encrypted) {
displayText = '<span class="mono ch-encrypted-text">🔒 encrypted</span>';
} else {
displayText = highlightMentions(msg.text || '');
}
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
const date = msg.timestamp ? new Date(msg.timestamp).toLocaleDateString() : '';
@@ -652,7 +516,7 @@
<div class="ch-msg-content">
<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.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</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>
</div>`;
}).join('');
-1338
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<circle cx="16" cy="8" r="3" fill="#00d4ff"/>
<circle cx="7" cy="22" r="3" fill="#00d4ff"/>
<circle cx="25" cy="22" r="3" fill="#00d4ff"/>
<circle cx="16" cy="18" r="2" fill="#00ff88"/>
<line x1="16" y1="8" x2="7" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
<line x1="16" y1="8" x2="25" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
<line x1="7" y1="22" x2="25" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
<line x1="16" y1="8" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
<line x1="7" y1="22" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
<line x1="25" y1="22" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 851 B

+6
View File
@@ -25,6 +25,12 @@
.chooser-btn span:last-child { font-size: .8rem; color: var(--text-muted); }
.home-level-toggle { margin-top: 16px; }
:root {
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
}
/* Hero */
.home-hero {
text-align: center;
+19 -28
View File
@@ -39,7 +39,7 @@
function showChooser(container) {
container.innerHTML = `
<section class="home-chooser">
<h1>Welcome to ${escapeHtml(window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer')}</h1>
<h1>Welcome to Bay Area MeshCore Analyzer</h1>
<p>How familiar are you with MeshCore?</p>
<div class="chooser-options">
<button class="chooser-btn new" id="chooseNew">
@@ -62,13 +62,11 @@
const exp = isExperienced();
const myNodes = getMyNodes();
const hasNodes = myNodes.length > 0;
const homeCfg = window.SITE_CONFIG?.home || null;
const siteName = window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer';
container.innerHTML = `
<section class="home-hero">
<h1>${hasNodes ? 'My Mesh' : escapeHtml(homeCfg?.heroTitle || siteName)}</h1>
<p>${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : escapeHtml(homeCfg?.heroSubtitle || 'Find your nodes to start monitoring them.')}</p>
<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" role="combobox" aria-expanded="false" aria-owns="homeSuggest" aria-autocomplete="list" aria-activedescendant="">
<div class="home-suggest" id="homeSuggest" role="listbox"></div>
@@ -94,18 +92,17 @@
${exp ? '' : `
<section class="home-checklist">
<h2>🚀 Getting on the mesh${homeCfg?.steps ? '' : ' — SF Bay Area'}</h2>
${checklist(homeCfg)}
<h2>🚀 Getting on the mesh SF Bay Area</h2>
${checklist()}
</section>`}
<section class="home-footer">
<div class="home-footer-links">
${homeCfg?.footerLinks ? homeCfg.footerLinks.map(l => `<a href="${escapeAttr(l.url)}" class="home-footer-link" target="_blank" rel="noopener">${escapeHtml(l.label)}</a>`).join('') : `
<a href="#/packets" class="home-footer-link">📦 Packets</a>
<a href="#/map" class="home-footer-link">🗺 Network Map</a>
<a href="#/live" class="home-footer-link">🔴 Live</a>
<a href="#/nodes" class="home-footer-link">📡 All Nodes</a>
<a href="#/channels" class="home-footer-link">💬 Channels</a>`}
<a href="#/channels" class="home-footer-link">💬 Channels</a>
</div>
<div class="home-level-toggle">
<small>${exp ? 'Want setup guides? ' : 'Already know MeshCore? '}
@@ -149,7 +146,7 @@
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), { ttl: CLIENT_TTL.nodeSearch });
const data = await api('/nodes/search?q=' + encodeURIComponent(q), { ttl: 10000 });
const nodes = data.nodes || [];
if (!nodes.length) {
suggest.innerHTML = '<div class="suggest-empty">No nodes found</div>';
@@ -250,13 +247,13 @@
const cards = await Promise.all(myNodes.map(async (mn) => {
try {
const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth });
const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: 30000 });
const node = h.node || {};
const stats = h.stats || {};
const obs = h.observers || [];
const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null;
const status = age === null ? 'silent' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent';
const status = age === null ? 'silent' : age < 3600000 ? 'healthy' : age < 86400000 ? 'degraded' : 'silent';
const statusDot = status === 'healthy' ? '🟢' : status === 'degraded' ? '🟡' : '🔴';
const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent';
const name = node.name || mn.name || truncate(mn.pubkey, 12);
@@ -264,7 +261,7 @@
// SNR quality label
const snrVal = stats.avgSnr;
const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : null;
const snrColor = snrVal != null ? (snrVal > 10 ? 'var(--status-green)' : snrVal > 0 ? 'var(--accent)' : snrVal > -5 ? 'var(--status-yellow)' : 'var(--status-red)') : '#6b7280';
const snrColor = snrVal != null ? (snrVal > 10 ? '#22c55e' : snrVal > 0 ? '#3b82f6' : snrVal > -5 ? '#f59e0b' : '#ef4444') : '#6b7280';
// Build sparkline from recent packets (packet timestamps → hourly buckets)
const sparkHtml = buildSparkline(h.recentPackets || []);
@@ -287,7 +284,7 @@
<div class="mnc-lbl">Observers</div>
</div>
<div class="mnc-metric">
<div class="mnc-val" style="color:${snrColor}">${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}</div>
<div class="mnc-val" style="color:${snrColor}">${snrVal != null ? snrVal.toFixed(1) + ' dB' : '—'}</div>
<div class="mnc-lbl">SNR${snrLabel ? ' · ' + snrLabel : ''}</div>
</div>
<div class="mnc-metric">
@@ -372,11 +369,11 @@
// ==================== STATS ====================
async function loadStats() {
try {
const s = await api('/stats', { ttl: CLIENT_TTL.nodeSearch });
const s = await api('/stats', { ttl: 5000 });
const el = document.getElementById('homeStats');
if (!el) return;
el.innerHTML = `
<div class="home-stat"><div class="val">${s.totalTransmissions ?? s.totalPackets ?? '—'}</div><div class="lbl">Transmissions</div></div>
<div class="home-stat"><div class="val">${s.totalPackets ?? '—'}</div><div class="lbl">Packets</div></div>
<div class="home-stat"><div class="val">${s.totalNodes ?? '—'}</div><div class="lbl">Nodes</div></div>
<div class="home-stat"><div class="val">${s.totalObservers ?? '—'}</div><div class="lbl">Observers</div></div>
<div class="home-stat"><div class="val">${s.packetsLast24h ?? '—'}</div><div class="lbl">Last 24h</div></div>
@@ -394,7 +391,7 @@
if (journey) journey.classList.remove('visible');
try {
const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth });
const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30000 });
const node = h.node || {};
const stats = h.stats || {};
const packets = h.recentPackets || [];
@@ -406,8 +403,8 @@
if (stats.lastHeard) {
const ageMs = Date.now() - new Date(stats.lastHeard).getTime();
const ago = timeAgo(stats.lastHeard);
if (ageMs < HEALTH_THRESHOLDS.nodeDegradedMs) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; }
else if (ageMs < HEALTH_THRESHOLDS.nodeSilentMs) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; }
if (ageMs < 3600000) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; }
else if (ageMs < 86400000) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; }
else { statusMsg = `Last heard ${ago}`; }
}
@@ -425,7 +422,7 @@
<div class="health-metric"><div class="val">${stats.packetsToday ?? '—'}</div><div class="lbl">Packets Today</div></div>
<div class="health-metric"><div class="val">${observers.length}</div><div class="lbl">Observers</div></div>
<div class="health-metric"><div class="val">${stats.lastHeard ? timeAgo(stats.lastHeard) : '—'}</div><div class="lbl">Last seen</div></div>
<div class="health-metric"><div class="val">${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}</div><div class="lbl">Avg SNR${snrLabel ? ' · ' + snrLabel : ''}</div></div>
<div class="health-metric"><div class="val">${snrVal != null ? snrVal.toFixed(1) + ' dB' : '—'}</div><div class="lbl">Avg SNR${snrLabel ? ' · ' + snrLabel : ''}</div></div>
<div class="health-metric"><div class="val">${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}</div><div class="lbl">Avg Hops</div></div>
</div>
${observers.length ? `<div class="health-observers"><strong>Heard by:</strong> ${observers.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}</div>` : ''}
@@ -444,7 +441,7 @@
<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 ? Number(p.snr).toFixed(1) + ' dB' : ''}</span>
<span class="snr">${p.snr != null ? p.snr.toFixed(1) + ' dB' : ''}</span>
</div>`;
}).join('') : '<p style="color:var(--text-muted);font-size:.85rem">No recent packets found for this node.</p>'}
</div>
@@ -510,13 +507,7 @@
function escapeAttr(s) { return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function timeSinceMs(d) { return Date.now() - d.getTime(); }
function checklist(homeCfg) {
if (homeCfg?.checklist) {
return homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
}
if (homeCfg?.steps) {
return homeCfg.steps.map(s => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(s.emoji || '')} ${escapeHtml(s.title)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}</div></div>`).join('');
}
function checklist() {
const items = [
{ q: '💬 First: Join the Bay Area MeshCore Discord',
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
-121
View File
@@ -1,121 +0,0 @@
/* === MeshCore Analyzer — hop-display.js === */
/* Shared hop rendering with conflict info for all pages */
'use strict';
window.HopDisplay = (function() {
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Dismiss any open conflict popover
function dismissPopover() {
const old = document.querySelector('.hop-conflict-popover');
if (old) old.remove();
}
// Global click handler to dismiss popovers
let _listenerAttached = false;
function ensureGlobalListener() {
if (_listenerAttached) return;
_listenerAttached = true;
document.addEventListener('click', (e) => {
if (!e.target.closest('.hop-conflict-popover') && !e.target.closest('.hop-conflict-btn')) {
dismissPopover();
}
});
}
function showConflictPopover(btn, h, conflicts, globalFallback) {
dismissPopover();
ensureGlobalListener();
const regional = conflicts.filter(c => c.regional);
const shown = regional.length > 0 ? regional : conflicts;
let html = `<div class="hop-conflict-header">${escapeHtml(h)}${shown.length} candidate${shown.length > 1 ? 's' : ''}${regional.length > 0 ? ' in region' : ' (global fallback)'}</div>`;
html += '<div class="hop-conflict-list">';
for (const c of shown) {
const name = escapeHtml(c.name || c.pubkey?.slice(0, 16) || '?');
const dist = c.distKm != null ? `<span class="hop-conflict-dist">${c.distKm}km</span>` : '';
const pk = c.pubkey ? c.pubkey.slice(0, 12) + '…' : '';
html += `<a href="#/nodes/${encodeURIComponent(c.pubkey || '')}" class="hop-conflict-item">
<span class="hop-conflict-name">${name}</span>
${dist}
<span class="hop-conflict-pk">${pk}</span>
</a>`;
}
html += '</div>';
const popover = document.createElement('div');
popover.className = 'hop-conflict-popover';
popover.innerHTML = html;
document.body.appendChild(popover);
// Position near the button
const rect = btn.getBoundingClientRect();
popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
popover.style.left = Math.max(8, Math.min(rect.left + window.scrollX - 60, window.innerWidth - 280)) + 'px';
}
/**
* Render a hop prefix as HTML with conflict info.
*/
function renderHop(h, entry, opts) {
opts = opts || {};
if (!entry) entry = {};
if (typeof entry === 'string') entry = { name: entry };
const name = entry.name || null;
const pubkey = entry.pubkey || h;
const ambiguous = entry.ambiguous || false;
const conflicts = entry.conflicts || [];
const globalFallback = entry.globalFallback || false;
const unreliable = entry.unreliable || false;
const display = opts.hexMode ? h : (name ? escapeHtml(opts.truncate ? name.slice(0, opts.truncate) : name) : h);
// Simple title for the hop link itself
let title = h;
if (unreliable) title += ' — unreliable';
// Badge — only count regional conflicts
const regionalConflicts = conflicts.filter(c => c.regional);
const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0);
const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback }));
const warnBadge = badgeCount > 1
? ` <button class="hop-conflict-btn" data-conflict='${conflictData}' onclick="event.preventDefault();event.stopPropagation();HopDisplay._showFromBtn(this)" title="${badgeCount} candidates — click for details">⚠${badgeCount}</button>`
: '';
const cls = [
'hop',
name ? 'hop-named' : '',
ambiguous ? 'hop-ambiguous' : '',
unreliable ? 'hop-unreliable' : '',
globalFallback ? 'hop-global-fallback' : '',
].filter(Boolean).join(' ');
if (opts.link !== false) {
return `<a class="${cls} hop-link" href="#/nodes/${encodeURIComponent(pubkey)}" title="${escapeHtml(title)}" data-hop-link="true">${display}</a>${warnBadge}`;
}
return `<span class="${cls}" title="${escapeHtml(title)}">${display}</span>${warnBadge}`;
}
/**
* Render a full path as HTML.
*/
function renderPath(hops, cache, opts) {
opts = opts || {};
const sep = opts.separator || ' → ';
if (!hops || !hops.length) return '—';
return hops.filter(Boolean).map(h => renderHop(h, cache[h], opts)).join(sep);
}
// Called from inline onclick
function _showFromBtn(btn) {
try {
const data = JSON.parse(btn.dataset.conflict);
showConflictPopover(btn, data.h, data.conflicts, data.globalFallback);
} catch (e) { console.error('Conflict popover error:', e); }
}
return { renderHop, renderPath, _showFromBtn };
})();
-207
View File
@@ -1,207 +0,0 @@
/**
* Client-side hop resolver eliminates /api/resolve-hops HTTP requests.
* Mirrors the server's disambiguateHops() logic from server.js.
*/
window.HopResolver = (function() {
'use strict';
const MAX_HOP_DIST = 1.8; // ~200km in degrees
const REGION_RADIUS_KM = 300;
let prefixIdx = {}; // lowercase hex prefix → [node, ...]
let nodesList = [];
let observerIataMap = {}; // observer_id → iata
let iataCoords = {}; // iata → {lat, lon}
function dist(lat1, lon1, lat2, lon2) {
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
}
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Initialize (or rebuild) the prefix index from the full nodes list.
* @param {Array} nodes - Array of {public_key, name, lat, lon, ...}
* @param {Object} [opts] - Optional: { observers: [{id, iata}], iataCoords: {code: {lat,lon}} }
*/
function init(nodes, opts) {
nodesList = nodes || [];
prefixIdx = {};
for (const n of nodesList) {
if (!n.public_key) continue;
const pk = n.public_key.toLowerCase();
for (let len = 1; len <= 3; len++) {
const p = pk.slice(0, len * 2);
if (!prefixIdx[p]) prefixIdx[p] = [];
prefixIdx[p].push(n);
}
}
// Store observer IATA mapping and coords if provided
observerIataMap = {};
if (opts && opts.observers) {
for (const o of opts.observers) {
if (o.id && o.iata) observerIataMap[o.id] = o.iata;
}
}
iataCoords = (opts && opts.iataCoords) || (window.IATA_COORDS_GEO) || {};
}
/**
* Check if a node is near an IATA region center.
* Returns { near, method, distKm } or null.
*/
function nodeInRegion(candidate, iata) {
const center = iataCoords[iata];
if (!center) return null;
if (candidate.lat && candidate.lon && !(candidate.lat === 0 && candidate.lon === 0)) {
const d = haversineKm(candidate.lat, candidate.lon, center.lat, center.lon);
return { near: d <= REGION_RADIUS_KM, method: 'geo', distKm: Math.round(d) };
}
return null; // no GPS — can't geo-filter client-side
}
/**
* Resolve an array of hex hop prefixes to node info.
* Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} }
*
* @param {string[]} hops - Hex prefixes
* @param {number|null} originLat - Sender latitude (forward anchor)
* @param {number|null} originLon - Sender longitude (forward anchor)
* @param {number|null} observerLat - Observer latitude (backward anchor)
* @param {number|null} observerLon - Observer longitude (backward anchor)
* @returns {Object} resolved map keyed by hop prefix
*/
function resolve(hops, originLat, originLon, observerLat, observerLon, observerId) {
if (!hops || !hops.length) return {};
// Determine observer's IATA for regional filtering
const packetIata = observerId ? observerIataMap[observerId] : null;
const resolved = {};
const hopPositions = {};
// First pass: find candidates with regional filtering
for (const hop of hops) {
const h = hop.toLowerCase();
const allCandidates = prefixIdx[h] || [];
if (allCandidates.length === 0) {
resolved[hop] = { name: null, candidates: [], conflicts: [] };
} else if (allCandidates.length === 1) {
const c = allCandidates[0];
const regionCheck = packetIata ? nodeInRegion(c, packetIata) : null;
resolved[hop] = { name: c.name, pubkey: c.public_key,
candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon, regional: regionCheck ? regionCheck.near : false, filterMethod: regionCheck ? regionCheck.method : 'none', distKm: regionCheck ? regionCheck.distKm : undefined }],
conflicts: [] };
} else {
// Multiple candidates — apply geo regional filtering
const checked = allCandidates.map(c => {
const r = packetIata ? nodeInRegion(c, packetIata) : null;
return { ...c, regional: r ? r.near : false, filterMethod: r ? r.method : 'none', distKm: r ? r.distKm : undefined };
});
const regional = checked.filter(c => c.regional);
regional.sort((a, b) => (a.distKm || 9999) - (b.distKm || 9999));
const candidates = regional.length > 0 ? regional : checked;
const globalFallback = regional.length === 0 && checked.length > 0 && packetIata != null;
const conflicts = candidates.map(c => ({
name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
regional: c.regional, filterMethod: c.filterMethod, distKm: c.distKm
}));
if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
candidates: conflicts, conflicts, globalFallback };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
ambiguous: true, candidates: conflicts, conflicts, globalFallback,
hopBytes: Math.ceil(hop.length / 2), totalGlobal: allCandidates.length, totalRegional: regional.length };
}
}
}
// Build initial positions for unambiguous hops
for (const hop of hops) {
const r = resolved[hop];
if (r && !r.ambiguous && r.pubkey) {
const node = nodesList.find(n => n.public_key === r.pubkey);
if (node && node.lat && node.lon && !(node.lat === 0 && node.lon === 0)) {
hopPositions[hop] = { lat: node.lat, lon: node.lon };
}
}
}
// Forward pass
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
if (hopPositions[hop]) { lastPos = hopPositions[hop]; continue; }
const r = resolved[hop];
if (!r || !r.ambiguous) continue;
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length) continue;
let anchor = lastPos;
if (!anchor && i === hops.length - 1 && observerLat != null) {
anchor = { lat: observerLat, lon: observerLon };
}
if (anchor) {
withLoc.sort((a, b) => dist(a.lat, a.lon, anchor.lat, anchor.lon) - dist(b.lat, b.lon, anchor.lat, anchor.lon));
}
r.name = withLoc[0].name;
r.pubkey = withLoc[0].pubkey;
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
lastPos = hopPositions[hop];
}
// Backward pass
let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null;
for (let i = hops.length - 1; i >= 0; i--) {
const hop = hops[i];
if (hopPositions[hop]) { nextPos = hopPositions[hop]; continue; }
const r = resolved[hop];
if (!r || !r.ambiguous) continue;
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length || !nextPos) continue;
withLoc.sort((a, b) => dist(a.lat, a.lon, nextPos.lat, nextPos.lon) - dist(b.lat, b.lon, nextPos.lat, nextPos.lon));
r.name = withLoc[0].name;
r.pubkey = withLoc[0].pubkey;
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
nextPos = hopPositions[hop];
}
// Sanity check: drop hops impossibly far from neighbors
for (let i = 0; i < hops.length; i++) {
const pos = hopPositions[hops[i]];
if (!pos) continue;
const prev = i > 0 ? hopPositions[hops[i - 1]] : null;
const next = i < hops.length - 1 ? hopPositions[hops[i + 1]] : null;
if (!prev && !next) continue;
const dPrev = prev ? dist(pos.lat, pos.lon, prev.lat, prev.lon) : 0;
const dNext = next ? dist(pos.lat, pos.lon, next.lat, next.lon) : 0;
const tooFarPrev = prev && dPrev > MAX_HOP_DIST;
const tooFarNext = next && dNext > MAX_HOP_DIST;
if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) {
const r = resolved[hops[i]];
if (r) r.unreliable = true;
delete hopPositions[hops[i]];
}
}
return resolved;
}
/**
* Check if the resolver has been initialized with nodes.
*/
function ready() {
return nodesList.length > 0;
}
return { init: init, resolve: resolve, ready: ready };
})();
+15 -31
View File
@@ -2,8 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>MeshCore Analyzer</title>
@@ -22,9 +20,9 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774477855">
<link rel="stylesheet" href="home.css?v=1774477855">
<link rel="stylesheet" href="live.css?v=1774477855">
<link rel="stylesheet" href="style.css?v=1773970465">
<link rel="stylesheet" href="home.css">
<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">
@@ -53,8 +51,6 @@
<a href="#/traces" class="nav-link" data-route="traces">Traces</a>
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
<a href="#/audio-lab" class="nav-link" data-route="audio-lab">🎵 Lab</a>
</div>
</div>
<div class="nav-right">
@@ -64,7 +60,6 @@
<div class="nav-fav-dropdown" id="favDropdown"></div>
</div>
<button class="nav-btn" id="searchToggle" title="Search (Ctrl+K)">🔍</button>
<button class="nav-btn" id="customizeToggle" title="Customize theme & branding">🎨</button>
<button class="nav-btn" id="darkModeToggle" title="Toggle dark mode">☀️</button>
<button class="nav-btn hamburger" id="hamburger" title="Menu" aria-label="Toggle navigation menu"></button>
</div>
@@ -81,28 +76,17 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774477855"></script>
<script src="customize.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774477855"></script>
<script src="hop-resolver.js?v=1774477855"></script>
<script src="hop-display.js?v=1774477855"></script>
<script src="app.js?v=1774477855"></script>
<script src="home.js?v=1774477855"></script>
<script src="packet-filter.js?v=1774477855"></script>
<script src="packets.js?v=1774477855"></script>
<script src="map.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="app.js?v=1773972187"></script>
<script src="home.js?v=1773972187"></script>
<script src="packets.js?v=1773972187"></script>
<script src="map.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1773972187" 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=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+17 -37
View File
@@ -50,7 +50,7 @@
.live-beacon {
width: 8px;
height: 8px;
background: var(--status-red);
background: #ef4444;
border-radius: 50%;
display: inline-block;
animation: beaconPulse 1.5s ease-in-out infinite;
@@ -80,11 +80,11 @@
.live-stat-pill span {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent);
color: #60a5fa;
}
.live-stat-pill.anim-pill span { color: var(--status-yellow); }
.live-stat-pill.rate-pill span { color: var(--status-green); }
.live-stat-pill.anim-pill span { color: #f59e0b; }
.live-stat-pill.rate-pill span { color: #22c55e; }
.live-sound-btn {
background: color-mix(in srgb, var(--text) 8%, transparent);
@@ -100,26 +100,6 @@
background: color-mix(in srgb, var(--text) 14%, transparent);
}
/* ---- Node Detail Panel ---- */
.live-node-detail {
top: 60px;
right: 12px;
width: 320px;
max-height: calc(100vh - 140px);
overflow-y: auto;
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
backdrop-filter: blur(12px);
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.live-node-detail.hidden {
transform: translateX(340px);
opacity: 0;
pointer-events: none;
}
/* ---- Feed ---- */
.live-feed {
bottom: 12px;
@@ -375,7 +355,7 @@
padding: 6px;
border-radius: 6px;
background: rgba(59,130,246,0.15);
color: var(--accent);
color: #60a5fa;
text-decoration: none;
font-size: .75rem;
font-weight: 600;
@@ -486,7 +466,7 @@
.vcr-live-btn {
background: rgba(239, 68, 68, 0.2);
color: var(--status-red);
color: #f87171;
font-weight: 700;
font-size: 0.7rem;
letter-spacing: 0.05em;
@@ -501,15 +481,15 @@
border-radius: 4px;
margin-left: auto;
}
.vcr-mode-live { color: var(--status-green); }
.vcr-mode-paused { color: var(--status-yellow); background: rgba(251,191,36,0.1); }
.vcr-mode-replay { color: var(--accent); background: rgba(96,165,250,0.1); }
.vcr-mode-live { color: #22c55e; }
.vcr-mode-paused { color: #fbbf24; background: rgba(251,191,36,0.1); }
.vcr-mode-replay { color: #60a5fa; background: rgba(96,165,250,0.1); }
.vcr-live-dot {
display: inline-block;
width: 6px;
height: 6px;
background: var(--status-green);
background: #22c55e;
border-radius: 50%;
margin-right: 4px;
animation: vcr-pulse 1.5s ease-in-out infinite;
@@ -541,7 +521,7 @@
}
.vcr-lcd-mode {
font-size: 0.65rem;
color: var(--status-green);
color: #4ade80;
text-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
font-weight: 700;
}
@@ -551,7 +531,7 @@
}
.vcr-lcd-pkts {
font-size: 0.6rem;
color: var(--status-yellow);
color: #fbbf24;
text-shadow: 0 0 4px rgba(251, 191, 36, 0.5);
font-weight: 700;
min-height: 0.7rem;
@@ -559,7 +539,7 @@
.vcr-missed {
font-size: 0.7rem;
font-weight: 700;
color: var(--status-yellow);
color: #fbbf24;
background: rgba(251,191,36,0.15);
padding: 2px 6px;
border-radius: 4px;
@@ -587,7 +567,7 @@
}
.vcr-scope-btn.active {
background: rgba(59,130,246,0.2);
color: var(--accent);
color: #60a5fa;
border-color: rgba(59,130,246,0.3);
}
@@ -613,7 +593,7 @@
top: 0;
width: 2px;
height: 100%;
background: var(--status-red);
background: #f87171;
border-radius: 1px;
pointer-events: none;
box-shadow: 0 0 4px rgba(248,113,113,0.5);
@@ -631,7 +611,7 @@
.vcr-prompt-btn {
background: rgba(59,130,246,0.15);
border: 1px solid rgba(59,130,246,0.25);
color: var(--accent);
color: #60a5fa;
font-size: 0.75rem;
font-weight: 600;
padding: 4px 12px;
@@ -642,7 +622,7 @@
.vcr-prompt-btn:hover { background: rgba(59,130,246,0.3); }
/* Adjust feed position to not overlap VCR bar */
.live-feed { bottom: 68px; }
.live-feed { bottom: 58px; }
.feed-show-btn { bottom: 68px !important; }
/* Mobile VCR */
+196 -1102
View File
File diff suppressed because it is too large Load Diff
+60 -420
View File
@@ -7,9 +7,8 @@
let markerLayer = null;
let clusterGroup = null;
let nodes = [];
let targetNodeKey = null;
let observers = [];
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all' };
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;
@@ -18,9 +17,18 @@
// Safe escape — falls back to identity if app.js hasn't loaded yet
const safeEsc = (typeof esc === 'function') ? esc : function (s) { return s; };
// Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals)
// Distinct shapes + high-contrast WCAG AA colors for each role
const ROLE_STYLE = {
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 }, // red diamond
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 }, // blue circle
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 }, // green square
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 }, // amber triangle
};
function makeMarkerIcon(role, isStale) {
const ROLE_LABELS = { repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers', sensor: 'Sensors' };
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;
@@ -35,50 +43,20 @@
case 'triangle':
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
break;
case 'star': {
// 5-pointed star
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
let pts = '';
for (let i = 0; i < 5; i++) {
const aOuter = (i * 72 - 90) * Math.PI / 180;
const aInner = ((i * 72) + 36 - 90) * Math.PI / 180;
pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `;
pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `;
}
path = `<polygon points="${pts.trim()}" fill="${s.color}" stroke="#fff" stroke-width="1.5"/>`;
break;
}
default: // circle
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
}
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
return L.divIcon({
html: svg,
className: 'meshcore-marker' + (isStale ? ' marker-stale' : ''),
className: 'meshcore-marker',
iconSize: [size, size],
iconAnchor: [c, c],
popupAnchor: [0, -c],
});
}
function makeRepeaterLabelIcon(node, isStale) {
var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion;
var hs = node.hash_size || 1;
// Show the short mesh hash ID (first N bytes of pubkey, uppercased)
var shortHash = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '??';
var bgColor = s.color;
var html = '<div style="background:' + bgColor + ';color:#fff;font-weight:bold;font-size:11px;padding:2px 5px;border-radius:3px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.4);text-align:center;line-height:1.2;white-space:nowrap;">' +
shortHash + '</div>';
return L.divIcon({
html: html,
className: 'meshcore-marker meshcore-label-marker' + (isStale ? ' marker-stale' : ''),
iconSize: null,
iconAnchor: [14, 12],
popupAnchor: [0, -12],
});
}
async function init(container) {
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>
@@ -93,18 +71,10 @@
<legend class="mc-label">Display</legend>
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Status</legend>
<div class="filter-group" id="mcStatusFilter">
<button class="btn ${filters.statusFilter==='all'?'active':''}" data-status="all">All</button>
<button class="btn ${filters.statusFilter==='active'?'active':''}" data-status="active">Active</button>
<button class="btn ${filters.statusFilter==='stale'?'active':''}" data-status="stale">Stale</button>
</div>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Filters</legend>
<label for="mcMqtt"><input type="checkbox" id="mcMqtt"> MQTT Connected Only</label>
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
</fieldset>
<fieldset class="mc-section">
@@ -125,44 +95,20 @@
</div>
</div>`;
// Init Leaflet — restore saved position or use configurable defaults (#115)
let defaultCenter = [37.6, -122.1];
let defaultZoom = 9;
try {
const mapCfg = await (await fetch('/api/config/map')).json();
if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) defaultCenter = mapCfg.center;
if (typeof mapCfg.zoom === 'number') defaultZoom = mapCfg.zoom;
} catch {}
// Init Leaflet — restore saved position or default to Bay Area
const defaultCenter = [37.6, -122.1];
const defaultZoom = 9;
let initCenter = defaultCenter;
let initZoom = defaultZoom;
// Check URL query params first (from packet detail links)
const urlParams = new URLSearchParams(location.hash.split('?')[1] || '');
if (urlParams.get('lat') && urlParams.get('lon')) {
initCenter = [parseFloat(urlParams.get('lat')), parseFloat(urlParams.get('lon'))];
initZoom = parseInt(urlParams.get('zoom')) || 12;
} else {
const savedView = localStorage.getItem('map-view');
if (savedView) {
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
}
const savedView = localStorage.getItem('map-view');
if (savedView) {
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
}
map = L.map('leaflet-map', { zoomControl: true }).setView(initCenter, initZoom);
// If navigated with ?node=PUBKEY, highlight that node after markers load
targetNodeKey = urlParams.get('node') || null;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
attribution: '© OpenStreetMap © CartoDB',
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 19,
}).addTo(map);
const _mapThemeObs = new MutationObserver(function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
});
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// Save position on move
map.on('moveend', () => {
@@ -171,14 +117,6 @@
userHasMoved = true;
});
map.on('zoomend', () => {
if (!_renderingMarkers) renderMarkers();
});
map.on('resize', () => {
if (!_renderingMarkers) renderMarkers();
});
markerLayer = L.layerGroup().addTo(map);
routeLayer = L.layerGroup().addTo(map);
@@ -202,29 +140,11 @@
// Bind controls
document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); });
const heatEl = document.getElementById('mcHeatmap');
if (localStorage.getItem('meshcore-map-heatmap') === 'true') { heatEl.checked = true; }
heatEl.addEventListener('change', e => { localStorage.setItem('meshcore-map-heatmap', e.target.checked); toggleHeatmap(e.target.checked); });
document.getElementById('mcHeatmap').addEventListener('change', e => { toggleHeatmap(e.target.checked); });
document.getElementById('mcMqtt').addEventListener('change', e => { filters.mqttOnly = e.target.checked; renderMarkers(); });
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
// Hash Labels toggle
const hashLabelEl = document.getElementById('mcHashLabels');
if (hashLabelEl) {
hashLabelEl.checked = filters.hashLabels;
hashLabelEl.addEventListener('change', e => { filters.hashLabels = e.target.checked; localStorage.setItem('meshcore-map-hash-labels', filters.hashLabels); renderMarkers(); });
}
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
// Status filter buttons
document.querySelectorAll('#mcStatusFilter .btn').forEach(btn => {
btn.addEventListener('click', () => {
filters.statusFilter = btn.dataset.status;
localStorage.setItem('meshcore-map-status-filter', filters.statusFilter);
document.querySelectorAll('#mcStatusFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.status === filters.statusFilter));
renderMarkers();
});
});
// WS for live advert updates
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {
@@ -238,42 +158,14 @@
if (routeHopsJson) {
sessionStorage.removeItem('map-route-hops');
try {
const parsed = JSON.parse(routeHopsJson);
// Support new format {origin, hops} and legacy plain array
if (Array.isArray(parsed)) {
drawPacketRoute(parsed, null);
} else {
drawPacketRoute(parsed.hops || [], parsed.origin || null);
}
const hopKeys = JSON.parse(routeHopsJson);
drawPacketRoute(hopKeys);
} catch {}
}
});
}
function drawPacketRoute(hopKeys, origin) {
// Hide default markers so only the route is visible
if (markerLayer) map.removeLayer(markerLayer);
if (clusterGroup) map.removeLayer(clusterGroup);
if (heatLayer) map.removeLayer(heatLayer);
routeLayer.clearLayers();
// Add close route button
const closeBtn = L.control({ position: 'topright' });
closeBtn.onAdd = function () {
const div = L.DomUtil.create('div', 'leaflet-bar');
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--input-bg,#1e293b);color:var(--text,#e2e8f0);border-radius:4px">✕</a>';
L.DomEvent.on(div, 'click', function (e) {
L.DomEvent.preventDefault(e);
routeLayer.clearLayers();
if (markerLayer) map.addLayer(markerLayer);
if (clusterGroup) map.addLayer(clusterGroup);
map.removeControl(closeBtn);
});
return div;
};
closeBtn.addTo(map);
function drawPacketRoute(hopKeys) {
// Resolve hop short hashes to node positions with geographic disambiguation
const raw = hopKeys.map(hop => {
const hopLower = hop.toLowerCase();
@@ -310,52 +202,29 @@
}
const positions = raw.filter(h => h && h.resolved);
// Resolve and prepend origin node
if (origin) {
let originPos = null;
if (origin.lat != null && origin.lon != null) {
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, isOrigin: true };
} else if (origin.pubkey) {
const pk = origin.pubkey.toLowerCase();
const match = nodes.find(n => n.public_key.toLowerCase() === pk || n.public_key.toLowerCase().startsWith(pk));
if (match && match.lat != null && match.lon != null) {
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role, isOrigin: true };
}
}
if (originPos) positions.unshift(originPos);
}
if (positions.length < 1) return;
// Even a single node is worth showing (zoom to it)
const coords = positions.map(p => [p.lat, p.lon]);
if (positions.length >= 2) {
// Draw route polyline
L.polyline(coords, {
color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4'
}).addTo(routeLayer);
}
// Add numbered markers at each hop
var labelItems = [];
positions.forEach((p, i) => {
const isOrigin = i === 0 && p.isOrigin;
const isLast = i === positions.length - 1 && positions.length > 1;
const color = isOrigin ? '#06b6d4' : isLast ? (getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444') : i === 0 ? (getComputedStyle(document.documentElement).getPropertyValue('--status-green').trim() || '#22c55e') : '#f59e0b';
const radius = isOrigin ? 14 : 10;
const label = isOrigin ? 'Sender' : isLast ? 'Last Hop' : `Hop ${isOrigin ? i : i}`;
if (isOrigin) {
L.circleMarker([p.lat, p.lon], {
radius: radius + 4, fillColor: 'transparent', fillOpacity: 0, color: '#06b6d4', weight: 2, opacity: 0.6
}).addTo(routeLayer);
}
const color = i === 0 ? '#22c55e' : i === positions.length - 1 ? '#ef4444' : '#f59e0b';
const label = i === 0 ? 'Origin' : i === positions.length - 1 ? 'Destination' : `Hop ${i}`;
const marker = L.circleMarker([p.lat, p.lon], {
radius: radius, fillColor: color,
radius: 10, fillColor: color,
fillOpacity: 0.9, color: '#fff', weight: 2
}).addTo(routeLayer);
marker.bindTooltip(`${i + 1}. ${p.name}`, { permanent: true, direction: 'top', className: 'route-tooltip' });
const popupHtml = `<div style="font-size:12px;min-width:160px">
<div style="font-weight:700;margin-bottom:4px">${label}: ${safeEsc(p.name)}</div>
<div style="color:#9ca3af;font-size:11px;margin-bottom:4px">${p.role || 'unknown'}</div>
@@ -364,19 +233,6 @@
${p.pubkey ? `<div style="margin-top:6px"><a href="#/nodes/${p.pubkey}" style="color:var(--accent);font-size:11px">View Node →</a></div>` : ''}
</div>`;
marker.bindPopup(popupHtml, { className: 'route-popup' });
labelItems.push({ latLng: L.latLng(p.lat, p.lon), isLabel: true, text: `${i + 1}. ${p.name}` });
});
// Deconflict labels so overlapping hop names spread out
deconflictLabels(labelItems, map);
labelItems.forEach(function (m) {
var pos = m.adjustedLatLng || m.latLng;
var icon = L.divIcon({ className: 'route-tooltip', html: m.text, iconSize: [null, null], iconAnchor: [0, 0] });
L.marker(pos, { icon: icon, interactive: false }).addTo(routeLayer);
if (m.offset > 2) {
L.polyline([m.latLng, pos], { weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3' }).addTo(routeLayer);
}
});
// Fit map to route
@@ -389,46 +245,16 @@
async function loadNodes() {
try {
// Load regions from config + observed IATAs
try { REGION_NAMES = await api('/config/regions', { ttl: 3600 }); } catch {}
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList });
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: 10000 });
nodes = data.nodes || [];
// Load observers for jump buttons + map markers
const obsData = await api('/observers', { ttl: CLIENT_TTL.observers });
observers = obsData.observers || [];
buildRoleChecks(data.counts || {});
// Load observers for jump buttons
const obsData = await api('/observers', { ttl: 30000 });
observers = obsData.observers || [];
buildJumpButtons();
renderMarkers();
// Restore heatmap if previously enabled
if (localStorage.getItem('meshcore-map-heatmap') === 'true') {
toggleHeatmap(true);
}
// If navigated with ?node=PUBKEY, center on and highlight that node
if (targetNodeKey) {
const targetNode = nodes.find(n => n.public_key === targetNodeKey);
if (targetNode && targetNode.lat && targetNode.lon) {
map.setView([targetNode.lat, targetNode.lon], 14);
// Delay popup open slightly — Leaflet needs the map to settle after setView
setTimeout(() => {
let found = false;
markerLayer.eachLayer(m => {
if (found) return;
if (m._nodeKey === targetNodeKey && m.openPopup) {
m.openPopup();
found = true;
}
});
if (!found) console.warn('[map] Target node marker not found:', targetNodeKey);
}, 500);
}
}
// 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) {
@@ -440,40 +266,14 @@
const el = document.getElementById('mcRoleChecks');
if (!el) return;
el.innerHTML = '';
const obsCount = observers.filter(o => o.lat && o.lon).length;
const roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★' };
// Count active/stale per role from loaded nodes
const roleCounts = {};
for (const role of roles) {
roleCounts[role] = { active: 0, stale: 0 };
}
for (const n of nodes) {
const role = (n.role || 'companion').toLowerCase();
if (!roleCounts[role]) roleCounts[role] = { active: 0, stale: 0 };
const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0;
const status = getNodeStatus(role, lastMs);
roleCounts[role][status]++;
}
for (const role of roles) {
for (const role of ['repeater', 'companion', 'room', 'sensor']) {
const count = counts[role + 's'] || 0;
const cbId = 'mcRole_' + role;
const lbl = document.createElement('label');
lbl.setAttribute('for', cbId);
const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲' };
const shape = shapeMap[role] || '●';
let countStr;
if (role === 'observer') {
countStr = `(${obsCount})`;
} else {
const rc = roleCounts[role] || { active: 0, stale: 0 };
const isInfra = role === 'repeater' || role === 'room';
const thresh = isInfra ? '72h' : '24h';
const activeTip = 'Active \u2014 heard within the last ' + thresh;
const staleTip = 'Stale \u2014 not heard for over ' + thresh;
countStr = `(<span title="${activeTip}">${rc.active} active</span>, <span title="${staleTip}">${rc.stale} stale</span>)`;
}
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)">${countStr}</span>`;
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();
@@ -482,7 +282,7 @@
}
}
let REGION_NAMES = {};
const REGION_NAMES = { SJC: 'San Jose', SFO: 'San Francisco', OAK: 'Oakland', MTV: 'Mountain View', SCZ: 'Santa Cruz', MRY: 'Monterey', PAO: 'Palo Alto' };
function buildJumpButtons() {
const el = document.getElementById('mcJumps');
@@ -528,171 +328,38 @@
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
}
var _renderingMarkers = false;
var _lastDeconflictZoom = null;
function deconflictLabels(markers, mapRef) {
const placed = [];
const PAD = 4;
var overlaps = function(b) {
for (var k = 0; k < placed.length; k++) {
var p = placed[k];
if (b.x < p.x + p.w + PAD && b.x + b.w + PAD > p.x &&
b.y < p.y + p.h + PAD && b.y + b.h + PAD > p.y) return true;
}
return false;
};
// Spiral offsets — 6 rings, 8 directions, up to ~132px
var offsets = [];
for (var ring = 1; ring <= 6; ring++) {
var dist = ring * 22;
for (var angle = 0; angle < 360; angle += 45) {
var rad = angle * Math.PI / 180;
offsets.push([Math.round(Math.cos(rad) * dist), Math.round(Math.sin(rad) * dist)]);
}
}
for (var i = 0; i < markers.length; i++) {
var m = markers[i];
var w = m.isLabel ? 38 : 20;
var h = m.isLabel ? 24 : 20;
var pt = mapRef.latLngToLayerPoint(m.latLng);
var bestPt = pt;
var box = { x: pt.x - w / 2, y: pt.y - h / 2, w: w, h: h };
if (overlaps(box)) {
for (var j = 0; j < offsets.length; j++) {
var tryPt = L.point(pt.x + offsets[j][0], pt.y + offsets[j][1]);
var tryBox = { x: tryPt.x - w / 2, y: tryPt.y - h / 2, w: w, h: h };
if (!overlaps(tryBox)) {
bestPt = tryPt;
box = tryBox;
break;
}
}
}
placed.push(box);
m.adjustedLatLng = mapRef.layerPointToLatLng(bestPt);
m.offset = Math.sqrt(Math.pow(bestPt.x - pt.x, 2) + Math.pow(bestPt.y - pt.y, 2));
}
}
function renderMarkers() {
if (_renderingMarkers) return;
_renderingMarkers = true;
try { _renderMarkersInner(); } finally { _renderingMarkers = false; }
}
function _renderMarkersInner() {
markerLayer.clearLayers();
const filtered = nodes.filter(n => {
if (!n.lat || !n.lon) return false;
if (!filters[n.role || 'companion']) return false;
// Status filter
if (filters.statusFilter !== 'all') {
const role = (n.role || 'companion').toLowerCase();
const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0;
const status = getNodeStatus(role, lastMs);
if (status !== filters.statusFilter) return false;
}
return true;
});
const allMarkers = [];
for (const node of filtered) {
const lastSeenTime = node.last_heard || node.last_seen;
const isStale = getNodeStatus(node.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0) === 'stale';
const useLabel = node.role === 'repeater' && filters.hashLabels;
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale) : makeMarkerIcon(node.role || 'companion', isStale);
const latLng = L.latLng(node.lat, node.lon);
allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + ')' });
}
const icon = makeMarkerIcon(node.role || 'companion');
const marker = L.marker([node.lat, node.lon], {
icon,
alt: `${node.name || 'Unknown'} (${node.role || 'node'})`,
});
// Add observer markers
if (filters.observer) {
for (const obs of observers) {
if (!obs.lat || !obs.lon) continue;
const icon = makeMarkerIcon('observer');
const latLng = L.latLng(obs.lat, obs.lon);
allMarkers.push({ latLng, node: obs, icon, isLabel: false, popupFn: function() { return buildObserverPopup(obs); }, alt: (obs.name || obs.id || 'Unknown') + ' (observer)' });
}
}
// Ensure map has correct pixel dimensions before deconfliction
// (SPA navigation may render markers before container is fully sized)
map.invalidateSize({ animate: false });
// Deconflict ALL markers
if (allMarkers.length > 0) {
deconflictLabels(allMarkers, map);
}
for (const m of allMarkers) {
const pos = m.adjustedLatLng || m.latLng;
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
marker._nodeKey = m.node.public_key || m.node.id || null;
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
marker.bindPopup(buildPopup(node), { maxWidth: 280 });
markerLayer.addLayer(marker);
if (m.offset > 10) {
const line = L.polyline([m.latLng, pos], {
color: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', weight: 2, dashArray: '6,4', opacity: 0.85
});
markerLayer.addLayer(line);
// Small dot at true GPS position
const dot = L.circleMarker(m.latLng, {
radius: 3, fillColor: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
});
markerLayer.addLayer(dot);
}
}
}
function buildObserverPopup(obs) {
const name = safeEsc(obs.name || obs.id || 'Unknown');
const iata = obs.iata ? `<span class="badge-region">${safeEsc(obs.iata)}</span>` : '';
const lastSeen = obs.last_seen ? timeAgo(obs.last_seen) : '—';
const packets = (obs.packet_count || 0).toLocaleString();
const loc = `${obs.lat.toFixed(5)}, ${obs.lon.toFixed(5)}`;
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS.observer};color:#fff;">OBSERVER</span>`;
return `
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${name}</h3>
${roleBadge} ${iata}
<dl style="margin-top:8px;font-size:12px;">
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
<dd style="margin-left:88px;padding:2px 0;">${loc}</dd>
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Last Seen</dt>
<dd style="margin-left:88px;padding:2px 0;">${lastSeen}</dd>
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Packets</dt>
<dd style="margin-left:88px;padding:2px 0;">${packets}</dd>
</dl>
<a href="#/observers/${encodeURIComponent(obs.id || obs.observer_id)}" style="display:block;margin-top:8px;font-size:12px;color:var(--accent);">View Detail </a>
</div>`;
}
function buildPopup(node) {
const key = node.public_key ? truncate(node.public_key, 16) : '—';
const loc = (node.lat && node.lon) ? `${node.lat.toFixed(5)}, ${node.lon.toFixed(5)}` : '—';
const lastAdvert = node.last_seen ? timeAgo(node.last_seen) : '—';
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS[node.role] || '#4b5563'};color:#fff;">${(node.role || 'unknown').toUpperCase()}</span>`;
const hs = node.hash_size || 1;
const hashPrefix = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '—';
const hashPrefixRow = `<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Hash Prefix</dt>
<dd style="font-family:var(--mono);font-size:11px;font-weight:700;margin-left:88px;padding:2px 0;">${safeEsc(hashPrefix)} <span style="font-weight:400;color:var(--text-muted);">(${hs}B)</span></dd>`;
return `
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${safeEsc(node.name || 'Unknown')}</h3>
${roleBadge}
<dl style="margin-top:8px;font-size:12px;">
${hashPrefixRow}
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Key</dt>
<dd style="font-family:var(--mono);font-size:11px;margin-left:88px;padding:2px 0;">${safeEsc(key)}</dd>
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
@@ -730,48 +397,21 @@
}
function toggleHeatmap(on) {
if (!on || !map) {
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; window._meshcoreHeatLayer = null; }
return;
}
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
if (!on || !map) return;
const points = nodes
.filter(n => n.lat != null && n.lon != null)
.map(n => {
const weight = n.advert_count || 1;
return [n.lat, n.lon, weight];
});
if (!points.length || typeof L.heatLayer !== 'function') return;
var savedOpacity = parseFloat(localStorage.getItem('meshcore-heatmap-opacity'));
if (isNaN(savedOpacity)) savedOpacity = 0.25;
// Update existing layer data without recreating (avoids opacity flash)
if (heatLayer) {
heatLayer.setLatLngs(points);
return;
if (points.length && typeof L.heatLayer === 'function') {
heatLayer = L.heatLayer(points, {
radius: 25, blur: 15, maxZoom: 14, minOpacity: 0.25,
gradient: { 0.2: '#0d47a1', 0.4: '#1565c0', 0.6: '#42a5f5', 0.8: '#ffca28', 1.0: '#ff5722' }
}).addTo(map);
}
heatLayer = L.heatLayer(points, {
radius: 25, blur: 15, maxZoom: 14, minOpacity: 0.05,
gradient: { 0.2: '#0d47a1', 0.4: '#1565c0', 0.6: '#42a5f5', 0.8: '#ffca28', 1.0: '#ff5722' }
});
// Set opacity on canvas BEFORE it's visible — hook the 'add' event
heatLayer.on('add', function() {
var canvas = heatLayer._canvas || (heatLayer.getContainer && heatLayer.getContainer());
if (canvas) canvas.style.opacity = savedOpacity;
});
heatLayer.addTo(map);
window._meshcoreHeatLayer = heatLayer;
}
let _themeRefreshHandler = null;
registerPage('map', {
init: function(app, routeParam) {
_themeRefreshHandler = () => { if (markerLayer) renderMarkers(); };
window.addEventListener('theme-refresh', _themeRefreshHandler);
return init(app, routeParam);
},
destroy: function() {
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
return destroy();
}
});
registerPage('map', { init, destroy });
})();
+2 -2
View File
@@ -40,7 +40,7 @@
let data;
try {
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days, { ttl: CLIENT_TTL.nodeAnalytics });
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days, { ttl: 60000 });
} catch (e) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load analytics: ' + escapeHtml(e.message) + '</div>';
return;
@@ -55,7 +55,7 @@
<div style="margin-bottom:12px">
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:12px"> Back to ${nodeName}</a>
<h2 style="margin:4px 0 2px;font-size:18px">📊 ${nodeName} Analytics</h2>
<div style="color:var(--text-muted);font-size:11px">${n.role || 'Unknown role'} · ${s.totalTransmissions || s.totalPackets} packets in ${days}d window</div>
<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">
+97 -403
View File
@@ -19,64 +19,12 @@
let selectedKey = null;
let activeTab = 'all';
let search = '';
// Sort state: column + direction, persisted to localStorage
let sortState = (function () {
try {
const saved = JSON.parse(localStorage.getItem('meshcore-nodes-sort'));
if (saved && saved.column && saved.direction) return saved;
} catch {}
return { column: 'last_seen', direction: 'desc' };
})();
function toggleSort(column) {
if (sortState.column === column) {
sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
} else {
// Default direction per column type
const descDefault = ['last_seen', 'advert_count'];
sortState = { column, direction: descDefault.includes(column) ? 'desc' : 'asc' };
}
localStorage.setItem('meshcore-nodes-sort', JSON.stringify(sortState));
}
function sortNodes(arr) {
const col = sortState.column;
const dir = sortState.direction === 'asc' ? 1 : -1;
return arr.sort(function (a, b) {
let va, vb;
if (col === 'name') {
va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase();
if (!a.name && b.name) return 1;
if (a.name && !b.name) return -1;
return va < vb ? -dir : va > vb ? dir : 0;
} else if (col === 'public_key') {
va = a.public_key || ''; vb = b.public_key || '';
return va < vb ? -dir : va > vb ? dir : 0;
} else if (col === 'role') {
va = (a.role || '').toLowerCase(); vb = (b.role || '').toLowerCase();
return va < vb ? -dir : va > vb ? dir : 0;
} else if (col === 'last_seen') {
va = a.last_heard ? new Date(a.last_heard).getTime() : a.last_seen ? new Date(a.last_seen).getTime() : 0;
vb = b.last_heard ? new Date(b.last_heard).getTime() : b.last_seen ? new Date(b.last_seen).getTime() : 0;
return (va - vb) * dir;
} else if (col === 'advert_count') {
va = a.advert_count || 0; vb = b.advert_count || 0;
return (va - vb) * dir;
}
return 0;
});
}
function sortArrow(col) {
if (sortState.column !== col) return '';
return '<span class="sort-arrow">' + (sortState.direction === 'asc' ? '▲' : '▼') + '</span>';
}
let lastHeard = localStorage.getItem('meshcore-nodes-last-heard') || '';
let statusFilter = localStorage.getItem('meshcore-nodes-status-filter') || 'all';
let sortBy = 'lastSeen';
let lastHeard = '';
let wsHandler = null;
let detailMap = null;
// ROLE_COLORS loaded from shared roles.js
const ROLE_COLORS = { repeater: '#3b82f6', room: '#6b7280', companion: '#22c55e', sensor: '#f59e0b' };
const TABS = [
{ key: 'all', label: 'All' },
{ key: 'repeater', label: 'Repeaters' },
@@ -85,80 +33,8 @@
{ key: 'sensor', label: 'Sensors' },
];
/* === Shared helper functions for node detail rendering === */
function getStatusTooltip(role, status) {
const isInfra = role === 'repeater' || role === 'room';
const threshold = isInfra ? '72h' : '24h';
if (status === 'active') {
return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
}
if (role === 'companion') {
return 'Stale \u2014 not heard for over ' + threshold + '. Companions only advertise when the user initiates \u2014 this may be normal.';
}
if (role === 'sensor') {
return 'Stale \u2014 not heard for over ' + threshold + '. This sensor may be offline.';
}
return 'Stale \u2014 not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.';
}
function getStatusInfo(n) {
// Single source of truth for all status-related info
const role = (n.role || '').toLowerCase();
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
// Prefer last_heard (from in-memory packets) > _lastHeard (health API) > last_seen (DB)
const lastHeardTime = n._lastHeard || n.last_heard || n.last_seen;
const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0;
const status = getNodeStatus(role, lastHeardMs);
const statusTooltip = getStatusTooltip(role, status);
const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale';
const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity;
let explanation = '';
if (status === 'active') {
explanation = 'Last heard ' + (lastHeardTime ? timeAgo(lastHeardTime) : 'unknown');
} else {
const ageDays = Math.floor(statusAge / 86400000);
const ageHours = Math.floor(statusAge / 3600000);
const ageStr = ageDays >= 1 ? ageDays + 'd' : ageHours + 'h';
const isInfra = role === 'repeater' || role === 'room';
const reason = isInfra
? 'repeaters typically advertise every 12-24h'
: 'companions only advertise when user initiates, this may be normal';
explanation = 'Not heard for ' + ageStr + ' — ' + reason;
}
return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role };
}
function renderNodeBadges(n, roleColor) {
// Returns HTML for: role badge, hash prefix badge, hash inconsistency link, status label
const info = getStatusInfo(n);
let html = `<span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span>`;
if (n.hash_size) {
html += ` <span class="badge" style="background:var(--nav-bg);color:var(--nav-text);font-family:var(--mono)">${n.public_key.slice(0, n.hash_size * 2).toUpperCase()}</span>`;
}
if (n.hash_size_inconsistent) {
html += ` <a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" class="badge" style="background:var(--status-yellow);color:#000;font-size:10px;cursor:pointer;text-decoration:none">⚠️ variable hash size</a>`;
}
html += ` <span title="${info.statusTooltip}">${info.statusLabel}</span>`;
return html;
}
function renderStatusExplanation(n) {
const info = getStatusInfo(n);
return `<div style="font-size:12px;color:var(--text-muted);margin:4px 0 6px"><span title="${info.statusTooltip}">${info.statusLabel}</span> — ${info.explanation}</div>`;
}
function renderHashInconsistencyWarning(n) {
if (!n.hash_size_inconsistent) return '';
return `<div style="font-size:11px;color:var(--text-muted);margin:-2px 0 6px;padding:6px 10px;background:var(--surface-2);border-radius:4px;border-left:3px solid var(--status-yellow)">Adverts show varying hash sizes (<strong>${(n.hash_sizes_seen||[]).join('-byte, ')}-byte</strong>). This is a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">known bug</a> where automatic adverts ignore the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</div>`;
}
let directNode = null; // set when navigating directly to #/nodes/:pubkey
let regionChangeHandler = null;
function init(app, routeParam) {
directNode = routeParam || null;
@@ -190,16 +66,12 @@
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
<div class="nodes-counts" id="nodeCounts"></div>
</div>
<div id="nodesRegionFilter" class="region-filter-container"></div>
<div class="split-layout">
<div class="panel-left" id="nodesLeft"></div>
<div class="panel-right empty" id="nodesRight"><span>Select a node to view details</span></div>
</div>
</div>`;
RegionFilter.init(document.getElementById('nodesRegionFilter'));
regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; loadNodes(); });
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
search = e.target.value;
loadNodes();
@@ -213,14 +85,15 @@
const body = document.getElementById('nodeFullBody');
try {
const [nodeData, healthData] = await Promise.all([
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
api('/nodes/' + encodeURIComponent(pubkey), { ttl: 15000 }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30000 }).catch(() => null)
]);
const n = nodeData.node;
const adverts = (nodeData.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const adverts = nodeData.recentAdverts || [];
const title = document.querySelector('.node-full-title');
if (title) title.textContent = n.name || pubkey.slice(0, 12);
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const hasLoc = n.lat != null && n.lon != null;
// Health stats
@@ -229,55 +102,49 @@
const observers = h.observers || [];
const recent = h.recentPackets || [];
const lastHeard = stats.lastHeard;
// Attach health lastHeard for shared helpers
n._lastHeard = lastHeard || n.last_seen;
const si = getStatusInfo(n);
const roleColor = si.roleColor;
const statusLabel = si.statusLabel;
const statusExplanation = si.explanation;
const statusAge = lastHeard ? (Date.now() - new Date(lastHeard).getTime()) : Infinity;
// 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 = `
<div class="node-full-card" style="padding:12px 16px;margin-bottom:8px">
${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:4px 0 6px">${renderNodeBadges(n, roleColor)}</div>
${renderHashInconsistencyWarning(n)}
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:6px">${n.public_key}</div>
<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: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-top-row">
${hasLoc ? `<div class="node-map-wrap"><div id="nodeFullMap" class="node-detail-map" style="height:100%;min-height:200px;border-radius:8px;overflow:hidden"></div></div>` : ''}
<div class="node-qr-wrap${hasLoc ? '' : ' node-qr-wrap--full'}">
<div class="node-qr" id="nodeFullQrCode"></div>
<div class="mono" style="font-size:10px;color:var(--text-muted);margin-top:8px;word-break:break-all;text-align:center;max-width:180px">${n.public_key.slice(0, 16)}${n.public_key.slice(-8)}</div>
</div>
<div class="node-full-card">
<h4>Stats</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>Total Packets</dt><dd>${stats.totalPackets || n.advert_count || 0}</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>
<table class="node-stats-table" id="node-stats">
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
<tr><td>Last Heard</td><td>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '')}</td></tr>
<tr><td>First Seen</td><td>${n.first_seen ? new Date(n.first_seen).toLocaleString() : ''}</td></tr>
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
${stats.avgSnr != null ? `<tr><td>Avg SNR</td><td>${stats.avgSnr.toFixed(1)} dB</td></tr>` : ''}
${stats.avgHops ? `<tr><td>Avg Hops</td><td>${stats.avgHops}</td></tr>` : ''}
${hasLoc ? `<tr><td>Location</td><td>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</td></tr>` : ''}
<tr><td>Hash Prefix</td><td>${n.hash_size ? '<code style="font-family:var(--mono);font-weight:700">' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + '</code> (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + (n.hash_sizes_seen || []).join(', ') + '-byte"> varies</span>' : ''}</td></tr>
</table>
${observers.length ? `<div class="node-full-card" id="node-observers">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</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>Region</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
<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.iata ? escapeHtml(o.iata) : '—'}</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>
@@ -286,12 +153,7 @@
</table>
</div>` : ''}
<div class="node-full-card" id="fullPathsSection">
<h4>Paths Through This Node</h4>
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths</div></div>
</div>
<div class="node-full-card" id="node-packets">
<div class="node-full-card">
<h4>Recent Packets (${adverts.length})</h4>
<div class="node-activity-list">
${adverts.length ? adverts.map(p => {
@@ -301,20 +163,10 @@
const obs = p.observer_name || p.observer_id;
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
// Show hash size per advert if inconsistent
let hashSizeBadge = '';
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
const hs = ((pb >> 6) & 0x3) + 1;
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
const hsFg = hs === 2 ? '#064e3b' : '#fff';
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
}
return `<div class="node-activity-item">
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze </a>
<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 packets</div>'}
</div>
@@ -341,15 +193,6 @@
}).catch(() => {});
});
// Deep-link scroll: ?section=node-packets or ?section=node-packets
const hashParams = location.hash.split('?')[1] || '';
const urlParams = new URLSearchParams(hashParams);
const scrollTarget = urlParams.get('section') || (urlParams.has('highlight') ? 'node-packets' : null);
if (scrollTarget) {
const targetEl = document.getElementById(scrollTarget);
if (targetEl) setTimeout(() => targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' }), 300);
}
// QR code for full-screen view
const qrFullEl = document.getElementById('nodeFullQrCode');
if (qrFullEl && typeof qrcode === 'function') {
@@ -360,115 +203,34 @@
const qr = qrcode(0, 'M');
qr.addData(meshcoreUrl);
qr.make();
qrFullEl.innerHTML = qr.createSvgTag(3, 0);
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 {}
}
// Fetch paths through this node (full-screen view)
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
const el = document.getElementById('fullPathsContent');
if (!el) return;
if (!pathData || !pathData.paths || !pathData.paths.length) {
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
return;
}
document.querySelector('#fullPathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique, ${pathData.totalTransmissions} transmissions)`;
const COLLAPSE_LIMIT = 10;
function renderPaths(paths) {
return paths.map(p => {
const chain = p.hops.map(h => {
const isThis = h.pubkey === n.public_key;
if (window.HopDisplay) {
const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable };
const html = HopDisplay.renderHop(h.prefix, entry);
return isThis ? html.replace('class="', 'class="hop-current ') : html;
}
const name = escapeHtml(h.name || h.prefix);
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--accent, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
return link;
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
<div>${chain}</div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze </a></div>
</div>`;
}).join('');
}
if (pathData.paths.length <= COLLAPSE_LIMIT) {
el.innerHTML = renderPaths(pathData.paths);
} else {
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
`<button id="showAllFullPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
document.getElementById('showAllFullPaths').addEventListener('click', function() {
el.innerHTML = renderPaths(pathData.paths);
});
}
}).catch(() => {
const el = document.getElementById('fullPathsContent');
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
});
} catch (e) {
body.innerHTML = `<div class="text-muted" style="padding:40px">Failed to load node: ${e.message}</div>`;
}
}
function destroy() {
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (detailMap) { detailMap.remove(); detailMap = null; }
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
regionChangeHandler = null;
nodes = [];
selectedKey = null;
}
let _allNodes = null; // cached full node list
async function loadNodes() {
try {
// Fetch all nodes once, filter client-side
if (!_allNodes) {
const params = new URLSearchParams({ limit: '5000' });
const rp = RegionFilter.getRegionParam();
if (rp) params.set('region', rp);
const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList });
_allNodes = data.nodes || [];
counts = data.counts || {};
}
// Client-side filtering
let filtered = _allNodes;
if (activeTab !== 'all') filtered = filtered.filter(n => (n.role || '').toLowerCase() === activeTab);
if (search) {
const q = search.toLowerCase();
filtered = filtered.filter(n => (n.name || '').toLowerCase().includes(q) || (n.public_key || '').toLowerCase().includes(q));
}
if (lastHeard) {
const ms = { '1h': 3600000, '2h': 7200000, '6h': 21600000, '12h': 43200000, '24h': 86400000, '48h': 172800000, '3d': 259200000, '7d': 604800000, '14d': 1209600000, '30d': 2592000000 }[lastHeard];
if (ms) filtered = filtered.filter(n => {
const t = n.last_heard || n.last_seen;
return t && (Date.now() - new Date(t).getTime()) < ms;
});
}
// Status filter (active/stale)
if (statusFilter === 'active' || statusFilter === 'stale') {
filtered = filtered.filter(n => {
const role = (n.role || 'companion').toLowerCase();
const t = n.last_heard || n.last_seen;
const lastMs = t ? new Date(t).getTime() : 0;
return getNodeStatus(role, lastMs) === statusFilter;
});
}
nodes = filtered;
// Defensive filter: hide nodes with obviously corrupted data
nodes = nodes.filter(n => {
if (n.public_key && n.public_key.length < 16) return false;
if (!n.name && !n.advert_count) return false;
return true;
});
const params = new URLSearchParams({ limit: '200', sortBy });
if (activeTab !== 'all') params.set('role', activeTab);
if (search) params.set('search', search);
if (lastHeard) params.set('lastHeard', lastHeard);
const data = await api('/nodes?' + params, { ttl: 10000 });
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') || '[]');
@@ -476,7 +238,7 @@
const missing = myNodes.filter(mn => !existingKeys.has(mn.pubkey));
if (missing.length) {
const fetched = await Promise.allSettled(
missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: CLIENT_TTL.nodeDetail }))
missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: 15000 }))
);
fetched.forEach(r => {
if (r.status === 'fulfilled' && r.value && r.value.public_key) nodes.push(r.value);
@@ -499,10 +261,10 @@
const el = document.getElementById('nodeCounts');
if (!el) return;
el.innerHTML = [
{ k: 'repeaters', l: 'Repeaters', c: ROLE_COLORS.repeater },
{ k: 'rooms', l: 'Rooms', c: ROLE_COLORS.room || '#6b7280' },
{ k: 'companions', l: 'Companions', c: ROLE_COLORS.companion },
{ k: 'sensors', l: 'Sensors', c: ROLE_COLORS.sensor },
{ k: 'repeaters', l: 'Repeaters', c: '#3b82f6' },
{ k: 'rooms', l: 'Rooms', c: '#6b7280' },
{ k: 'companions', l: 'Companions', c: '#22c55e' },
{ k: 'sensors', l: 'Sensors', c: '#f59e0b' },
].map(r => `<span class="node-count-pill" style="background:${r.c}">${counts[r.k] || 0} ${r.l}</span>`).join('');
}
@@ -516,33 +278,28 @@
${TABS.map(t => `<button class="node-tab ${activeTab === t.key ? 'active' : ''}" data-tab="${t.key}">${t.label}</button>`).join('')}
</div>
<div class="nodes-filters">
<div class="filter-group" id="nodeStatusFilter">
<button class="btn ${statusFilter==='all'?'active':''}" data-status="all">All</button>
<button class="btn ${statusFilter==='active'?'active':''}" data-status="active">Active</button>
<button class="btn ${statusFilter==='stale'?'active':''}" data-status="stale">Stale</button>
</div>
<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="2h" ${lastHeard==='2h'?'selected':''}>2 hours</option>
<option value="6h" ${lastHeard==='6h'?'selected':''}>6 hours</option>
<option value="12h" ${lastHeard==='12h'?'selected':''}>12 hours</option>
<option value="24h" ${lastHeard==='24h'?'selected':''}>24 hours</option>
<option value="48h" ${lastHeard==='48h'?'selected':''}>48 hours</option>
<option value="3d" ${lastHeard==='3d'?'selected':''}>3 days</option>
<option value="7d" ${lastHeard==='7d'?'selected':''}>7 days</option>
<option value="14d" ${lastHeard==='14d'?'selected':''}>14 days</option>
<option value="30d" ${lastHeard==='30d'?'selected':''}>30 days</option>
</select>
<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>
</select>
</div>
</div>
<table class="data-table" id="nodesTable">
<thead><tr>
<th class="sortable${sortState.column==='name'?' sort-active':''}" data-sort="name">Name${sortArrow('name')}</th>
<th class="sortable${sortState.column==='public_key'?' sort-active':''}" data-sort="public_key">Public Key${sortArrow('public_key')}</th>
<th class="sortable${sortState.column==='role'?' sort-active':''}" data-sort="role">Role${sortArrow('role')}</th>
<th class="sortable${sortState.column==='last_seen'?' sort-active':''}" data-sort="last_seen">Last Seen${sortArrow('last_seen')}</th>
<th class="sortable${sortState.column==='advert_count'?' sort-active':''}" data-sort="advert_count">Adverts${sortArrow('advert_count')}</th>
<th class="sortable" data-sort="name" aria-sort="${sortBy === 'name' ? 'ascending' : 'none'}">Name</th>
<th>Public Key</th>
<th>Role</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>`;
@@ -555,21 +312,12 @@
});
// Filter changes
document.getElementById('nodeLastHeard').addEventListener('change', e => { lastHeard = e.target.value; localStorage.setItem('meshcore-nodes-last-heard', lastHeard); loadNodes(); });
// Status filter buttons
document.querySelectorAll('#nodeStatusFilter .btn').forEach(btn => {
btn.addEventListener('click', () => {
statusFilter = btn.dataset.status;
localStorage.setItem('meshcore-nodes-status-filter', statusFilter);
document.querySelectorAll('#nodeStatusFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.status === statusFilter));
loadNodes();
});
});
document.getElementById('nodeLastHeard').addEventListener('change', e => { lastHeard = e.target.value; loadNodes(); });
document.getElementById('nodeSort').addEventListener('change', e => { sortBy = e.target.value; loadNodes(); });
// Sortable column headers
el.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => { toggleSort(th.dataset.sort); renderLeft(); });
th.addEventListener('click', () => { sortBy = th.dataset.sort; loadNodes(); });
});
// Delegated click/keyboard handler for table rows
@@ -611,13 +359,11 @@
return;
}
// Claimed ("My Mesh") nodes always on top, then favorites, then sort
// 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 = sortNodes([...nodes]);
// Stable re-sort: claimed first, then favorites, preserving sort within each group
sorted.sort((a, b) => {
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;
@@ -629,14 +375,11 @@
tbody.innerHTML = sorted.map(n => {
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const isClaimed = myKeys.has(n.public_key);
const lastSeenTime = n.last_heard || n.last_seen;
const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0);
const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale';
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 class="${lastSeenClass}">${timeAgo(n.last_heard || n.last_seen)}</td>
<td>${timeAgo(n.last_seen)}</td>
<td>${n.advert_count || 0}</td>
</tr>`;
}).join('');
@@ -658,8 +401,8 @@
try {
const [data, healthData] = await Promise.all([
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
api('/nodes/' + encodeURIComponent(pubkey), { ttl: 15000 }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30000 }).catch(() => null)
]);
data.healthData = healthData;
renderDetail(panel, data);
@@ -670,37 +413,38 @@
function renderDetail(panel, data) {
const n = data.node;
const adverts = (data.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const adverts = data.recentAdverts || [];
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 via shared helper
// Status calculation
const lastHeard = stats.lastHeard;
n._lastHeard = lastHeard || n.last_seen;
const si = getStatusInfo(n);
const roleColor = si.roleColor;
const totalPackets = stats.totalTransmissions || stats.totalPackets || n.advert_count || 0;
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 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">${renderNodeBadges(n, roleColor)}
<a href="#/nodes/${encodeURIComponent(n.public_key)}" class="btn-primary" style="display:inline-block;text-decoration:none;font-size:11px;padding:2px 8px;margin-left:8px">🔍 Details</a>
<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>
${renderStatusExplanation(n)}
${hasLoc ? `<div class="node-map-qr-wrap">
<div class="node-map-container node-detail-map" id="nodeMap" style="border-radius:8px;overflow:hidden;"></div>
<div class="node-map-qr-overlay node-qr" id="nodeQrCode"></div>
</div>` : `<div class="node-qr" id="nodeQrCode" style="margin:8px 0"></div>`}
<div class="node-detail-section">
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:4px">${n.public_key}</div>
<h4>Public Key</h4>
<div class="node-detail-key mono">${n.public_key}</div>
<div class="node-qr" id="nodeQrCode"></div>
</div>
<div class="node-detail-section">
@@ -717,21 +461,15 @@
</div>
${observers.length ? `<div class="node-detail-section">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
<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)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
<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" id="pathsSection">
<h4>Paths Through This Node</h4>
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths</div></div>
</div>
<div class="node-detail-section">
<h4>Recent Packets (${adverts.length})</h4>
<div id="advertTimeline">
@@ -746,10 +484,9 @@
<span class="advert-dot" style="background:${roleColor}"></span>
<div class="advert-info">
<strong>${timeAgo(a.timestamp)}</strong> ${icon} ${pType}${detail}
${a.observation_count > 1 ? ' <span class="badge badge-obs">👁 ' + a.observation_count + '</span>' : ''}
${obs ? ' via ' + escapeHtml(obs) : ''}
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
<br><a href="#/packets/${a.hash}" class="ch-analyze-link">Analyze </a>
<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 packets</div>'}
@@ -779,62 +516,19 @@
const qr = qrcode(0, 'M');
qr.addData(meshcoreUrl);
qr.make();
const isOverlay = !!qrEl.closest('.node-map-qr-overlay');
qrEl.innerHTML = qr.createSvgTag(3, 0);
qrEl.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 = qrEl.querySelector('svg');
if (svg) {
svg.style.display = 'block'; svg.style.margin = '0 auto';
// Make QR background transparent for map overlay
if (isOverlay) {
svg.querySelectorAll('rect').forEach(r => {
const fill = (r.getAttribute('fill') || '').toLowerCase();
if (fill === '#ffffff' || fill === 'white' || fill === '#fff') {
r.setAttribute('fill', 'transparent');
}
});
}
}
if (svg) { svg.style.display = 'block'; svg.style.margin = '0 auto'; }
} catch {}
}
// Fetch paths through this node
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
const el = document.getElementById('pathsContent');
if (!el) return;
if (!pathData || !pathData.paths || !pathData.paths.length) {
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
document.querySelector('#pathsSection h4').textContent = 'Paths Through This Node';
return;
}
document.querySelector('#pathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique path${pathData.totalPaths !== 1 ? 's' : ''}, ${pathData.totalTransmissions} transmissions)`;
const COLLAPSE_LIMIT = 10;
const showAll = pathData.paths.length <= COLLAPSE_LIMIT;
function renderPaths(paths) {
return paths.map(p => {
const chain = p.hops.map(h => {
const isThis = h.pubkey === n.public_key;
const name = escapeHtml(h.name || h.prefix);
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--accent, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
return link;
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
<div>${chain}</div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze </a></div>
</div>`;
}).join('');
}
if (showAll) {
el.innerHTML = renderPaths(pathData.paths);
} else {
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
`<button id="showAllPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
document.getElementById('showAllPaths').addEventListener('click', function() {
el.innerHTML = renderPaths(pathData.paths);
});
}
}).catch(() => {
const el = document.getElementById('pathsContent');
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
// Copy URL
document.getElementById('copyUrlBtn').addEventListener('click', () => {
navigator.clipboard.writeText(nodeUrl).then(() => {
const btn = document.getElementById('copyUrlBtn');
btn.textContent = '✅ Copied!';
setTimeout(() => btn.textContent = '📋 Copy URL', 2000);
}).catch(() => {});
});
}
-320
View File
@@ -1,320 +0,0 @@
/* === MeshCore Analyzer — observer-detail.js === */
'use strict';
(function () {
const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
const CHART_COLORS = ['#4a9eff', '#ff6b6b', '#51cf66', '#fcc419', '#cc5de8', '#20c997', '#ff922b', '#845ef7', '#f06595', '#339af0'];
let charts = [];
let currentDays = 7;
let currentId = null;
function destroyCharts() {
charts.forEach(c => { try { c.destroy(); } catch {} });
charts = [];
}
function chartDefaults() {
const style = getComputedStyle(document.documentElement);
Chart.defaults.color = style.getPropertyValue('--text-muted').trim() || '#6b7280';
Chart.defaults.borderColor = style.getPropertyValue('--border').trim() || '#e2e5ea';
}
function formatDuration(secs) {
if (!secs) return '—';
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function init(app, routeParam) {
currentId = routeParam;
if (!currentId) {
app.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observer ID specified.</div>';
return;
}
app.innerHTML = `
<div class="observer-detail-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back"></a>
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
<div style="margin-left:auto;display:flex;gap:8px">
<select id="obsDaysSelect" class="time-range-select" aria-label="Time range">
<option value="1">24 Hours</option>
<option value="3">3 Days</option>
<option value="7" selected>7 Days</option>
<option value="30">30 Days</option>
</select>
</div>
</div>
<div id="obsDetailContent"><div class="text-center text-muted" style="padding:40px">Loading</div></div>
</div>`;
document.getElementById('obsDaysSelect').addEventListener('change', function (e) {
currentDays = parseInt(e.target.value);
loadDetail();
});
loadDetail();
}
function destroy() {
destroyCharts();
currentId = null;
}
async function loadDetail() {
try {
destroyCharts();
chartDefaults();
const [obs, analytics] = await Promise.all([
api('/observers/' + encodeURIComponent(currentId)),
api('/observers/' + encodeURIComponent(currentId) + '/analytics?days=' + currentDays),
]);
renderDetail(obs, analytics);
} catch (e) {
document.getElementById('obsDetailContent').innerHTML =
'<div class="text-muted" style="padding:40px">Error: ' + e.message + '</div>';
}
}
function renderDetail(obs, analytics) {
const el = document.getElementById('obsDetailContent');
if (!el) return;
const title = document.getElementById('obsTitle');
if (title) title.textContent = obs.name || obs.id.substring(0, 16) + '…';
// Parse radio string
let radioHtml = '—';
if (obs.radio) {
const rp = obs.radio.split(',');
radioHtml = rp[0] + ' MHz · SF' + (rp[2] || '?') + ' · BW' + (rp[1] || '?') + ' · CR' + (rp[3] || '?');
}
// Health status
const ago = obs.last_seen ? Date.now() - new Date(obs.last_seen).getTime() : Infinity;
const statusCls = ago < 600000 ? 'health-green' : ago < HEALTH_THRESHOLDS.nodeDegradedMs ? 'health-yellow' : 'health-red';
const statusLabel = ago < 600000 ? 'Online' : ago < HEALTH_THRESHOLDS.nodeDegradedMs ? 'Stale' : 'Offline';
el.innerHTML = `
<div class="obs-info-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
<div class="stat-card">
<div class="stat-label">Status</div>
<div class="stat-value"><span class="health-dot ${statusCls}"></span> ${statusLabel}</div>
</div>
<div class="stat-card">
<div class="stat-label">Region</div>
<div class="stat-value">${obs.iata ? '<span class="badge-region">' + obs.iata + '</span>' : '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Model</div>
<div class="stat-value">${obs.model || '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Firmware</div>
<div class="stat-value" style="font-size:0.8em;word-break:break-all">${obs.firmware || '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Client</div>
<div class="stat-value" style="font-size:0.8em;word-break:break-all">${obs.client_version || '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Radio</div>
<div class="stat-value" style="font-size:0.85em">${radioHtml}</div>
</div>
<div class="stat-card">
<div class="stat-label">Battery</div>
<div class="stat-value">${obs.battery_mv ? obs.battery_mv + ' mV' : '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-value">${formatDuration(obs.uptime_secs)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Noise Floor</div>
<div class="stat-value">${obs.noise_floor != null ? obs.noise_floor + ' dBm' : '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Packets</div>
<div class="stat-value">${(obs.packet_count || 0).toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Packets/Hour</div>
<div class="stat-value">${(obs.packetsLastHour || 0).toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">First Seen</div>
<div class="stat-value" style="font-size:0.85em">${obs.first_seen ? new Date(obs.first_seen).toLocaleDateString() : '—'}</div>
</div>
</div>
<div class="mono" style="font-size:0.75em;color:var(--text-muted);margin-bottom:20px;word-break:break-all">
ID: ${obs.id}
</div>
<div class="obs-charts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px">
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">Packets Over Time</h3>
<canvas id="obsTimeChart"></canvas>
</div>
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">Packet Types</h3>
<div style="max-width:280px;margin:0 auto"><canvas id="obsTypeChart"></canvas></div>
</div>
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">Unique Nodes Heard</h3>
<canvas id="obsNodesChart"></canvas>
</div>
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">SNR Distribution</h3>
<canvas id="obsSnrChart"></canvas>
</div>
</div>
<div style="margin-top:20px">
<h3 style="font-size:0.95em">Recent Packets</h3>
<div id="obsRecentPackets"><div class="text-muted">Loading</div></div>
</div>`;
// Render charts
if (analytics.timeline && analytics.timeline.length > 0) {
renderTimelineChart(analytics.timeline);
}
if (analytics.packetTypes) {
renderTypeChart(analytics.packetTypes);
}
if (analytics.nodesTimeline && analytics.nodesTimeline.length > 0) {
renderNodesChart(analytics.nodesTimeline);
}
if (analytics.snrDistribution && analytics.snrDistribution.length > 0) {
renderSnrChart(analytics.snrDistribution);
}
if (analytics.recentPackets) {
renderRecentPackets(analytics.recentPackets);
}
}
function renderTimelineChart(timeline) {
const ctx = document.getElementById('obsTimeChart');
if (!ctx) return;
const c = new Chart(ctx, {
type: 'bar',
data: {
labels: timeline.map(t => t.label),
datasets: [{
label: 'Packets',
data: timeline.map(t => t.count),
backgroundColor: CHART_COLORS[0] + '80',
borderColor: CHART_COLORS[0],
borderWidth: 1,
}]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
y: { beginAtZero: true, ticks: { precision: 0 } }
}
}
});
charts.push(c);
}
function renderTypeChart(types) {
const ctx = document.getElementById('obsTypeChart');
if (!ctx) return;
const labels = Object.keys(types).map(k => PAYLOAD_LABELS[k] || 'Type ' + k);
const values = Object.values(types);
const c = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{ data: values, backgroundColor: CHART_COLORS.slice(0, labels.length) }]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 } } }
}
});
charts.push(c);
}
function renderNodesChart(timeline) {
const ctx = document.getElementById('obsNodesChart');
if (!ctx) return;
const c = new Chart(ctx, {
type: 'line',
data: {
labels: timeline.map(t => t.label),
datasets: [{
label: 'Unique Nodes',
data: timeline.map(t => t.count),
borderColor: CHART_COLORS[2],
backgroundColor: CHART_COLORS[2] + '20',
fill: true, tension: 0.3, pointRadius: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
y: { beginAtZero: true, ticks: { precision: 0 } }
}
}
});
charts.push(c);
}
function renderSnrChart(distribution) {
const ctx = document.getElementById('obsSnrChart');
if (!ctx) return;
const c = new Chart(ctx, {
type: 'bar',
data: {
labels: distribution.map(d => d.range),
datasets: [{
label: 'Packets',
data: distribution.map(d => d.count),
backgroundColor: CHART_COLORS[3] + '80',
borderColor: CHART_COLORS[3],
borderWidth: 1,
}]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'SNR (dB)' } },
y: { beginAtZero: true, ticks: { precision: 0 } }
}
}
});
charts.push(c);
}
function renderRecentPackets(packets) {
const el = document.getElementById('obsRecentPackets');
if (!el || !packets.length) { if (el) el.innerHTML = '<div class="text-muted">No recent packets.</div>'; return; }
el.innerHTML = `<table class="data-table" style="font-size:0.85em">
<thead><tr><th>Time</th><th>Type</th><th>Hash</th><th>SNR</th><th>RSSI</th><th>Hops</th></tr></thead>
<tbody>${packets.map(p => {
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
return `<tr style="cursor:pointer" onclick="location.hash='#/packets/${p.hash || p.id}'">
<td>${timeAgo(p.timestamp)}</td>
<td>${typeName}</td>
<td class="mono" style="font-size:0.85em">${(p.hash || '').substring(0, 10)}</td>
<td>${p.snr != null ? Number(p.snr).toFixed(1) : '—'}</td>
<td>${p.rssi != null ? p.rssi : '—'}</td>
<td>${hops.length}</td>
</tr>`;
}).join('')}</tbody>
</table>`;
}
registerPage('observer-detail', { init, destroy });
})();
+12 -23
View File
@@ -5,7 +5,6 @@
let observers = [];
let wsHandler = null;
let refreshTimer = null;
let regionChangeHandler = null;
function init(app) {
app.innerHTML = `
@@ -14,11 +13,8 @@
<h2>Observer Status</h2>
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
</div>
<div id="obsRegionFilter" class="region-filter-container"></div>
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading</div></div>
</div>`;
RegionFilter.init(document.getElementById('obsRegionFilter'));
regionChangeHandler = RegionFilter.onChange(function () { render(); });
loadObservers();
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
@@ -37,14 +33,12 @@
wsHandler = null;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
regionChangeHandler = null;
observers = [];
}
async function loadObservers() {
try {
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
const data = await api('/observers', { ttl: 30000 });
observers = data.observers || [];
render();
} catch (e) {
@@ -75,39 +69,34 @@
}
function sparkBar(count, max) {
if (max === 0) return `<span class="text-muted">0/hr</span>`;
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 `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap"><span style="display:inline-block;width:60px;height:12px;background:var(--border);border-radius:3px;overflow:hidden;vertical-align:middle"><span style="display:block;height:100%;width:${pct}%;background:linear-gradient(90deg,#3b82f6,#60a5fa);border-radius:3px"></span></span><span style="font-size:11px">${count}/hr</span></span>`;
return `<div class="spark-bar" ${aria}><div class="spark-fill" style="width:${pct}%"></div><span class="spark-label">${count}/hr</span></div>`;
}
function render() {
const el = document.getElementById('obsContent');
if (!el) return;
// Apply region filter
const selectedRegions = RegionFilter.getSelected();
const filtered = selectedRegions
? observers.filter(o => o.iata && selectedRegions.includes(o.iata))
: observers;
if (filtered.length === 0) {
if (observers.length === 0) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observers found.</div>';
return;
}
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
const maxPktsHr = Math.max(1, ...observers.map(o => o.packetsLastHour || 0));
// Summary counts
const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
const online = observers.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
const stale = observers.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
const offline = observers.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
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">📡 ${filtered.length} Total</span>
<span class="obs-stat">📡 ${observers.length} Total</span>
</div>
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
<caption class="sr-only">Observer status and statistics</caption>
@@ -115,10 +104,10 @@
<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>${filtered.map(o => {
<tbody>${observers.map(o => {
const h = healthStatus(o.last_seen);
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
return `<tr>
<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>
-392
View File
@@ -1,392 +0,0 @@
/* packet-filter.js Wireshark-style filter language for MeshCore packets
* Standalone IIFE exposing window.PacketFilter = { parse, evaluate, compile }
*/
(function() {
'use strict';
// Local copies of type maps (also available as window globals from app.js)
// Standard firmware payload type names (canonical)
var FW_PAYLOAD_TYPES = { 0: 'REQ', 1: 'RESPONSE', 2: 'TXT_MSG', 3: 'ACK', 4: 'ADVERT', 5: 'GRP_TXT', 6: 'GRP_DATA', 7: 'ANON_REQ', 8: 'PATH', 9: 'TRACE', 10: 'MULTIPART', 11: 'CONTROL', 15: 'RAW_CUSTOM' };
// Aliases: display names → firmware names (for user convenience)
var TYPE_ALIASES = { 'request': 'REQ', 'response': 'RESPONSE', 'direct msg': 'TXT_MSG', 'dm': 'TXT_MSG', 'ack': 'ACK', 'advert': 'ADVERT', 'channel msg': 'GRP_TXT', 'channel': 'GRP_TXT', 'group data': 'GRP_DATA', 'anon req': 'ANON_REQ', 'path': 'PATH', 'trace': 'TRACE', 'multipart': 'MULTIPART', 'control': 'CONTROL', 'raw': 'RAW_CUSTOM', 'custom': 'RAW_CUSTOM' };
var ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' };
// Use window globals if available (they may have more types)
function getRT() { return window.ROUTE_TYPES || ROUTE_TYPES; }
// ── Lexer ──────────────────────────────────────────────────────────────────
var TK = {
FIELD: 'FIELD', OP: 'OP', STRING: 'STRING', NUMBER: 'NUMBER', BOOL: 'BOOL',
AND: 'AND', OR: 'OR', NOT: 'NOT', LPAREN: 'LPAREN', RPAREN: 'RPAREN'
};
var OP_WORDS = { contains: true, starts_with: true, ends_with: true };
function lex(input) {
var tokens = [], i = 0, len = input.length;
while (i < len) {
// skip whitespace
if (input[i] === ' ' || input[i] === '\t') { i++; continue; }
// two-char operators
var two = input.slice(i, i + 2);
if (two === '&&') { tokens.push({ type: TK.AND, value: '&&' }); i += 2; continue; }
if (two === '||') { tokens.push({ type: TK.OR, value: '||' }); i += 2; continue; }
if (two === '==' || two === '!=' || two === '>=' || two === '<=') {
tokens.push({ type: TK.OP, value: two }); i += 2; continue;
}
// single char
if (input[i] === '>' || input[i] === '<') {
tokens.push({ type: TK.OP, value: input[i] }); i++; continue;
}
if (input[i] === '!') { tokens.push({ type: TK.NOT, value: '!' }); i++; continue; }
if (input[i] === '(') { tokens.push({ type: TK.LPAREN }); i++; continue; }
if (input[i] === ')') { tokens.push({ type: TK.RPAREN }); i++; continue; }
// quoted string
if (input[i] === '"') {
var j = i + 1;
while (j < len && input[j] !== '"') {
if (input[j] === '\\') j++;
j++;
}
if (j >= len) return { tokens: null, error: 'Unterminated string starting at position ' + i };
tokens.push({ type: TK.STRING, value: input.slice(i + 1, j) });
i = j + 1; continue;
}
// number (including negative: only if previous token is OP, AND, OR, NOT, LPAREN, or start)
if (/[0-9]/.test(input[i]) || (input[i] === '-' && i + 1 < len && /[0-9]/.test(input[i + 1]) &&
(tokens.length === 0 || tokens[tokens.length - 1].type === TK.OP ||
tokens[tokens.length - 1].type === TK.AND || tokens[tokens.length - 1].type === TK.OR ||
tokens[tokens.length - 1].type === TK.NOT || tokens[tokens.length - 1].type === TK.LPAREN))) {
var start = i;
if (input[i] === '-') i++;
while (i < len && /[0-9]/.test(input[i])) i++;
if (i < len && input[i] === '.') { i++; while (i < len && /[0-9]/.test(input[i])) i++; }
tokens.push({ type: TK.NUMBER, value: parseFloat(input.slice(start, i)) });
continue;
}
// identifier / keyword / bare value
if (/[a-zA-Z_]/.test(input[i])) {
var s = i;
while (i < len && /[a-zA-Z0-9_.]/.test(input[i])) i++;
var word = input.slice(s, i);
if (word === 'true' || word === 'false') {
tokens.push({ type: TK.BOOL, value: word === 'true' });
} else if (OP_WORDS[word]) {
tokens.push({ type: TK.OP, value: word });
} else {
// Could be a field or a bare string value — context decides in parser
tokens.push({ type: TK.FIELD, value: word });
}
continue;
}
return { tokens: null, error: "Unexpected character '" + input[i] + "' at position " + i };
}
return { tokens: tokens, error: null };
}
// ── Parser ─────────────────────────────────────────────────────────────────
function parse(expression) {
if (!expression || !expression.trim()) return { ast: null, error: null };
var lexResult = lex(expression);
if (lexResult.error) return { ast: null, error: lexResult.error };
var tokens = lexResult.tokens, pos = 0;
function peek() { return pos < tokens.length ? tokens[pos] : null; }
function advance() { return tokens[pos++]; }
function parseOr() {
var left = parseAnd();
while (peek() && peek().type === TK.OR) {
advance();
var right = parseAnd();
left = { type: 'or', left: left, right: right };
}
return left;
}
function parseAnd() {
var left = parseNot();
while (peek() && peek().type === TK.AND) {
advance();
var right = parseNot();
left = { type: 'and', left: left, right: right };
}
return left;
}
function parseNot() {
if (peek() && peek().type === TK.NOT) {
advance();
return { type: 'not', expr: parseNot() };
}
if (peek() && peek().type === TK.LPAREN) {
advance();
var expr = parseOr();
if (!peek() || peek().type !== TK.RPAREN) {
throw new Error('Expected closing parenthesis');
}
advance();
return expr;
}
return parseComparison();
}
function parseComparison() {
var t = peek();
if (!t) throw new Error('Unexpected end of expression');
if (t.type !== TK.FIELD) throw new Error("Expected field name, got '" + (t.value || t.type) + "'");
var field = advance().value;
// Check if next token is an operator
var next = peek();
if (!next || next.type === TK.AND || next.type === TK.OR || next.type === TK.RPAREN) {
// Bare field — truthy check
return { type: 'truthy', field: field };
}
if (next.type !== TK.OP) {
throw new Error("Expected operator after '" + field + "', got '" + (next.value || next.type) + "'");
}
var op = advance().value;
// Parse value
var valTok = peek();
if (!valTok) throw new Error("Expected value after '" + field + ' ' + op + "'");
var value;
if (valTok.type === TK.STRING) { value = advance().value; }
else if (valTok.type === TK.NUMBER) { value = advance().value; }
else if (valTok.type === TK.BOOL) { value = advance().value; }
else if (valTok.type === TK.FIELD) {
// Bare word as string value (e.g., ADVERT, FLOOD)
value = advance().value;
}
else { throw new Error("Expected value after '" + field + ' ' + op + "'"); }
return { type: 'comparison', field: field, op: op, value: value };
}
try {
var ast = parseOr();
if (pos < tokens.length) {
throw new Error("Unexpected '" + (tokens[pos].value || tokens[pos].type) + "' at end of expression");
}
return { ast: ast, error: null };
} catch (e) {
return { ast: null, error: e.message };
}
}
// ── Field Resolver ─────────────────────────────────────────────────────────
function resolveField(packet, field) {
if (field === 'type') return FW_PAYLOAD_TYPES[packet.payload_type] || '';
if (field === 'route') return getRT()[packet.route_type] || '';
if (field === 'hash') return packet.hash || '';
if (field === 'raw') return packet.raw_hex || '';
if (field === 'size') return packet.raw_hex ? packet.raw_hex.length / 2 : 0;
if (field === 'snr') return packet.snr;
if (field === 'rssi') return packet.rssi;
if (field === 'hops') {
try { return JSON.parse(packet.path_json || '[]').length; } catch(e) { return 0; }
}
if (field === 'observer') return packet.observer_name || '';
if (field === 'observer_id') return packet.observer_id || '';
if (field === 'observations') return packet.observation_count || 0;
if (field === 'path') {
try { return JSON.parse(packet.path_json || '[]').join(' → '); } catch(e) { return ''; }
}
if (field === 'payload_bytes') {
return packet.raw_hex ? Math.max(0, packet.raw_hex.length / 2 - 2) : 0;
}
if (field === 'payload_hex') {
return packet.raw_hex ? packet.raw_hex.slice(4) : '';
}
// Decoded payload fields (dot notation)
if (field.startsWith('payload.')) {
try {
var decoded = typeof packet.decoded_json === 'string' ? JSON.parse(packet.decoded_json) : packet.decoded_json;
if (decoded == null) return null;
var parts = field.slice(8).split('.');
var val = decoded;
for (var k = 0; k < parts.length; k++) {
if (val == null) return null;
val = val[parts[k]];
}
return val;
} catch(e) { return null; }
}
return null;
}
// ── Evaluator ──────────────────────────────────────────────────────────────
function evaluate(ast, packet) {
if (!ast) return true;
switch (ast.type) {
case 'and': return evaluate(ast.left, packet) && evaluate(ast.right, packet);
case 'or': return evaluate(ast.left, packet) || evaluate(ast.right, packet);
case 'not': return !evaluate(ast.expr, packet);
case 'truthy': {
var v = resolveField(packet, ast.field);
return !!v;
}
case 'comparison': {
var fieldVal = resolveField(packet, ast.field);
var target = ast.value;
var op = ast.op;
if (fieldVal == null || fieldVal === undefined) return false;
// Numeric operators
if (op === '>' || op === '<' || op === '>=' || op === '<=') {
var a = typeof fieldVal === 'number' ? fieldVal : parseFloat(fieldVal);
var b = typeof target === 'number' ? target : parseFloat(target);
if (isNaN(a) || isNaN(b)) return false;
if (op === '>') return a > b;
if (op === '<') return a < b;
if (op === '>=') return a >= b;
return a <= b;
}
// Equality
if (op === '==' || op === '!=') {
var eq;
// Resolve type aliases (e.g., "Channel Msg" → "GRP_TXT")
var resolvedTarget = target;
if (ast.field === 'type' && typeof target === 'string') {
var alias = TYPE_ALIASES[String(target).toLowerCase()];
if (alias) resolvedTarget = alias;
}
if (typeof fieldVal === 'number' && typeof resolvedTarget === 'number') {
eq = fieldVal === resolvedTarget;
} else if (typeof fieldVal === 'boolean' || typeof resolvedTarget === 'boolean') {
eq = fieldVal === resolvedTarget;
} else {
eq = String(fieldVal).toLowerCase() === String(resolvedTarget).toLowerCase();
}
return op === '==' ? eq : !eq;
}
// String operators
var sv = String(fieldVal).toLowerCase();
var tv = String(target).toLowerCase();
if (op === 'contains') return sv.indexOf(tv) !== -1;
if (op === 'starts_with') return sv.indexOf(tv) === 0;
if (op === 'ends_with') return sv.slice(-tv.length) === tv;
return false;
}
default: return false;
}
}
// ── Compile ────────────────────────────────────────────────────────────────
function compile(expression) {
var result = parse(expression);
if (result.error) {
return { filter: function() { return true; }, error: result.error };
}
if (!result.ast) {
return { filter: function() { return true; }, error: null };
}
var ast = result.ast;
return {
filter: function(packet) { return evaluate(ast, packet); },
error: null
};
}
var _exports = { parse: parse, evaluate: evaluate, compile: compile };
if (typeof window !== 'undefined') window.PacketFilter = _exports;
// ── Self-tests (Node.js only) ─────────────────────────────────────────────
if (typeof module !== 'undefined' && module.exports) {
var assert = function(cond, msg) {
if (!cond) throw new Error('FAIL: ' + (msg || ''));
process.stdout.write('.');
};
// Mock window for tests
if (typeof window === 'undefined') {
global.window = { PacketFilter: { parse: parse, evaluate: evaluate, compile: compile } };
}
var c;
// Basic comparison — type == Advert (payload_type 4)
c = compile('type == Advert');
assert(!c.error, 'no error');
assert(c.filter({ payload_type: 4 }), 'type == Advert');
assert(!c.filter({ payload_type: 1 }), 'type != Advert');
// Case insensitive
c = compile('type == advert');
assert(c.filter({ payload_type: 4 }), 'case insensitive');
// Numeric
c = compile('snr > 5');
assert(c.filter({ snr: 8 }), 'snr > 5 pass');
assert(!c.filter({ snr: 3 }), 'snr > 5 fail');
// Negative number
c = compile('snr < -5');
assert(c.filter({ snr: -10 }), 'snr < -5');
assert(!c.filter({ snr: 0 }), 'snr not < -5');
// Contains
c = compile('payload.name contains "Gilroy"');
assert(c.filter({ decoded_json: '{"name":"ESP1 Gilroy Repeater"}' }), 'contains');
assert(!c.filter({ decoded_json: '{"name":"SFO Node"}' }), 'not contains');
// AND/OR
c = compile('type == Advert && snr > 5');
assert(c.filter({ payload_type: 4, snr: 8 }), 'AND pass');
assert(!c.filter({ payload_type: 4, snr: 2 }), 'AND fail');
c = compile('snr > 100 || rssi > -50');
assert(c.filter({ snr: 1, rssi: -30 }), 'OR pass');
assert(!c.filter({ snr: 1, rssi: -200 }), 'OR fail');
// Bare field truthy
c = compile('payload.flags.hasLocation');
assert(c.filter({ decoded_json: '{"flags":{"hasLocation":true}}' }), 'truthy true');
assert(!c.filter({ decoded_json: '{"flags":{"hasLocation":false}}' }), 'truthy false');
// NOT
c = compile('!type == Advert');
assert(!c.filter({ payload_type: 4 }), 'NOT advert');
assert(c.filter({ payload_type: 1 }), 'NOT non-advert');
// Hops
c = compile('hops > 2');
assert(c.filter({ path_json: '["a","b","c"]' }), 'hops > 2');
assert(!c.filter({ path_json: '["a"]' }), 'hops not > 2');
// starts_with
c = compile('hash starts_with "8a91"');
assert(c.filter({ hash: '8a91bf33' }), 'starts_with');
assert(!c.filter({ hash: 'deadbeef' }), 'not starts_with');
// Parentheses
c = compile('(type == Advert || type == ACK) && snr > 0');
assert(c.filter({ payload_type: 4, snr: 5 }), 'parens');
assert(!c.filter({ payload_type: 4, snr: -1 }), 'parens fail');
// Error handling
c = compile('invalid @@@ garbage');
assert(c.error !== null, 'error on bad input');
// Null field values
c = compile('snr > 5');
assert(!c.filter({}), 'null field');
// Size
c = compile('size > 10');
assert(c.filter({ raw_hex: 'aabbccddee112233445566778899001122' }), 'size');
// Observer
c = compile('observer == "kpabap"');
assert(c.filter({ observer_name: 'kpabap' }), 'observer');
console.log('\nAll tests passed!');
module.exports = { parse: parse, evaluate: evaluate, compile: compile };
}
})();
+137 -880
View File
File diff suppressed because it is too large Load Diff
+1 -76
View File
@@ -18,91 +18,16 @@
Promise.resolve(window.apiPerf ? window.apiPerf() : null)
]);
// Also fetch health telemetry
const health = await fetch('/api/health').then(r => r.json()).catch(() => null);
let html = '';
// Server overview
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:16px 0;">
<div class="perf-card"><div class="perf-num">${server.totalRequests}</div><div class="perf-label">Total Requests</div></div>
<div class="perf-card"><div class="perf-num">${server.avgMs}ms</div><div class="perf-label">Avg Response</div></div>
<div class="perf-card"><div class="perf-num">${health ? health.uptimeHuman : Math.round(server.uptime / 60) + 'm'}</div><div class="perf-label">Uptime</div></div>
<div class="perf-card"><div class="perf-num">${Math.round(server.uptime / 60)}m</div><div class="perf-label">Uptime</div></div>
<div class="perf-card"><div class="perf-num">${server.slowQueries.length}</div><div class="perf-label">Slow (&gt;100ms)</div></div>
</div>`;
// System health (memory, event loop, WS)
if (health) {
const m = health.memory, el = health.eventLoop;
const elColor = el.p95Ms > 500 ? 'var(--status-red)' : el.p95Ms > 100 ? 'var(--status-yellow)' : 'var(--status-green)';
const memColor = m.heapUsed > m.heapTotal * 0.85 ? 'var(--status-red)' : m.heapUsed > m.heapTotal * 0.7 ? 'var(--status-yellow)' : 'var(--status-green)';
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
<div class="perf-card"><div class="perf-num" style="color:${elColor}">${el.p95Ms}ms</div><div class="perf-label">Event Loop p95</div></div>
<div class="perf-card"><div class="perf-num">${el.maxLagMs}ms</div><div class="perf-label">EL Max Lag</div></div>
<div class="perf-card"><div class="perf-num">${el.currentLagMs}ms</div><div class="perf-label">EL Current</div></div>
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
</div>`;
}
// Cache stats
if (server.cache) {
const c = server.cache;
const clientCache = _apiCache ? _apiCache.size : 0;
html += `<h3>Cache</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${c.size}</div><div class="perf-label">Server Entries</div></div>
<div class="perf-card"><div class="perf-num">${c.hits}</div><div class="perf-label">Server Hits</div></div>
<div class="perf-card"><div class="perf-num">${c.misses}</div><div class="perf-label">Server Misses</div></div>
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? 'var(--status-green)' : c.hitRate > 20 ? 'var(--status-yellow)' : 'var(--status-red)'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
<div class="perf-card"><div class="perf-num">${c.staleHits || 0}</div><div class="perf-label">Stale Hits (SWR)</div></div>
<div class="perf-card"><div class="perf-num">${c.recomputes || 0}</div><div class="perf-label">Recomputes</div></div>
<div class="perf-card"><div class="perf-num">${clientCache}</div><div class="perf-label">Client Entries</div></div>
</div>`;
if (client) {
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${client.cacheHits || 0}</div><div class="perf-label">Client Hits</div></div>
<div class="perf-card"><div class="perf-num">${client.cacheMisses || 0}</div><div class="perf-label">Client Misses</div></div>
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? 'var(--status-green)' : 'var(--status-yellow)'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
</div>`;
}
}
// Packet Store stats
if (server.packetStore) {
const ps = server.packetStore;
html += `<h3>In-Memory Packet Store</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${ps.inMemory.toLocaleString()}</div><div class="perf-label">Packets in RAM</div></div>
<div class="perf-card"><div class="perf-num">${ps.estimatedMB}MB</div><div class="perf-label">Memory Used</div></div>
<div class="perf-card"><div class="perf-num">${ps.maxMB}MB</div><div class="perf-label">Memory Limit</div></div>
<div class="perf-card"><div class="perf-num">${ps.queries.toLocaleString()}</div><div class="perf-label">Queries Served</div></div>
<div class="perf-card"><div class="perf-num">${ps.inserts.toLocaleString()}</div><div class="perf-label">Live Inserts</div></div>
<div class="perf-card"><div class="perf-num">${ps.evicted.toLocaleString()}</div><div class="perf-label">Evicted</div></div>
<div class="perf-card"><div class="perf-num">${ps.indexes.byHash.toLocaleString()}</div><div class="perf-label">Unique Hashes</div></div>
<div class="perf-card"><div class="perf-num">${ps.indexes.byObserver}</div><div class="perf-label">Observers</div></div>
<div class="perf-card"><div class="perf-num">${ps.indexes.byNode.toLocaleString()}</div><div class="perf-label">Indexed Nodes</div></div>
</div>`;
}
// SQLite stats
if (server.sqlite && !server.sqlite.error) {
const sq = server.sqlite;
const walColor = sq.walSizeMB > 50 ? 'var(--status-red)' : sq.walSizeMB > 10 ? 'var(--status-yellow)' : 'var(--status-green)';
const freelistColor = sq.freelistMB > 10 ? 'var(--status-yellow)' : 'var(--status-green)';
html += `<h3>SQLite</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${sq.dbSizeMB}MB</div><div class="perf-label">DB Size</div></div>
<div class="perf-card"><div class="perf-num" style="color:${walColor}">${sq.walSizeMB}MB</div><div class="perf-label">WAL Size</div></div>
<div class="perf-card"><div class="perf-num" style="color:${freelistColor}">${sq.freelistMB}MB</div><div class="perf-label">Freelist</div></div>
<div class="perf-card"><div class="perf-num">${(sq.rows.transmissions || 0).toLocaleString()}</div><div class="perf-label">Transmissions</div></div>
<div class="perf-card"><div class="perf-num">${(sq.rows.observations || 0).toLocaleString()}</div><div class="perf-label">Observations</div></div>
<div class="perf-card"><div class="perf-num">${sq.rows.nodes || 0}</div><div class="perf-label">Nodes</div></div>
<div class="perf-card"><div class="perf-num">${sq.rows.observers || 0}</div><div class="perf-label">Observers</div></div>`;
if (sq.walPages) {
html += `<div class="perf-card"><div class="perf-num">${sq.walPages.busy}</div><div class="perf-label">WAL Busy Pages</div></div>`;
}
html += `</div>`;
}
// Server endpoints table
const eps = Object.entries(server.endpoints);
if (eps.length) {
-218
View File
@@ -1,218 +0,0 @@
/* === MeshCore Analyzer — region-filter.js (shared region filter component) === */
'use strict';
(function () {
var LS_KEY = 'meshcore-region-filter';
var _regions = {}; // { code: label }
var _selected = null; // Set of selected region codes, null = all
var _listeners = [];
var _loaded = false;
function loadFromStorage() {
try {
var stored = JSON.parse(localStorage.getItem(LS_KEY));
if (Array.isArray(stored) && stored.length > 0) return new Set(stored);
} catch (e) { /* ignore */ }
return null; // null = all selected
}
function saveToStorage() {
if (!_selected) {
localStorage.removeItem(LS_KEY);
} else {
localStorage.setItem(LS_KEY, JSON.stringify(Array.from(_selected)));
}
}
_selected = loadFromStorage();
/** Fetch regions from server */
async function fetchRegions() {
if (_loaded) return _regions;
try {
var data = await fetch('/api/config/regions').then(function (r) { return r.json(); });
_regions = data || {};
_loaded = true;
// If stored selection has codes no longer valid, clean up
if (_selected) {
var codes = Object.keys(_regions);
var cleaned = new Set();
_selected.forEach(function (c) { if (codes.includes(c)) cleaned.add(c); });
_selected = cleaned.size > 0 ? cleaned : null;
saveToStorage();
}
} catch (e) {
_regions = {};
}
return _regions;
}
/** Get selected regions as array, or null if all */
function getSelected() {
if (!_selected || _selected.size === 0) return null;
return Array.from(_selected);
}
/** Get region query param string for API calls: "SJC,SFO" or empty */
function getRegionParam() {
var sel = getSelected();
return sel ? sel.join(',') : '';
}
/** Build query string fragment: "&region=SJC,SFO" or "" */
function regionQueryString() {
var p = getRegionParam();
return p ? '&region=' + encodeURIComponent(p) : '';
}
/** Handle a region toggle (shared logic for both pill and dropdown modes) */
function toggleRegion(region, codes, container) {
if (region === '__all__') {
_selected = null;
} else {
if (!_selected) {
_selected = new Set([region]);
} else if (_selected.has(region)) {
_selected.delete(region);
if (_selected.size === 0) _selected = null;
} else {
_selected.add(region);
}
if (_selected && _selected.size === codes.length) _selected = null;
}
saveToStorage();
render(container);
_listeners.forEach(function (fn) { fn(getSelected()); });
}
/** Build summary label for dropdown trigger */
function dropdownLabel(codes) {
if (!_selected) return 'All Regions';
var sel = Array.from(_selected);
if (sel.length === 0) return 'All Regions';
if (sel.length <= 2) return sel.join(', ');
return sel.length + ' Regions';
}
/** Render pill bar mode (≤4 regions) */
function renderPills(container, codes) {
var allSelected = !_selected;
var html = '<div class="region-filter-bar" role="group" aria-label="Region filter">';
html += '<span class="region-filter-label" id="region-filter-label">Region:</span>';
html += '<button class="region-pill' + (allSelected ? ' region-pill-active' : '') +
'" data-region="__all__" role="checkbox" aria-checked="' + allSelected + '">All</button>';
codes.forEach(function (code) {
var label = _regions[code] || code;
var active = allSelected || (_selected && _selected.has(code));
html += '<button class="region-pill' + (active ? ' region-pill-active' : '') +
'" data-region="' + code + '" role="checkbox" aria-checked="' + !!active + '">' + label + '</button>';
});
html += '</div>';
container.innerHTML = html;
container.onclick = function (e) {
var btn = e.target.closest('[data-region]');
if (!btn) return;
toggleRegion(btn.dataset.region, codes, container);
};
}
/** Render dropdown mode (>4 regions) */
function renderDropdown(container, codes) {
var allSelected = !_selected;
var html = '<div class="region-dropdown-wrap" role="group" aria-label="Region filter">';
html += '<button class="region-dropdown-trigger" aria-haspopup="listbox" aria-expanded="false">' +
dropdownLabel(codes) + ' ▾</button>';
html += '<div class="region-dropdown-menu" role="listbox" aria-label="Select regions" hidden>';
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="__all__"' +
(allSelected ? ' checked' : '') + '> <strong>All</strong></label>';
codes.forEach(function (code) {
var configLabel = _regions[code];
var cityName = configLabel || (window.IATA_CITIES && window.IATA_CITIES[code]);
var label = cityName ? (code + ' - ' + cityName) : code;
var active = allSelected || (_selected && _selected.has(code));
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="' + code + '"' +
(active ? ' checked' : '') + '> ' + label + '</label>';
});
html += '</div></div>';
container.innerHTML = html;
var trigger = container.querySelector('.region-dropdown-trigger');
var menu = container.querySelector('.region-dropdown-menu');
trigger.onclick = function () {
var open = !menu.hidden;
menu.hidden = open;
trigger.setAttribute('aria-expanded', String(!open));
};
menu.onchange = function (e) {
var input = e.target;
if (!input.dataset.region) return;
toggleRegion(input.dataset.region, codes, container);
};
// Close on outside click
function onDocClick(e) {
if (!container.contains(e.target)) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
}
}
document.addEventListener('click', onDocClick, true);
container._regionCleanup = function () {
document.removeEventListener('click', onDocClick, true);
};
}
/** Render the filter bar into a container element */
function render(container) {
// Clean up previous outside-click listener if any
if (container._regionCleanup) { container._regionCleanup(); container._regionCleanup = null; }
var codes = Object.keys(_regions);
if (codes.length < 2) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
container.style.display = '';
if (codes.length > 4 || container._forceDropdown) {
renderDropdown(container, codes);
} else {
renderPills(container, codes);
}
}
/** Subscribe to selection changes. Callback receives selected array or null */
function onChange(fn) {
_listeners.push(fn);
return fn;
}
/** Unsubscribe */
function offChange(fn) {
_listeners = _listeners.filter(function (f) { return f !== fn; });
}
/** Initialize filter in a container, fetch regions, render, return promise.
* Options: { dropdown: true } to force dropdown mode regardless of region count */
async function initFilter(container, opts) {
if (opts && opts.dropdown) container._forceDropdown = true;
await fetchRegions();
render(container);
}
// Expose globally
window.RegionFilter = {
init: initFilter,
render: render,
getSelected: getSelected,
getRegionParam: getRegionParam,
regionQueryString: regionQueryString,
onChange: onChange,
offChange: offChange,
fetchRegions: fetchRegions
};
})();
-366
View File
@@ -1,366 +0,0 @@
/* === MeshCore Analyzer — roles.js (shared config module) === */
'use strict';
/*
* Centralized roles, thresholds, tile URLs, and UI constants.
* Loaded BEFORE all page scripts via index.html.
* Defaults are set synchronously; server config overrides arrive via fetch.
*/
(function () {
// ─── Role definitions ───
window.ROLE_COLORS = {
repeater: '#dc2626', companion: '#2563eb', room: '#16a34a',
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
};
window.TYPE_COLORS = {
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
ANON_REQ: '#f43f5e', UNKNOWN: '#6b7280'
};
// Badge CSS class name mapping
const TYPE_BADGE_MAP = {
ADVERT: 'advert', GRP_TXT: 'grp-txt', TXT_MSG: 'txt-msg', ACK: 'ack',
REQUEST: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path',
ANON_REQ: 'anon-req', UNKNOWN: 'unknown'
};
// Generate badge CSS from TYPE_COLORS — single source of truth
window.syncBadgeColors = function() {
var el = document.getElementById('type-color-badges');
if (!el) { el = document.createElement('style'); el.id = 'type-color-badges'; document.head.appendChild(el); }
var css = '';
for (var type in TYPE_BADGE_MAP) {
var color = window.TYPE_COLORS[type];
if (!color) continue;
var cls = TYPE_BADGE_MAP[type];
css += '.badge-' + cls + ' { background: ' + color + '20; color: ' + color + '; }\n';
}
el.textContent = css;
};
// Auto-sync on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', window.syncBadgeColors);
} else {
window.syncBadgeColors();
}
window.ROLE_LABELS = {
repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers',
sensor: 'Sensors', observer: 'Observers'
};
window.ROLE_STYLE = {
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 },
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 },
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 },
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }
};
window.ROLE_EMOJI = {
repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★'
};
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
// ─── Health thresholds (ms) ───
window.HEALTH_THRESHOLDS = {
infraDegradedMs: 86400000, // 24h
infraSilentMs: 259200000, // 72h
nodeDegradedMs: 3600000, // 1h
nodeSilentMs: 86400000 // 24h
};
// Helper: get degraded/silent thresholds for a role (backward compat)
window.getHealthThresholds = function (role) {
var isInfra = role === 'repeater' || role === 'room';
return {
degradedMs: isInfra ? HEALTH_THRESHOLDS.infraDegradedMs : HEALTH_THRESHOLDS.nodeDegradedMs,
silentMs: isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs
};
};
// Simplified two-state helper: returns 'active' or 'stale'
window.getNodeStatus = function (role, lastSeenMs) {
var isInfra = role === 'repeater' || role === 'room';
var staleMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;
var age = typeof lastSeenMs === 'number' ? (Date.now() - lastSeenMs) : Infinity;
return age < staleMs ? 'active' : 'stale';
};
// ─── Tile URLs ───
window.TILE_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
window.TILE_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
window.getTileUrl = function () {
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
return isDark ? TILE_DARK : TILE_LIGHT;
};
// ─── SNR thresholds ───
window.SNR_THRESHOLDS = { excellent: 6, good: 0 };
// ─── Distance thresholds (km) ───
window.DIST_THRESHOLDS = { local: 50, regional: 200 };
// ─── MAX_HOP_DIST (degrees, ~200km ≈ 1.8°) ───
window.MAX_HOP_DIST = 1.8;
// ─── Result limits ───
window.LIMITS = {
topNodes: 15,
topPairs: 12,
topRingNodes: 8,
topSenders: 10,
topCollisionNodes: 10,
recentReplay: 8,
feedMax: 25
};
// ─── Performance thresholds ───
window.PERF_SLOW_MS = 100;
// ─── WebSocket reconnect delay (ms) ───
window.WS_RECONNECT_MS = 3000;
// ─── Propagation buffer (ms) for realistic mode ───
window.PROPAGATION_BUFFER_MS = 5000;
// ─── Cache invalidation debounce (ms) ───
window.CACHE_INVALIDATE_MS = 5000;
// ─── External URLs ───
window.EXTERNAL_URLS = {
flasher: 'https://flasher.meshcore.co.uk/'
};
// ─── Fetch server overrides ───
window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) {
if (cfg.roles) {
if (cfg.roles.colors) Object.assign(ROLE_COLORS, cfg.roles.colors);
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
if (cfg.roles.style) {
for (var k in cfg.roles.style) ROLE_STYLE[k] = Object.assign(ROLE_STYLE[k] || {}, cfg.roles.style[k]);
}
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
}
if (cfg.healthThresholds) Object.assign(HEALTH_THRESHOLDS, cfg.healthThresholds);
if (cfg.tiles) {
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
}
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
if (cfg.limits) Object.assign(LIMITS, cfg.limits);
if (cfg.perfSlowMs != null) window.PERF_SLOW_MS = cfg.perfSlowMs;
if (cfg.wsReconnectMs != null) window.WS_RECONNECT_MS = cfg.wsReconnectMs;
if (cfg.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
// Sync ROLE_STYLE colors with ROLE_COLORS
for (var role in ROLE_STYLE) {
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
}
}).catch(function () { /* use defaults */ });
// ─── Built-in IATA airport code → city name mapping ───
window.IATA_CITIES = {
// United States
'SEA': 'Seattle, WA',
'SFO': 'San Francisco, CA',
'PDX': 'Portland, OR',
'LAX': 'Los Angeles, CA',
'DEN': 'Denver, CO',
'SLC': 'Salt Lake City, UT',
'PHX': 'Phoenix, AZ',
'DFW': 'Dallas, TX',
'ATL': 'Atlanta, GA',
'ORD': 'Chicago, IL',
'JFK': 'New York, NY',
'LGA': 'New York, NY',
'BOS': 'Boston, MA',
'MIA': 'Miami, FL',
'FLL': 'Fort Lauderdale, FL',
'IAH': 'Houston, TX',
'HOU': 'Houston, TX',
'MSP': 'Minneapolis, MN',
'DTW': 'Detroit, MI',
'CLT': 'Charlotte, NC',
'EWR': 'Newark, NJ',
'IAD': 'Washington, DC',
'DCA': 'Washington, DC',
'BWI': 'Baltimore, MD',
'LAS': 'Las Vegas, NV',
'MCO': 'Orlando, FL',
'TPA': 'Tampa, FL',
'BNA': 'Nashville, TN',
'AUS': 'Austin, TX',
'SAT': 'San Antonio, TX',
'RDU': 'Raleigh, NC',
'SAN': 'San Diego, CA',
'OAK': 'Oakland, CA',
'SJC': 'San Jose, CA',
'SMF': 'Sacramento, CA',
'PHL': 'Philadelphia, PA',
'PIT': 'Pittsburgh, PA',
'CLE': 'Cleveland, OH',
'CMH': 'Columbus, OH',
'CVG': 'Cincinnati, OH',
'IND': 'Indianapolis, IN',
'MCI': 'Kansas City, MO',
'STL': 'St. Louis, MO',
'MSY': 'New Orleans, LA',
'MEM': 'Memphis, TN',
'SDF': 'Louisville, KY',
'JAX': 'Jacksonville, FL',
'RIC': 'Richmond, VA',
'ORF': 'Norfolk, VA',
'BDL': 'Hartford, CT',
'PVD': 'Providence, RI',
'ABQ': 'Albuquerque, NM',
'OKC': 'Oklahoma City, OK',
'TUL': 'Tulsa, OK',
'OMA': 'Omaha, NE',
'BOI': 'Boise, ID',
'GEG': 'Spokane, WA',
'ANC': 'Anchorage, AK',
'HNL': 'Honolulu, HI',
'OGG': 'Maui, HI',
'BUF': 'Buffalo, NY',
'SYR': 'Syracuse, NY',
'ROC': 'Rochester, NY',
'ALB': 'Albany, NY',
'BTV': 'Burlington, VT',
'PWM': 'Portland, ME',
'MKE': 'Milwaukee, WI',
'DSM': 'Des Moines, IA',
'LIT': 'Little Rock, AR',
'BHM': 'Birmingham, AL',
'CHS': 'Charleston, SC',
'SAV': 'Savannah, GA',
// Canada
'YVR': 'Vancouver, BC',
'YYZ': 'Toronto, ON',
'YUL': 'Montreal, QC',
'YOW': 'Ottawa, ON',
'YYC': 'Calgary, AB',
'YEG': 'Edmonton, AB',
'YWG': 'Winnipeg, MB',
'YHZ': 'Halifax, NS',
'YQB': 'Quebec City, QC',
// Europe
'LHR': 'London, UK',
'LGW': 'London, UK',
'STN': 'London, UK',
'CDG': 'Paris, FR',
'ORY': 'Paris, FR',
'FRA': 'Frankfurt, DE',
'MUC': 'Munich, DE',
'BER': 'Berlin, DE',
'AMS': 'Amsterdam, NL',
'MAD': 'Madrid, ES',
'BCN': 'Barcelona, ES',
'FCO': 'Rome, IT',
'MXP': 'Milan, IT',
'ZRH': 'Zurich, CH',
'GVA': 'Geneva, CH',
'VIE': 'Vienna, AT',
'CPH': 'Copenhagen, DK',
'ARN': 'Stockholm, SE',
'OSL': 'Oslo, NO',
'HEL': 'Helsinki, FI',
'DUB': 'Dublin, IE',
'LIS': 'Lisbon, PT',
'ATH': 'Athens, GR',
'IST': 'Istanbul, TR',
'WAW': 'Warsaw, PL',
'PRG': 'Prague, CZ',
'BUD': 'Budapest, HU',
'OTP': 'Bucharest, RO',
'SOF': 'Sofia, BG',
'ZAG': 'Zagreb, HR',
'BEG': 'Belgrade, RS',
'KBP': 'Kyiv, UA',
'LED': 'St. Petersburg, RU',
'SVO': 'Moscow, RU',
'BRU': 'Brussels, BE',
'EDI': 'Edinburgh, UK',
'MAN': 'Manchester, UK',
// Asia
'NRT': 'Tokyo, JP',
'HND': 'Tokyo, JP',
'KIX': 'Osaka, JP',
'ICN': 'Seoul, KR',
'PEK': 'Beijing, CN',
'PVG': 'Shanghai, CN',
'HKG': 'Hong Kong',
'TPE': 'Taipei, TW',
'SIN': 'Singapore',
'BKK': 'Bangkok, TH',
'KUL': 'Kuala Lumpur, MY',
'CGK': 'Jakarta, ID',
'MNL': 'Manila, PH',
'DEL': 'New Delhi, IN',
'BOM': 'Mumbai, IN',
'BLR': 'Bangalore, IN',
'CCU': 'Kolkata, IN',
'SGN': 'Ho Chi Minh City, VN',
'HAN': 'Hanoi, VN',
'DOH': 'Doha, QA',
'DXB': 'Dubai, AE',
'AUH': 'Abu Dhabi, AE',
'TLV': 'Tel Aviv, IL',
// Oceania
'SYD': 'Sydney, AU',
'MEL': 'Melbourne, AU',
'BNE': 'Brisbane, AU',
'PER': 'Perth, AU',
'AKL': 'Auckland, NZ',
'WLG': 'Wellington, NZ',
'CHC': 'Christchurch, NZ',
// South America
'GRU': 'São Paulo, BR',
'GIG': 'Rio de Janeiro, BR',
'EZE': 'Buenos Aires, AR',
'SCL': 'Santiago, CL',
'BOG': 'Bogota, CO',
'LIM': 'Lima, PE',
'UIO': 'Quito, EC',
'CCS': 'Caracas, VE',
'MVD': 'Montevideo, UY',
// Africa
'JNB': 'Johannesburg, ZA',
'CPT': 'Cape Town, ZA',
'CAI': 'Cairo, EG',
'NBO': 'Nairobi, KE',
'ADD': 'Addis Ababa, ET',
'CMN': 'Casablanca, MA',
'LOS': 'Lagos, NG'
};
// Simple markdown → HTML (bold, italic, links, code, lists, line breaks)
window.miniMarkdown = function(text) {
if (!text) return '';
var html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--accent)">$1</a>')
.replace(/^- (.+)/gm, '<li>$1</li>')
.replace(/\n/g, '<br>');
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li>.*?<\/li><br>?)+)/g, function(m) {
return '<ul>' + m.replace(/<br>/g, '') + '</ul>';
});
return html;
};
})();
+69 -356
View File
@@ -3,12 +3,7 @@
:root {
--nav-bg: #0f0f23;
--nav-bg2: #1a1a2e;
--nav-text: #ffffff;
--nav-text-muted: #cbd5e1;
--accent: #4a9eff;
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
--accent-hover: #6db3ff;
--text: #1a1a2e;
--text-muted: #5b6370;
@@ -35,9 +30,6 @@
When changing dark theme variables, update BOTH blocks below. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
--surface-0: #0f0f23;
--surface-1: #1a1a2e;
--surface-2: #232340;
@@ -58,9 +50,6 @@
}
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
[data-theme="dark"] {
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
--surface-0: #0f0f23;
--surface-1: #1a1a2e;
--surface-2: #232340;
@@ -98,15 +87,15 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
/* === Nav === */
.top-nav {
display: flex; align-items: center; justify-content: space-between;
background: linear-gradient(135deg, var(--nav-bg) 0%, var(--nav-bg2) 100%); color: var(--nav-text); padding: 0 20px; height: 52px;
background: linear-gradient(135deg, #0f0f23 0%, #151532 50%, #1a1035 100%); color: #fff; padding: 0 20px; height: 52px;
position: sticky; top: 0; z-index: 1100;
box-shadow: 0 2px 8px rgba(0,0,0,.3);
}
.nav-left { display: flex; align-items: center; gap: 24px; }
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--nav-text); font-weight: 700; font-size: 16px; }
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: #fff; font-weight: 700; font-size: 16px; }
.brand-icon { font-size: 20px; }
.live-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted);
width: 8px; height: 8px; border-radius: 50%; background: #555;
display: inline-block; margin-left: 4px; transition: background .3s;
}
@keyframes pulse-ring {
@@ -114,18 +103,18 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
.live-dot.connected { background: var(--status-green); animation: pulse-ring 2s ease-out infinite; }
.live-dot.connected { background: #22c55e; animation: pulse-ring 2s ease-out infinite; }
.nav-links { display: flex; align-items: center; gap: 4px; }
.nav-link {
color: var(--nav-text-muted); text-decoration: none; padding: 14px 12px; font-size: 14px;
color: #cbd5e1; text-decoration: none; padding: 14px 12px; font-size: 14px;
border-bottom: 2px solid transparent; transition: all .15s;
background: none; border-top: none; border-left: none; border-right: none;
cursor: pointer; font-family: var(--font);
}
.nav-link:hover { color: var(--nav-text); }
.nav-link:hover { color: #fff; }
.nav-link.active {
color: var(--nav-text);
color: #fff;
border-bottom-color: transparent;
background: rgba(74, 158, 255, 0.15);
border-radius: 6px;
@@ -136,28 +125,28 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
.nav-dropdown { position: relative; }
.dropdown-menu {
display: none; position: absolute; top: 100%; left: 0;
background: var(--nav-bg2); border: 1px solid var(--border); border-radius: 6px;
background: var(--nav-bg2); border: 1px solid #333; border-radius: 6px;
min-width: 140px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.nav-dropdown:hover .dropdown-menu { display: block; }
.dropdown-item {
display: block; padding: 8px 16px; color: var(--text-muted); text-decoration: none; font-size: 13px;
display: block; padding: 8px 16px; color: #cbd5e1; text-decoration: none; font-size: 13px;
}
.dropdown-item:hover { background: var(--accent); color: #fff; }
.nav-right { display: flex; align-items: center; gap: 8px; }
.nav-btn {
background: none; border: 1px solid var(--border); color: var(--nav-text-muted); padding: 6px 12px;
background: none; border: 1px solid #444; color: #cbd5e1; padding: 6px 12px;
border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;
min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
}
.nav-btn:hover { background: var(--nav-bg2); color: var(--nav-text); }
.nav-btn:hover { background: #333; color: #fff; }
/* === Nav Stats === */
.nav-stats {
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
display: flex; gap: 12px; align-items: center; font-size: 12px; color: #94a3b8;
font-family: var(--mono); margin-right: 4px;
}
.nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; }
.nav-stats .stat-val { color: #e2e8f0; font-weight: 600; transition: color 0.3s ease; }
.nav-stats .stat-val.updated { color: var(--accent); }
/* === Layout === */
@@ -199,37 +188,22 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; align-items: center;
}
.filter-bar input, .filter-bar select {
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
font-size: 13px; background: var(--input-bg); color: var(--text); font-family: var(--font);
height: 34px; box-sizing: border-box; line-height: 1;
padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px;
font-size: 12px; background: var(--input-bg); color: var(--text); font-family: var(--font);
}
.filter-bar input { width: 120px; }
.filter-bar select { min-width: 90px; }
.filter-bar .btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
background: var(--input-bg); cursor: pointer; font-size: 13px; transition: all .15s;
font-family: var(--font); color: var(--text); height: 34px; box-sizing: border-box; line-height: 1;
font-family: var(--font); color: var(--text);
}
.filter-group { display: flex; gap: 6px; align-items: center; }
.filter-group .btn { padding: 4px 10px; font-size: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); cursor: pointer; transition: background 0.15s, color 0.15s; }
.filter-group .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.filter-group .btn:hover:not(.active) { background: var(--surface-2); }
.filter-group + .filter-group { border-left: 1px solid var(--border); padding-left: 12px; margin-left: 6px; }
.sort-help { cursor: help; font-size: 14px; color: var(--text-muted, #888); position: relative; display: inline-block; }
.sort-help-tip {
display: none; position: absolute; top: 130%; left: 50%; transform: translateX(-50%);
background: var(--card-bg, #222); color: var(--text, #eee); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.5;
white-space: pre-line; width: 260px; z-index: 100;
box-shadow: 0 4px 12px rgba(0,0,0,.3); pointer-events: none;
}
.sort-help:hover .sort-help-tip { display: block; }
.filter-bar .btn:hover { background: var(--row-hover); }
.filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-icon {
background: none; border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 6px 10px; cursor: pointer; font-size: 14px; transition: all .15s;
padding: 6px 10px; cursor: pointer; font-size: 14px; transition: all .15s;
}
.btn-icon:hover { background: var(--row-hover); }
@@ -252,7 +226,6 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
max-width: 0; /* forces td to respect table width instead of expanding to content */
}
.data-table td.col-details { white-space: normal; word-break: break-word; }
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
.data-table tbody tr:nth-child(even) { background: var(--row-stripe); }
.data-table tbody tr:hover { background: var(--row-hover); cursor: pointer; }
.data-table tbody tr.selected { background: var(--selected-bg); }
@@ -262,16 +235,32 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
display: inline-block; padding: 2px 8px; border-radius: var(--badge-radius);
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px;
}
.badge-advert { background: #dcfce7; color: #166534; }
.badge-grp-txt { background: #dbeafe; color: #1e40af; }
.badge-ack { background: #f3f4f6; color: var(--text-muted); }
.badge-req { background: #ffedd5; color: #9a3412; }
.badge-txt-msg { background: #f3e8ff; color: #7e22ce; }
.badge-trace { background: #cffafe; color: #0e7490; }
.badge-path { background: #fef9c3; color: #a16207; }
.badge-response { background: #e0e7ff; color: #3730a3; }
.badge-anon-req { background: #fce7f3; color: #9d174d; }
.badge-unknown { background: #f3f4f6; color: var(--text-muted); }
[data-theme="dark"] .badge-advert { background: #166534; color: #86efac; }
[data-theme="dark"] .badge-grp-txt { background: #1e3a5f; color: #93c5fd; }
[data-theme="dark"] .badge-ack { background: #374151; color: #d1d5db; }
[data-theme="dark"] .badge-req { background: #7c2d12; color: #fdba74; }
[data-theme="dark"] .badge-txt-msg { background: #581c87; color: #d8b4fe; }
[data-theme="dark"] .badge-trace { background: #164e63; color: #67e8f9; }
[data-theme="dark"] .badge-path { background: #713f12; color: #fde68a; }
[data-theme="dark"] .badge-response { background: #312e81; color: #a5b4fc; }
[data-theme="dark"] .badge-anon-req { background: #831843; color: #f9a8d4; }
[data-theme="dark"] .badge-unknown { background: #374151; color: #d1d5db; }
.badge-region {
display: inline-block; padding: 2px 6px; border-radius: 4px;
font-size: 10px; font-weight: 700; font-family: var(--mono);
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
}
.badge-obs {
display: inline-block; padding: 1px 6px; border-radius: 10px;
font-size: 10px; font-weight: 600;
background: #ede9fe; color: #6d28d9;
background: var(--nav-bg); color: #fff; letter-spacing: .5px;
}
/* === Monospace === */
@@ -327,7 +316,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 12px;
}
.field-table th {
text-align: left; padding: 6px 8px; background: var(--nav-bg); color: var(--nav-text);
text-align: left; padding: 6px 8px; background: var(--nav-bg); color: #fff;
font-size: 11px; text-transform: uppercase; letter-spacing: .3px;
}
.field-table td {
@@ -654,7 +643,7 @@ button.ch-item.selected { background: var(--selected-bg); }
border-radius: 4px; font-family: var(--mono); font-size: 12px; font-weight: 600;
}
.trace-path-arrow { color: var(--text-muted); font-size: 16px; }
.trace-path-label { color: var(--text-muted); font-size: 12px; font-style: italic; }
.trace-path-label { color: #94a3b8; font-size: 12px; font-style: italic; }
.trace-path-info { font-size: 12px; color: var(--text-muted); }
/* Timeline */
@@ -680,31 +669,29 @@ button.ch-item.selected { background: var(--selected-bg); }
}
.tl-delta { font-size: 11px; color: var(--text-muted); text-align: right; }
.tl-snr { font-size: 12px; font-weight: 600; text-align: right; }
.tl-snr.good { color: var(--status-green); }
.tl-snr.ok { color: var(--status-yellow); }
.tl-snr.bad { color: var(--status-red); }
.tl-snr.good { color: #16a34a; }
.tl-snr.ok { color: #ca8a04; }
.tl-snr.bad { color: #dc2626; }
.tl-rssi { font-size: 12px; color: var(--text-muted); text-align: right; }
/* === Scrollbar === */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* === Observers Page === */
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.health-dot.health-green { background: var(--status-green); box-shadow: 0 0 6px #22c55e80; }
.health-dot.health-yellow { background: var(--status-yellow); box-shadow: 0 0 6px #eab30880; }
.health-dot.health-red { background: var(--status-red); box-shadow: 0 0 6px #ef444480; }
.health-dot.health-green { background: #22c55e; box-shadow: 0 0 6px #22c55e80; }
.health-dot.health-yellow { background: #eab308; box-shadow: 0 0 6px #eab30880; }
.health-dot.health-red { background: #ef4444; box-shadow: 0 0 6px #ef444480; }
.obs-table td:first-child { white-space: nowrap; }
.obs-table td:nth-child(6) { max-width: none; overflow: visible; }
.col-observer { min-width: 70px; max-width: none; }
.spark-bar { position: relative; min-width: 60px; max-width: 100px; flex: 1; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; }
@media (max-width: 640px) { .spark-bar { max-width: 60px; } }
.spark-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-hover, #60a5fa)); border-radius: 4px; transition: width 0.3s; }
.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; }
/* === Dark mode input overrides === */
@@ -716,7 +703,6 @@ button.ch-item.selected { background: var(--selected-bg); }
[data-theme="dark"] .trace-search input,
[data-theme="dark"] .mc-jump-btn,
[data-theme="dark"] .filter-bar .btn { background: var(--input-bg); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
[data-theme="dark"] .ch-item.selected,
[data-theme="dark"] .data-table tbody tr.selected { background: var(--selected-bg); }
[data-theme="dark"] .tl-bar-container { background: #334155; }
@@ -782,10 +768,6 @@ button.ch-item.selected { background: var(--selected-bg); }
}
.data-table tbody tr.new-row { animation: row-flash 800ms ease-out; }
.data-table th.sortable { cursor: pointer; user-select: none; }
.data-table th.sortable:hover { color: var(--accent); }
.data-table th.sort-active { color: var(--accent); }
.data-table th .sort-arrow { font-size: 10px; margin-left: 4px; opacity: 0.7; }
.data-table tbody tr { border-left: 3px solid transparent; transition: border-color 0.15s, background 0.15s; }
.data-table tbody tr:hover { border-left-color: var(--accent); }
@@ -862,8 +844,6 @@ button.ch-item.selected { background: var(--selected-bg); }
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
.filter-bar.filters-expanded input { width: 100%; }
.filter-bar.filters-expanded select { width: 100%; }
.filter-group { flex-wrap: wrap; }
.filter-group + .filter-group { border-left: none; padding-left: 0; margin-left: 0; }
.filter-bar .btn { min-height: 36px; }
.node-filter-wrap { width: 100%; }
@@ -918,7 +898,7 @@ button.ch-item.selected { background: var(--selected-bg); }
transition: color .15s, transform .15s;
}
.fav-star:hover { transform: scale(1.2); }
.fav-star.on { color: var(--status-yellow); }
.fav-star.on { color: #f5a623; }
/* BYOP Decode Modal */
.byop-modal { max-width: 560px; }
@@ -931,8 +911,8 @@ button.ch-item.selected { background: var(--selected-bg); }
border: 1px solid var(--border); border-radius: 6px; background: var(--surface-1);
color: var(--text);
}
.byop-input:focus { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
.byop-err { color: var(--status-red); font-size: .85rem; }
.byop-input:focus { border-color: var(--accent); outline: none; }
.byop-err { color: #ef4444; font-size: .85rem; }
.byop-decoded { margin-top: 8px; }
.byop-section { margin-bottom: 14px; }
.byop-section-title {
@@ -1066,9 +1046,9 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hash-cell.hash-active:hover { outline: 2px solid var(--accent); outline-offset: -2px; }
.hash-cell.hash-selected { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: 0 0 6px var(--accent); }
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
.badge-hash-3 { background: #3b82f620; color: var(--accent); }
.badge-hash-1 { background: #ef444420; color: #ef4444; }
.badge-hash-2 { background: #22c55e20; color: #22c55e; }
.badge-hash-3 { background: #3b82f620; color: #3b82f6; }
.timeline-legend { display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 12px; }
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.timeline-chart svg { display: block; }
@@ -1120,13 +1100,9 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
}
.observer-selector { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
.node-qr { text-align: center; margin-top: 8px; }
.node-qr svg { max-width: 100px; border-radius: 4px; }
.node-qr svg { max-width: 140px; border-radius: 4px; }
[data-theme="dark"] .node-qr svg rect[fill="#ffffff"] { fill: var(--card-bg); }
[data-theme="dark"] .node-qr svg rect[fill="#000000"] { fill: var(--text); }
.node-map-qr-wrap { position: relative; }
.node-map-qr-overlay { position: absolute; bottom: 8px; right: 8px; z-index: 400; background: rgba(255,255,255,0.5); border-radius: 4px; padding: 4px; line-height: 0; margin: 0; text-align: center; }
.node-map-qr-overlay svg { max-width: 56px !important; display: block; margin: 0; }
[data-theme="dark"] .node-map-qr-overlay { background: rgba(255,255,255,0.4); }
/* Replay on Live Map button in packet detail */
.detail-actions {
@@ -1179,12 +1155,12 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Clickable hop links */
.hop-link {
color: var(--accent, #3b82f6);
color: var(--primary, #3b82f6);
text-decoration: none;
cursor: pointer;
transition: color 0.15s;
}
.hop-link:hover { color: var(--accent-hover, #60a5fa); text-decoration: underline; }
.hop-link:hover { color: var(--accent, #60a5fa); text-decoration: underline; }
/* Detail map link */
.detail-map-link {
@@ -1201,19 +1177,6 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
}
.detail-map-link:hover { background: rgba(245, 158, 11, 0.25); }
.copy-link-btn {
padding: 5px 12px;
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.25);
color: var(--accent, #3b82f6);
border-radius: 6px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.copy-link-btn:hover { background: rgba(59, 130, 246, 0.25); }
/* Route tooltip on map */
.route-tooltip {
background: rgba(0,0,0,0.8) !important;
@@ -1224,33 +1187,15 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
}
/* Ambiguous hop indicator */
.hop-ambiguous { border-bottom: 1px dashed var(--status-yellow, #f59e0b); }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: var(--status-yellow, #f59e0b); }
.hop-conflict-btn { background: var(--status-yellow, #f59e0b); color: #000; border: none; border-radius: 4px; font-size: 11px;
font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; }
.hop-conflict-btn:hover { background: var(--status-yellow, #d97706); filter: brightness(0.85); }
.hop-conflict-popover { position: absolute; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; }
.hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border);
color: var(--text-muted); }
.hop-conflict-list { padding: 4px 0; }
.hop-conflict-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-decoration: none;
color: var(--text); font-size: 13px; border-bottom: 1px solid var(--border); }
.hop-conflict-item:last-child { border-bottom: none; }
.hop-conflict-item:hover { background: var(--hover-bg); }
.hop-conflict-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; }
.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
.hop-global-fallback { border-bottom: 1px dashed var(--status-red); }
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; }
/* Self-loop subpath rows */
.subpath-selfloop { opacity: 0.6; }
.subpath-selfloop td:first-child::after { content: ''; }
/* Hop prefix in subpath routes */
.hop-prefix { color: var(--text-muted); font-size: 0.8em; }
.hop-prefix { color: #9ca3af; font-size: 0.8em; }
/* Subpath split layout */
.subpath-layout { display: flex; gap: 0; flex: 1; min-height: 0; overflow: auto; position: relative; }
@@ -1258,7 +1203,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.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; }
.subpath-detail-inner h4 { margin: 0 0 4px; word-break: break-word; }
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: var(--text-muted); font-size: 0.9em; }
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: #9ca3af; font-size: 0.9em; }
.subpath-section { margin: 16px 0; }
.subpath-section h5 { margin: 0 0 6px; font-size: 0.9em; }
.subpath-selected { background: var(--accent, #3b82f6) !important; color: #fff; }
@@ -1268,7 +1213,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
/* Hour distribution chart */
.hour-chart { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
.hour-bar { flex: 1; background: var(--accent, #3b82f6); border-radius: 2px 2px 0 0; min-width: 4px; }
.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: var(--text-muted); }
.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: #9ca3af; }
/* Parent paths */
.parent-path { padding: 3px 0; border-bottom: 1px solid var(--border, #e5e7eb); }
@@ -1288,7 +1233,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
/* 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: var(--text-muted); }
.subpath-jump-nav span { color: #9ca3af; }
.subpath-jump-nav a { padding: 4px 12px; border-radius: 4px; background: var(--accent, #3b82f6); color: #fff; text-decoration: none; font-size: 0.85em; }
.subpath-jump-nav a:hover { opacity: 0.8; }
@@ -1304,11 +1249,10 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
/* #71 — Column visibility toggle */
.col-toggle-wrap { position: relative; display: inline-block; }
.col-toggle-btn { font-size: 13px; padding: 6px 10px; cursor: pointer; background: var(--input-bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); height: 34px; box-sizing: border-box; line-height: 1; }
.col-toggle-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 input[type="checkbox"] { width: 14px; height: 14px; margin: 0; flex-shrink: 0; }
.col-toggle-menu label:hover { background: var(--row-hover); }
/* Column hide classes */
@@ -1381,7 +1325,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
height: 36px;
border-radius: 6px;
border: 1px solid var(--border, #333);
background: var(--card-bg, #1e1e1e);
background: var(--bg-card, #1e1e1e);
color: var(--text, #fff);
font-size: 18px;
cursor: pointer;
@@ -1397,21 +1341,6 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
height: 280px;
min-height: 200px;
}
.node-top-row { display: flex; gap: 16px; margin-bottom: 12px; }
.node-top-row .node-map-wrap { flex: 3; min-height: 200px; border-radius: 8px; overflow: hidden; }
.node-top-row .node-map-wrap .node-detail-map { height: 100%; }
.node-top-row .node-qr-wrap { flex: 1; min-width: 120px; max-width: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.node-qr-wrap--full { max-width: 240px; margin: 0 auto; }
.node-stats-table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 12px; }
.node-stats-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); }
.node-stats-table tr:last-child td { border-bottom: none; }
.node-stats-table tr:nth-child(even) { background: var(--row-stripe); }
.node-stats-table td:first-child { font-weight: 600; color: var(--text-muted); width: 40%; white-space: nowrap; }
.node-stats-table td:last-child { font-weight: 500; }
@media (max-width: 768px) {
.node-top-row { flex-direction: column; }
.node-top-row .node-qr-wrap { min-height: auto; }
}
@media (max-width: 640px) {
.node-detail-map {
height: 200px;
@@ -1429,9 +1358,6 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
}
.meshcore-marker { background: none !important; border: none !important; }
.marker-stale { opacity: 0.7; filter: grayscale(90%) brightness(0.8); }
.last-seen-active { color: var(--status-green); }
.last-seen-stale { color: var(--text-muted); }
/* === Node Analytics === */
.analytics-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
@@ -1497,219 +1423,6 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.perf-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
.perf-table code { font-size: 12px; color: var(--text); }
.perf-table .perf-slow { background: rgba(239, 68, 68, 0.08); }
.perf-table .perf-slow td { color: var(--status-red); }
.perf-table .perf-slow td { color: #ef4444; }
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
.perf-table .perf-warn td { color: var(--status-yellow); }
/* ─── Region filter bar ─── */
.region-filter-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
.region-filter-container { margin: 0; padding: 0; display: inline-flex; align-items: center; }
.region-pill {
display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 16px;
font-size: 12px; font-weight: 500; cursor: pointer; border: 1.5px solid var(--border);
background: transparent; color: var(--text-muted); transition: all 0.15s;
}
.region-pill:hover { border-color: var(--accent); color: var(--accent); }
.region-pill-active {
background: var(--accent); color: #fff; border-color: var(--accent);
}
.region-pill-active:hover { opacity: 0.85; }
.region-filter-label {
font-size: 12px; font-weight: 600; color: var(--text-muted); align-self: center;
margin-right: 2px; user-select: none;
}
.region-dropdown-wrap { position: relative; display: inline-flex; align-items: center; }
.region-dropdown-trigger {
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 6px;
font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border);
background: var(--input-bg); color: var(--text); transition: all 0.15s;
height: 34px; box-sizing: border-box; white-space: nowrap; line-height: 1;
}
.region-dropdown-trigger:hover { border-color: var(--accent); color: var(--accent); }
.region-dropdown-menu {
position: absolute; top: 100%; left: 0; z-index: 90;
min-width: 220px; width: max-content; max-height: 260px; overflow-y: auto;
background: var(--card-bg, #fff); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0;
}
.region-dropdown-item {
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; max-width: 320px;
}
.region-dropdown-item input[type="checkbox"] {
width: 14px; height: 14px; margin: 0; flex-shrink: 0;
}
.region-dropdown-item:hover { background: var(--row-hover, #f5f5f5); }
/* Generic multi-select dropdown (Observer, Type filters) */
.multi-select-wrap { position: relative; display: inline-flex; align-items: center; }
.multi-select-trigger {
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 6px;
font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border);
background: var(--input-bg); color: var(--text); transition: all 0.15s;
height: 34px; box-sizing: border-box; white-space: nowrap; line-height: 1;
}
.multi-select-trigger:hover { border-color: var(--accent); color: var(--accent); }
.multi-select-menu {
position: absolute; top: 100%; left: 0; z-index: 90;
min-width: 220px; max-height: 260px; overflow-y: auto;
background: var(--card-bg, #fff); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0; display: none;
}
.multi-select-menu.open { display: block; }
.multi-select-item {
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
}
.multi-select-item input[type="checkbox"] {
width: 14px; height: 14px; margin: 0; flex-shrink: 0;
}
.multi-select-item:hover { background: var(--row-hover, #f5f5f5); }
.chan-tag { background: var(--accent, #3b82f6); color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 0.9em; font-weight: 600; }
/* Matrix mode hex animation */
.matrix-char { background: none !important; border: none !important; }
.matrix-char span { display: block; text-align: center; white-space: nowrap; line-height: 1; }
/* === Matrix Theme === */
.matrix-theme .leaflet-tile-pane {
filter: brightness(1.1) contrast(1.2) sepia(0.6) hue-rotate(70deg) saturate(2);
}
.matrix-theme.leaflet-container::before {
content: ''; position: absolute; inset: 0; z-index: 401;
background: rgba(0, 60, 10, 0.35); mix-blend-mode: multiply; pointer-events: none;
}
.matrix-theme.leaflet-container::after {
content: ''; position: absolute; inset: 0; z-index: 402;
background: rgba(0, 255, 65, 0.06); mix-blend-mode: screen; pointer-events: none;
}
.matrix-theme { background: #000 !important; }
.matrix-theme .leaflet-control-zoom a { background: #0a0a0a !important; color: #00ff41 !important; border-color: #00ff4130 !important; }
.matrix-theme .leaflet-control-attribution { background: rgba(0,0,0,0.8) !important; color: #00ff4180 !important; }
.matrix-theme .leaflet-control-attribution a { color: #00ff4160 !important; }
/* Scanline overlay */
.matrix-scanlines {
position: absolute; inset: 0; z-index: 9999; pointer-events: none;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,255,65,0.02) 2px, rgba(0,255,65,0.02) 4px);
}
/* Feed panel in matrix mode */
.matrix-theme .live-feed {
background: rgba(0, 10, 0, 0.92) !important;
border-color: #00ff4130 !important;
font-family: 'Courier New', monospace !important;
}
.matrix-theme .live-feed .live-feed-item { color: #00ff41 !important; border-color: #00ff4115 !important; }
.matrix-theme .live-feed .live-feed-item:hover { background: rgba(0,255,65,0.08) !important; }
.matrix-theme .live-feed .feed-hide-btn { color: #00ff41 !important; }
/* Controls in matrix mode */
.matrix-theme .live-controls {
background: rgba(0, 10, 0, 0.9) !important;
border-color: #00ff4130 !important;
color: #00ff41 !important;
}
.matrix-theme .live-controls label,
.matrix-theme .live-controls span,
.matrix-theme .live-controls .lcd-display { color: #00ff41 !important; }
.matrix-theme .live-controls button { color: #00ff41 !important; border-color: #00ff4130 !important; }
.matrix-theme .live-controls input[type="range"] { accent-color: #00ff41; }
/* Node detail panel in matrix mode */
.matrix-theme .live-node-detail {
background: rgba(0, 10, 0, 0.95) !important;
border-color: #00ff4130 !important;
color: #00ff41 !important;
}
.matrix-theme .live-node-detail a { color: #00ff41 !important; }
.matrix-theme .live-node-detail .feed-hide-btn { color: #00ff41 !important; }
/* Node labels on map */
.matrix-theme .node-label { color: #00ff41 !important; text-shadow: 0 0 4px #00ff41 !important; }
.matrix-theme .leaflet-marker-icon:not(.matrix-char) { filter: hue-rotate(90deg) saturate(1) brightness(0.35) opacity(0.5); }
/* Audio controls */
.audio-controls {
display: flex;
gap: 12px;
align-items: center;
padding: 4px 8px;
font-size: 12px;
}
.audio-controls.hidden { display: none; }
.audio-slider-label {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-muted, #6b7280);
font-size: 11px;
white-space: nowrap;
}
.audio-slider {
width: 80px;
height: 4px;
cursor: pointer;
accent-color: #8b5cf6;
}
.audio-slider-label span {
min-width: 24px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.matrix-theme .audio-controls label,
.matrix-theme .audio-controls span { color: #00ff41 !important; }
.matrix-theme .audio-slider { accent-color: #00ff41; }
/* Audio voice selector */
.audio-voice-select {
background: var(--input-bg, #1f2937);
color: var(--text, #e5e7eb);
border: 1px solid var(--border, #374151);
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
cursor: pointer;
}
.matrix-theme .audio-voice-select {
background: #001a00 !important;
color: #00ff41 !important;
border-color: #00ff4130 !important;
}
/* Audio unlock overlay */
.audio-unlock-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.6);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.audio-unlock-prompt {
background: #1f2937;
color: #e5e7eb;
padding: 24px 40px;
border-radius: 12px;
font-size: 20px;
font-weight: 600;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
user-select: none;
}
.matrix-theme .audio-unlock-prompt {
background: #001a00;
color: #00ff41;
box-shadow: 0 0 30px rgba(0,255,65,0.2);
}
/* Packet Filter Language */
.packet-filter-input { transition: border-color 0.2s; }
.packet-filter-input:focus { border-color: var(--accent); outline: none; }
.packet-filter-input.filter-error { border-color: var(--status-red); }
.packet-filter-input.filter-active { border-color: var(--status-green); }
.perf-table .perf-warn td { color: #f59e0b; }
+50 -151
View File
@@ -5,10 +5,11 @@
let currentHash = null;
let traceData = [];
let packetMeta = null;
function init(app, routeParam) {
// Check URL for pre-filled hash — support both route param and query param
function init(app) {
// Check URL for pre-filled hash
const params = new URLSearchParams(location.hash.split('?')[1] || '');
const urlHash = routeParam || params.get('hash') || '';
const urlHash = params.get('hash') || '';
app.innerHTML = `
<div class="traces-page">
@@ -36,16 +37,6 @@
packetMeta = null;
}
function obsLabel(t) {
return t.observer_name || (t.observer && t.observer.length > 16 ? t.observer.slice(0, 12) + '…' : t.observer) || '—';
}
function obsLink(t) {
const label = escapeHtml(obsLabel(t));
if (!t.observer) return label;
return `<a href="#/observers/${encodeURIComponent(t.observer)}" style="color:var(--accent);text-decoration:none;" title="${escapeHtml(t.observer)}">${label}</a>`;
}
async function doTrace() {
const input = document.getElementById('traceHashInput');
const hash = input.value.trim();
@@ -69,23 +60,14 @@
return;
}
// Extract ALL unique paths from observations
const allPaths = [];
for (const t of traceData) {
// Extract path from first packet that has it
let pathHops = [];
for (const p of packets) {
try {
const hops = JSON.parse(t.path_json || '[]');
if (hops.length > 0) allPaths.push({ hops, observer: obsLabel(t) });
const hops = JSON.parse(p.path_json || '[]');
if (hops.length > 0) { pathHops = hops; break; }
} catch {}
}
// Fallback to packet-level path
if (allPaths.length === 0) {
for (const p of packets) {
try {
const hops = JSON.parse(p.path_json || '[]');
if (hops.length > 0) { allPaths.push({ hops, observer: 'packet' }); break; }
} catch {}
}
}
// Get packet type info from first packet
packetMeta = packets[0] || null;
@@ -94,13 +76,13 @@
try { decoded = JSON.parse(packetMeta.decoded_json); } catch {}
}
renderResults(results, allPaths, decoded);
renderResults(results, pathHops, decoded);
} catch (e) {
results.innerHTML = `<div class="trace-empty" style="color:#ef4444">Error: ${e.message}</div>`;
}
}
function renderResults(container, allPaths, decoded) {
function renderResults(container, pathHops, decoded) {
const uniqueObservers = [...new Set(traceData.map(t => t.observer))];
const typeName = packetMeta ? payloadTypeName(packetMeta.payload_type) : '—';
const typeClass = packetMeta ? payloadTypeColor(packetMeta.payload_type) : 'unknown';
@@ -136,136 +118,31 @@
</div>
</div>
${allPaths.length > 0 ? renderPathGraph(allPaths) : ''}
${pathHops.length > 0 ? renderPathViz(pathHops) : ''}
${traceData.length > 0 ? renderTimeline(t0, spreadMs) : ''}
${renderObserverTable()}
`;
makeColumnsResizable('#traceObsTable', 'meshcore-trace-col-widths');
}
function renderPathGraph(allPaths) {
// Collect unique nodes and edges across all observed paths
const nodeSet = new Set();
const edgeMap = new Map(); // "from→to" => Set of observer labels
nodeSet.add('Origin');
nodeSet.add('Dest');
for (const { hops, observer } of allPaths) {
const chain = ['Origin', ...hops, 'Dest'];
for (let i = 0; i < chain.length - 1; i++) {
nodeSet.add(chain[i]);
nodeSet.add(chain[i + 1]);
const key = chain[i] + '→' + chain[i + 1];
if (!edgeMap.has(key)) edgeMap.set(key, new Set());
edgeMap.get(key).add(observer);
}
}
const nodes = [...nodeSet];
// Assign positions: lay out nodes left to right by their earliest appearance in any path
const order = new Map();
order.set('Origin', 0);
let maxCol = 0;
for (const { hops } of allPaths) {
const chain = ['Origin', ...hops, 'Dest'];
for (let i = 0; i < chain.length; i++) {
if (!order.has(chain[i])) {
order.set(chain[i], i);
}
maxCol = Math.max(maxCol, i);
}
}
order.set('Dest', maxCol);
// Group nodes by column for vertical stacking
const colGroups = new Map();
for (const [node, col] of order) {
if (!colGroups.has(col)) colGroups.set(col, []);
colGroups.get(col).push(node);
}
const colCount = maxCol + 1;
const svgW = Math.max(600, colCount * 140);
const maxRows = Math.max(...[...colGroups.values()].map(g => g.length));
const svgH = Math.max(120, maxRows * 60 + 40);
const colSpacing = svgW / (colCount + 1);
// Compute node positions
const nodePos = new Map();
for (const [col, group] of colGroups) {
const rowSpacing = svgH / (group.length + 1);
group.forEach((node, i) => {
nodePos.set(node, { x: (col + 1) * colSpacing, y: (i + 1) * rowSpacing });
});
}
// Colors for edges (cycle through)
const edgeColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
const observerColorMap = new Map();
let colorIdx = 0;
for (const obsSet of edgeMap.values()) {
for (const obs of obsSet) {
if (!observerColorMap.has(obs)) {
observerColorMap.set(obs, edgeColors[colorIdx % edgeColors.length]);
colorIdx++;
}
}
}
// Build SVG
let edgesSvg = '';
for (const [key, observers] of edgeMap) {
const [from, to] = key.split('→');
const p1 = nodePos.get(from);
const p2 = nodePos.get(to);
if (!p1 || !p2) continue;
const obsArr = [...observers];
const thickness = Math.min(obsArr.length, 6);
// Use first observer's color, show count as tooltip
const color = observerColorMap.get(obsArr[0]) || '#6b7280';
const title = obsArr.length > 1 ? `${obsArr.length} observers: ${obsArr.join(', ')}` : obsArr[0];
edgesSvg += `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}" stroke="${color}" stroke-width="${thickness}" stroke-opacity="0.6"><title>${escapeHtml(title)}</title></line>`;
// Arrowhead
const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
const arrowLen = 8;
const ax = p2.x - 20 * Math.cos(angle);
const ay = p2.y - 20 * Math.sin(angle);
const a1x = ax - arrowLen * Math.cos(angle - 0.4);
const a1y = ay - arrowLen * Math.sin(angle - 0.4);
const a2x = ax - arrowLen * Math.cos(angle + 0.4);
const a2y = ay - arrowLen * Math.sin(angle + 0.4);
edgesSvg += `<polygon points="${ax},${ay} ${a1x},${a1y} ${a2x},${a2y}" fill="${color}" opacity="0.8"/>`;
}
let nodesSvg = '';
for (const [node, pos] of nodePos) {
const isEndpoint = node === 'Origin' || node === 'Dest';
const r = isEndpoint ? 18 : 14;
const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--surface-2, #374151)';
const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--border, #4b5563)';
const label = isEndpoint ? node : node;
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`;
nodesSvg += `<text x="${pos.x}" y="${pos.y + 4}" text-anchor="middle" fill="white" font-size="${isEndpoint ? 10 : 9}" font-weight="${isEndpoint ? 700 : 500}">${escapeHtml(label)}</text>`;
}
// Legend: unique paths
const uniquePaths = [...new Set(allPaths.map(p => p.hops.join('→')))];
const legendHtml = uniquePaths.length > 1
? `<div class="trace-path-info" style="margin-top:8px">${uniquePaths.length} unique path${uniquePaths.length > 1 ? 's' : ''} observed by ${allPaths.length} observer${allPaths.length > 1 ? 's' : ''}</div>`
: `<div class="trace-path-info">${allPaths[0].hops.length} hop${allPaths[0].hops.length !== 1 ? 's' : ''} in relay path</div>`;
function renderPathViz(hops) {
const arrows = hops.map(h => `<span class="trace-path-hop">${h}</span>`).join('<span class="trace-path-arrow">→</span>');
return `
<div class="trace-section">
<h3>Path Graph</h3>
<div style="overflow-x:auto;">
<svg width="${svgW}" height="${svgH}" style="display:block;margin:0 auto;">
${edgesSvg}
${nodesSvg}
</svg>
<h3>Path Visualization</h3>
<div class="trace-path-viz">
<span class="trace-path-label">Origin</span>
<span class="trace-path-arrow"></span>
${arrows}
<span class="trace-path-arrow"></span>
<span class="trace-path-label">Dest</span>
</div>
${legendHtml}
<div class="trace-path-info">${hops.length} hop${hops.length !== 1 ? 's' : ''} in relay path</div>
</div>`;
}
function renderTimeline(t0, spreadMs) {
// Build timeline bars
const barWidth = spreadMs > 0 ? spreadMs : 1;
const rows = traceData.map((t, i) => {
const time = new Date(t.time);
@@ -275,13 +152,13 @@
const delta = spreadMs > 0 ? `+${(offsetMs / 1000).toFixed(3)}s` : '';
return `<div class="tl-row">
<div class="tl-observer">${obsLink(t)}</div>
<div class="tl-observer">${truncate(t.observer || '—', 20)}</div>
<div class="tl-bar-container">
<div class="tl-marker" style="left:${pct}%" title="${time.toISOString()}"></div>
</div>
<div class="tl-delta mono">${delta}</div>
<div class="tl-snr ${snrClass}">${t.snr != null ? Number(t.snr).toFixed(1) + ' dB' : '—'}</div>
<div class="tl-rssi">${t.rssi != null ? Number(t.rssi).toFixed(0) + ' dBm' : '—'}</div>
<div class="tl-snr ${snrClass}">${t.snr != null ? t.snr.toFixed(1) + ' dB' : '—'}</div>
<div class="tl-rssi">${t.rssi != null ? t.rssi.toFixed(0) + ' dBm' : '—'}</div>
</div>`;
});
@@ -295,5 +172,27 @@
</div>`;
}
function renderObserverTable() {
const rows = traceData.map((t, i) => {
const snrClass = t.snr != null ? (t.snr >= 0 ? 'good' : t.snr >= -10 ? 'ok' : 'bad') : '';
return `<tr>
<td>${i + 1}</td>
<td class="mono">${t.observer || '—'}</td>
<td>${t.time ? new Date(t.time).toLocaleString() : '—'}</td>
<td class="tl-snr ${snrClass}">${t.snr != null ? t.snr.toFixed(1) + ' dB' : '—'}</td>
<td>${t.rssi != null ? t.rssi.toFixed(0) + ' dBm' : '—'}</td>
</tr>`;
});
return `
<div class="trace-section">
<h3>Observer Details</h3>
<table class="data-table" id="traceObsTable">
<thead><tr><th>#</th><th>Observer</th><th>Timestamp</th><th>SNR</th><th>RSSI</th></tr></thead>
<tbody>${rows.join('')}</tbody>
</table>
</div>`;
}
registerPage('traces', { init, destroy });
})();
-978
View File
@@ -1,978 +0,0 @@
// After Playwright tests, this script:
// 1. Connects to the running test server
// 2. Exercises frontend interactions to maximize code coverage
// 3. Extracts window.__coverage__ from the browser
// 4. Writes it to .nyc_output/ for merging
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
async function collectCoverage() {
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
headless: true
});
const page = await browser.newPage();
page.setDefaultTimeout(10000);
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// Helper: safe click
async function safeClick(selector, timeout) {
try {
await page.click(selector, { timeout: timeout || 3000 });
await page.waitForTimeout(300);
} catch {}
}
// Helper: safe fill
async function safeFill(selector, text) {
try {
await page.fill(selector, text);
await page.waitForTimeout(300);
} catch {}
}
// Helper: safe select
async function safeSelect(selector, value) {
try {
await page.selectOption(selector, value);
await page.waitForTimeout(300);
} catch {}
}
// Helper: click all matching elements
async function clickAll(selector, max = 10) {
try {
const els = await page.$$(selector);
for (let i = 0; i < Math.min(els.length, max); i++) {
try { await els[i].click(); await page.waitForTimeout(300); } catch {}
}
} catch {}
}
// Helper: iterate all select options
async function cycleSelect(selector) {
try {
const options = await page.$$eval(`${selector} option`, opts => opts.map(o => o.value));
for (const val of options) {
try { await page.selectOption(selector, val); await page.waitForTimeout(400); } catch {}
}
} catch {}
}
// ══════════════════════════════════════════════
// HOME PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Home page — chooser...');
// Clear localStorage to get chooser
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.evaluate(() => localStorage.clear()).catch(() => {});
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
// Click "I'm new"
await safeClick('#chooseNew');
await page.waitForTimeout(1000);
// Now on home page as "new" user — interact with search
await safeFill('#homeSearch', 'test');
await page.waitForTimeout(600);
// Click suggest items if any
await clickAll('.suggest-item', 3);
// Click suggest claim buttons
await clickAll('.suggest-claim', 2);
await safeFill('#homeSearch', '');
await page.waitForTimeout(300);
// Click my-node-card elements
await clickAll('.my-node-card', 3);
await page.waitForTimeout(300);
// Click health/packets buttons on cards
await clickAll('[data-action="health"]', 2);
await page.waitForTimeout(500);
await clickAll('[data-action="packets"]', 2);
await page.waitForTimeout(500);
// Click toggle level
await safeClick('#toggleLevel');
await page.waitForTimeout(500);
// Click FAQ items
await clickAll('.faq-q, .question, [class*="accordion"]', 5);
// Click timeline items
await clickAll('.timeline-item', 5);
// Click health claim button
await clickAll('.health-claim', 2);
// Click cards
await clickAll('.card, .health-card', 3);
// Click remove buttons on my-node cards
await clickAll('.mnc-remove', 2);
// Switch to experienced mode
await page.evaluate(() => localStorage.clear()).catch(() => {});
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1000);
await safeClick('#chooseExp');
await page.waitForTimeout(1000);
// Interact with experienced home page
await safeFill('#homeSearch', 'a');
await page.waitForTimeout(600);
await clickAll('.suggest-item', 2);
await safeFill('#homeSearch', '');
await page.waitForTimeout(300);
// Click outside to dismiss suggest
await page.evaluate(() => document.body.click()).catch(() => {});
await page.waitForTimeout(300);
// ══════════════════════════════════════════════
// NODES PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Nodes page...');
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
// Sort by EVERY column
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
try { await page.click(`th[data-sort="${col}"]`); await page.waitForTimeout(300); } catch {}
// Click again for reverse sort
try { await page.click(`th[data-sort="${col}"]`); await page.waitForTimeout(300); } catch {}
}
// Click EVERY role tab
const roleTabs = await page.$$('.node-tab[data-tab]');
for (const tab of roleTabs) {
try { await tab.click(); await page.waitForTimeout(500); } catch {}
}
// Go back to "all"
try { await page.click('.node-tab[data-tab="all"]'); await page.waitForTimeout(400); } catch {}
// Click EVERY status filter
for (const status of ['active', 'stale', 'all']) {
try { await page.click(`#nodeStatusFilter .btn[data-status="${status}"]`); await page.waitForTimeout(400); } catch {}
}
// Cycle EVERY Last Heard option
await cycleSelect('#nodeLastHeard');
// Search
await safeFill('#nodeSearch', 'test');
await page.waitForTimeout(500);
await safeFill('#nodeSearch', '');
await page.waitForTimeout(300);
// Click node rows to open side pane — try multiple
const nodeRows = await page.$$('#nodesBody tr');
for (let i = 0; i < Math.min(nodeRows.length, 4); i++) {
try { await nodeRows[i].click(); await page.waitForTimeout(600); } catch {}
}
// In side pane — click detail/analytics links
await safeClick('a[href*="/nodes/"]', 2000);
await page.waitForTimeout(1500);
// Click fav star
await clickAll('.fav-star', 2);
// On node detail page — interact
// Click back button
await safeClick('#nodeBackBtn');
await page.waitForTimeout(500);
// Navigate to a node detail page via hash
try {
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
if (firstNodeKey) {
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
// Click tabs on detail page
await clickAll('.tab-btn, [data-tab]', 10);
// Click copy URL button
await safeClick('#copyUrlBtn');
// Click "Show all paths" button
await safeClick('#showAllPaths');
await safeClick('#showAllFullPaths');
// Click node analytics day buttons
for (const days of ['1', '7', '30', '365']) {
try { await page.click(`[data-days="${days}"]`); await page.waitForTimeout(800); } catch {}
}
}
} catch {}
// Node detail with scroll target
try {
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
if (firstKey) {
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
}
} catch {}
// ══════════════════════════════════════════════
// PACKETS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Packets page...');
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
// Open filter bar
await safeClick('#filterToggleBtn');
await page.waitForTimeout(500);
// Type various filter expressions
const filterExprs = [
'type == ADVERT', 'type == GRP_TXT', 'snr > 0', 'hops > 1',
'route == FLOOD', 'rssi < -80', 'type == TXT_MSG', 'type == ACK',
'snr > 5 && hops > 1', 'type == PATH', '@@@', ''
];
for (const expr of filterExprs) {
await safeFill('#packetFilterInput', expr);
await page.waitForTimeout(500);
}
// Cycle ALL time window options
await cycleSelect('#fTimeWindow');
// Toggle group by hash
await safeClick('#fGroup');
await page.waitForTimeout(600);
await safeClick('#fGroup');
await page.waitForTimeout(600);
// Toggle My Nodes filter
await safeClick('#fMyNodes');
await page.waitForTimeout(500);
await safeClick('#fMyNodes');
await page.waitForTimeout(500);
// Click observer menu trigger
await safeClick('#observerTrigger');
await page.waitForTimeout(400);
// Click items in observer menu
await clickAll('#observerMenu input[type="checkbox"]', 5);
await safeClick('#observerTrigger');
await page.waitForTimeout(300);
// Click type filter trigger
await safeClick('#typeTrigger');
await page.waitForTimeout(400);
await clickAll('#typeMenu input[type="checkbox"]', 5);
await safeClick('#typeTrigger');
await page.waitForTimeout(300);
// Hash input
await safeFill('#fHash', 'abc123');
await page.waitForTimeout(500);
await safeFill('#fHash', '');
await page.waitForTimeout(300);
// Node filter
await safeFill('#fNode', 'test');
await page.waitForTimeout(500);
await clickAll('.node-filter-option', 3);
await safeFill('#fNode', '');
await page.waitForTimeout(300);
// Observer sort
await cycleSelect('#fObsSort');
// Column toggle menu
await safeClick('#colToggleBtn');
await page.waitForTimeout(400);
await clickAll('#colToggleMenu input[type="checkbox"]', 8);
await safeClick('#colToggleBtn');
await page.waitForTimeout(300);
// Hex hash toggle
await safeClick('#hexHashToggle');
await page.waitForTimeout(400);
await safeClick('#hexHashToggle');
await page.waitForTimeout(300);
// Pause button
await safeClick('#pktPauseBtn');
await page.waitForTimeout(400);
await safeClick('#pktPauseBtn');
await page.waitForTimeout(400);
// Click packet rows to open detail pane
const pktRows = await page.$$('#pktBody tr');
for (let i = 0; i < Math.min(pktRows.length, 5); i++) {
try { await pktRows[i].click(); await page.waitForTimeout(500); } catch {}
}
// Resize handle drag simulation
try {
await page.evaluate(() => {
const handle = document.getElementById('pktResizeHandle');
if (handle) {
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 500, bubbles: true }));
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 400, bubbles: true }));
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
}
});
await page.waitForTimeout(300);
} catch {}
// Click outside filter menus to close them
try {
await page.evaluate(() => document.body.click());
await page.waitForTimeout(300);
} catch {}
// Navigate to specific packet by hash
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
// ══════════════════════════════════════════════
// MAP PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Map page...');
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(3000);
// Toggle controls panel
await safeClick('#mapControlsToggle');
await page.waitForTimeout(500);
// Toggle each role checkbox on/off
try {
const roleChecks = await page.$$('#mcRoleChecks input[type="checkbox"]');
for (const cb of roleChecks) {
try { await cb.click(); await page.waitForTimeout(300); } catch {}
try { await cb.click(); await page.waitForTimeout(300); } catch {}
}
} catch {}
// Toggle clusters, heatmap, neighbors, hash labels
await safeClick('#mcClusters');
await page.waitForTimeout(300);
await safeClick('#mcClusters');
await page.waitForTimeout(300);
await safeClick('#mcHeatmap');
await page.waitForTimeout(300);
await safeClick('#mcHeatmap');
await page.waitForTimeout(300);
await safeClick('#mcNeighbors');
await page.waitForTimeout(300);
await safeClick('#mcNeighbors');
await page.waitForTimeout(300);
await safeClick('#mcHashLabels');
await page.waitForTimeout(300);
await safeClick('#mcHashLabels');
await page.waitForTimeout(300);
// Last heard dropdown on map
await cycleSelect('#mcLastHeard');
// Status filter buttons on map
for (const st of ['active', 'stale', 'all']) {
try { await page.click(`#mcStatusFilter .btn[data-status="${st}"]`); await page.waitForTimeout(400); } catch {}
}
// Click jump buttons (region jumps)
await clickAll('#mcJumps button', 5);
// Click markers
await clickAll('.leaflet-marker-icon', 5);
await clickAll('.leaflet-interactive', 3);
// Click popups
await clickAll('.leaflet-popup-content a', 3);
// Zoom controls
await safeClick('.leaflet-control-zoom-in');
await page.waitForTimeout(300);
await safeClick('.leaflet-control-zoom-out');
await page.waitForTimeout(300);
// Toggle dark mode while on map (triggers tile layer swap)
await safeClick('#darkModeToggle');
await page.waitForTimeout(800);
await safeClick('#darkModeToggle');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════
// ANALYTICS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Analytics page...');
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(3000);
// Click EVERY analytics tab
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
for (const tabName of analyticsTabs) {
try {
await page.click(`#analyticsTabs [data-tab="${tabName}"]`, { timeout: 2000 });
await page.waitForTimeout(1500);
} catch {}
}
// On topology tab — click observer selector buttons
try {
await page.click('#analyticsTabs [data-tab="topology"]', { timeout: 2000 });
await page.waitForTimeout(1500);
await clickAll('#obsSelector .tab-btn', 5);
// Click the "All Observers" button
await safeClick('[data-obs="__all"]');
await page.waitForTimeout(500);
} catch {}
// On collisions tab — click navigate rows
try {
await page.click('#analyticsTabs [data-tab="collisions"]', { timeout: 2000 });
await page.waitForTimeout(1500);
await clickAll('tr[data-action="navigate"]', 3);
await page.waitForTimeout(500);
} catch {}
// On subpaths tab — click rows
try {
await page.click('#analyticsTabs [data-tab="subpaths"]', { timeout: 2000 });
await page.waitForTimeout(1500);
await clickAll('tr[data-action="navigate"]', 3);
await page.waitForTimeout(500);
} catch {}
// On nodes tab — click sortable headers
try {
await page.click('#analyticsTabs [data-tab="nodes"]', { timeout: 2000 });
await page.waitForTimeout(1500);
await clickAll('.analytics-table th', 8);
await page.waitForTimeout(300);
} catch {}
// Deep-link to each analytics tab via URL
for (const tab of analyticsTabs) {
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
}
// Region filter on analytics
try {
await page.click('#analyticsRegionFilter');
await page.waitForTimeout(300);
await clickAll('#analyticsRegionFilter input[type="checkbox"]', 3);
await page.waitForTimeout(300);
} catch {}
// ══════════════════════════════════════════════
// CUSTOMIZE
// ══════════════════════════════════════════════
console.log(' [coverage] Customizer...');
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(500);
await safeClick('#customizeToggle');
await page.waitForTimeout(1000);
// Click EVERY customizer tab
for (const tab of ['branding', 'theme', 'nodes', 'home', 'export']) {
try { await page.click(`.cust-tab[data-tab="${tab}"]`); await page.waitForTimeout(500); } catch {}
}
// On branding tab — change text inputs
try {
await page.click('.cust-tab[data-tab="branding"]');
await page.waitForTimeout(300);
await safeFill('input[data-key="branding.siteName"]', 'Test Site');
await safeFill('input[data-key="branding.tagline"]', 'Test Tagline');
await safeFill('input[data-key="branding.logoUrl"]', 'https://example.com/logo.png');
await safeFill('input[data-key="branding.faviconUrl"]', 'https://example.com/favicon.ico');
} catch {}
// On theme tab — click EVERY preset
try {
await page.click('.cust-tab[data-tab="theme"]');
await page.waitForTimeout(300);
const presets = await page.$$('.cust-preset-btn[data-preset]');
for (const preset of presets) {
try { await preset.click(); await page.waitForTimeout(400); } catch {}
}
} catch {}
// Change color inputs on theme tab
try {
const colorInputs = await page.$$('input[type="color"][data-theme]');
for (let i = 0; i < Math.min(colorInputs.length, 5); i++) {
try {
await colorInputs[i].evaluate(el => {
el.value = '#ff5500';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.waitForTimeout(200);
} catch {}
}
} catch {}
// Click reset buttons on theme
await clickAll('[data-reset-theme]', 3);
await clickAll('[data-reset-node]', 3);
await clickAll('[data-reset-type]', 3);
// On nodes tab — change node color inputs
try {
await page.click('.cust-tab[data-tab="nodes"]');
await page.waitForTimeout(300);
const nodeColors = await page.$$('input[type="color"][data-node]');
for (let i = 0; i < Math.min(nodeColors.length, 3); i++) {
try {
await nodeColors[i].evaluate(el => {
el.value = '#00ff00';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.waitForTimeout(200);
} catch {}
}
// Type color inputs
const typeColors = await page.$$('input[type="color"][data-type-color]');
for (let i = 0; i < Math.min(typeColors.length, 3); i++) {
try {
await typeColors[i].evaluate(el => {
el.value = '#0000ff';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.waitForTimeout(200);
} catch {}
}
} catch {}
// On home tab — edit home customization fields
try {
await page.click('.cust-tab[data-tab="home"]');
await page.waitForTimeout(300);
await safeFill('input[data-key="home.heroTitle"]', 'Test Hero');
await safeFill('input[data-key="home.heroSubtitle"]', 'Test Subtitle');
// Edit journey steps
await clickAll('[data-move-step]', 2);
await clickAll('[data-rm-step]', 1);
// Edit checklist
await clickAll('[data-rm-check]', 1);
// Edit links
await clickAll('[data-rm-link]', 1);
// Modify step fields
const stepTitles = await page.$$('input[data-step-field="title"]');
for (let i = 0; i < Math.min(stepTitles.length, 2); i++) {
try {
await stepTitles[i].fill('Test Step ' + i);
await page.waitForTimeout(200);
} catch {}
}
} catch {}
// On export tab
try {
await page.click('.cust-tab[data-tab="export"]');
await page.waitForTimeout(500);
// Click export/import buttons if present
await clickAll('.cust-panel[data-panel="export"] button', 3);
} catch {}
// Reset preview and user theme
await safeClick('#custResetPreview');
await page.waitForTimeout(400);
await safeClick('#custResetUser');
await page.waitForTimeout(400);
// Close customizer
await safeClick('.cust-close');
await page.waitForTimeout(300);
// ══════════════════════════════════════════════
// CHANNELS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Channels page...');
await page.goto(`${BASE}/#/channels`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
// Click channel rows/items
await clickAll('.channel-item, .channel-row, .channel-card', 3);
await clickAll('table tbody tr', 3);
// Navigate to a specific channel
try {
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
if (channelHash) {
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
}
} catch {}
// ══════════════════════════════════════════════
// LIVE PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Live page...');
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(3000);
// VCR controls
await safeClick('#vcrPauseBtn');
await page.waitForTimeout(400);
await safeClick('#vcrPauseBtn');
await page.waitForTimeout(400);
// VCR speed cycle
await safeClick('#vcrSpeedBtn');
await page.waitForTimeout(300);
await safeClick('#vcrSpeedBtn');
await page.waitForTimeout(300);
await safeClick('#vcrSpeedBtn');
await page.waitForTimeout(300);
// VCR mode / missed
await safeClick('#vcrMissed');
await page.waitForTimeout(300);
// VCR prompt buttons
await safeClick('#vcrPromptReplay');
await page.waitForTimeout(300);
await safeClick('#vcrPromptSkip');
await page.waitForTimeout(300);
// Toggle visualization options
await safeClick('#liveHeatToggle');
await page.waitForTimeout(400);
await safeClick('#liveHeatToggle');
await page.waitForTimeout(300);
await safeClick('#liveGhostToggle');
await page.waitForTimeout(300);
await safeClick('#liveGhostToggle');
await page.waitForTimeout(300);
await safeClick('#liveRealisticToggle');
await page.waitForTimeout(300);
await safeClick('#liveRealisticToggle');
await page.waitForTimeout(300);
await safeClick('#liveFavoritesToggle');
await page.waitForTimeout(300);
await safeClick('#liveFavoritesToggle');
await page.waitForTimeout(300);
await safeClick('#liveMatrixToggle');
await page.waitForTimeout(300);
await safeClick('#liveMatrixToggle');
await page.waitForTimeout(300);
await safeClick('#liveMatrixRainToggle');
await page.waitForTimeout(300);
await safeClick('#liveMatrixRainToggle');
await page.waitForTimeout(300);
// Audio toggle and controls
await safeClick('#liveAudioToggle');
await page.waitForTimeout(400);
try {
await page.fill('#audioBpmSlider', '120');
await page.waitForTimeout(300);
// Dispatch input event on slider
await page.evaluate(() => {
const s = document.getElementById('audioBpmSlider');
if (s) { s.value = '140'; s.dispatchEvent(new Event('input', { bubbles: true })); }
});
await page.waitForTimeout(300);
} catch {}
await safeClick('#liveAudioToggle');
await page.waitForTimeout(300);
// VCR timeline click
try {
await page.evaluate(() => {
const canvas = document.getElementById('vcrTimeline');
if (canvas) {
const rect = canvas.getBoundingClientRect();
canvas.dispatchEvent(new MouseEvent('click', {
clientX: rect.left + rect.width * 0.5,
clientY: rect.top + rect.height * 0.5,
bubbles: true
}));
}
});
await page.waitForTimeout(500);
} catch {}
// VCR LCD canvas
try {
await page.evaluate(() => {
const canvas = document.getElementById('vcrLcdCanvas');
if (canvas) canvas.getContext('2d');
});
await page.waitForTimeout(300);
} catch {}
// Resize the live page panel
try {
await page.evaluate(() => {
window.dispatchEvent(new Event('resize'));
});
await page.waitForTimeout(300);
} catch {}
// ══════════════════════════════════════════════
// TRACES PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Traces page...');
await page.goto(`${BASE}/#/traces`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await clickAll('table tbody tr', 3);
// ══════════════════════════════════════════════
// OBSERVERS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Observers page...');
await page.goto(`${BASE}/#/observers`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
// Click observer rows
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
try { await obsRows[i].click(); await page.waitForTimeout(500); } catch {}
}
// Navigate to observer detail page
try {
const obsLink = await page.$('a[href*="/observers/"]');
if (obsLink) {
await obsLink.click();
await page.waitForTimeout(2000);
// Change days select
await cycleSelect('#obsDaysSelect');
}
} catch {}
// ══════════════════════════════════════════════
// PERF PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Perf page...');
await page.goto(`${BASE}/#/perf`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await safeClick('#perfRefresh');
await page.waitForTimeout(1000);
await safeClick('#perfReset');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════
// APP.JS — Router, theme, global features
// ══════════════════════════════════════════════
console.log(' [coverage] App.js — router + global...');
// Navigate to bad route to trigger error/404
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1000);
// Navigate to every route via hash
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
for (const route of allRoutes) {
try {
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
await page.waitForTimeout(800);
} catch {}
}
// Trigger hashchange manually
try {
await page.evaluate(() => {
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
await page.waitForTimeout(500);
} catch {}
// Theme toggle multiple times
for (let i = 0; i < 4; i++) {
await safeClick('#darkModeToggle');
await page.waitForTimeout(300);
}
// Dispatch theme-changed event
try {
await page.evaluate(() => {
window.dispatchEvent(new Event('theme-changed'));
});
await page.waitForTimeout(300);
} catch {}
// Hamburger menu
await safeClick('#hamburger');
await page.waitForTimeout(400);
// Click nav links in mobile menu
await clickAll('.nav-links .nav-link', 5);
await page.waitForTimeout(300);
// Favorites
await safeClick('#favToggle');
await page.waitForTimeout(500);
await clickAll('.fav-dd-item', 3);
// Click outside to close
try { await page.evaluate(() => document.body.click()); await page.waitForTimeout(300); } catch {}
await safeClick('#favToggle');
await page.waitForTimeout(300);
// Global search
await safeClick('#searchToggle');
await page.waitForTimeout(500);
await safeFill('#searchInput', 'test');
await page.waitForTimeout(1000);
// Click search result items
await clickAll('.search-result-item', 3);
await page.waitForTimeout(500);
// Close search
try { await page.keyboard.press('Escape'); } catch {}
await page.waitForTimeout(300);
// Ctrl+K shortcut
try {
await page.keyboard.press('Control+k');
await page.waitForTimeout(500);
await safeFill('#searchInput', 'node');
await page.waitForTimeout(800);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
} catch {}
// Click search overlay background to close
try {
await safeClick('#searchToggle');
await page.waitForTimeout(300);
await page.click('#searchOverlay', { position: { x: 5, y: 5 } });
await page.waitForTimeout(300);
} catch {}
// Navigate via nav links with data-route
for (const route of allRoutes) {
await safeClick(`a[data-route="${route}"]`);
await page.waitForTimeout(600);
}
// Exercise apiPerf console function
try {
await page.evaluate(() => { if (window.apiPerf) window.apiPerf(); });
await page.waitForTimeout(300);
} catch {}
// Exercise utility functions
try {
await page.evaluate(() => {
// timeAgo with various inputs
if (typeof timeAgo === 'function') {
timeAgo(null);
timeAgo(new Date().toISOString());
timeAgo(new Date(Date.now() - 30000).toISOString());
timeAgo(new Date(Date.now() - 3600000).toISOString());
timeAgo(new Date(Date.now() - 86400000 * 2).toISOString());
}
// truncate
if (typeof truncate === 'function') {
truncate('hello world', 5);
truncate(null, 5);
truncate('hi', 10);
}
// routeTypeName, payloadTypeName, payloadTypeColor
if (typeof routeTypeName === 'function') {
for (let i = 0; i <= 4; i++) routeTypeName(i);
}
if (typeof payloadTypeName === 'function') {
for (let i = 0; i <= 15; i++) payloadTypeName(i);
}
if (typeof payloadTypeColor === 'function') {
for (let i = 0; i <= 15; i++) payloadTypeColor(i);
}
// invalidateApiCache
if (typeof invalidateApiCache === 'function') {
invalidateApiCache();
invalidateApiCache('/test');
}
});
await page.waitForTimeout(300);
} catch {}
// ══════════════════════════════════════════════
// PACKET FILTER — exercise the filter parser
// ══════════════════════════════════════════════
console.log(' [coverage] Packet filter parser...');
try {
await page.evaluate(() => {
if (window.PacketFilter && window.PacketFilter.compile) {
const PF = window.PacketFilter;
// Valid expressions
const exprs = [
'type == ADVERT', 'type == GRP_TXT', 'type != ACK',
'snr > 0', 'snr < -5', 'snr >= 10', 'snr <= 3',
'hops > 1', 'hops == 0', 'rssi < -80',
'route == FLOOD', 'route == DIRECT', 'route == TRANSPORT_FLOOD',
'type == ADVERT && snr > 0', 'type == TXT_MSG || type == GRP_TXT',
'!type == ACK', 'NOT type == ADVERT',
'type == ADVERT && (snr > 0 || hops > 1)',
'observer == "test"', 'from == "abc"', 'to == "xyz"',
'has_text', 'is_encrypted',
'type contains ADV',
];
for (const e of exprs) {
try { PF.compile(e); } catch {}
}
// Bad expressions
const bad = ['@@@', '== ==', '(((', 'type ==', ''];
for (const e of bad) {
try { PF.compile(e); } catch {}
}
}
});
} catch {}
// ══════════════════════════════════════════════
// REGION FILTER — exercise
// ══════════════════════════════════════════════
console.log(' [coverage] Region filter...');
try {
// Open region filter on nodes page
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await safeClick('#nodesRegionFilter');
await page.waitForTimeout(300);
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
await page.waitForTimeout(300);
} catch {}
// Region filter on packets
try {
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await safeClick('#packetsRegionFilter');
await page.waitForTimeout(300);
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
await page.waitForTimeout(300);
} catch {}
// ══════════════════════════════════════════════
// FINAL — navigate through all routes once more
// ══════════════════════════════════════════════
console.log(' [coverage] Final route sweep...');
for (const route of allRoutes) {
try {
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
await page.waitForTimeout(500);
} catch {}
}
// Extract coverage
const coverage = await page.evaluate(() => window.__coverage__);
await browser.close();
if (coverage) {
const outDir = path.join(__dirname, '..', '.nyc_output');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'frontend-coverage.json'), JSON.stringify(coverage));
console.log('Frontend coverage collected: ' + Object.keys(coverage).length + ' files');
} else {
console.log('WARNING: No __coverage__ object found — instrumentation may have failed');
}
}
collectCoverage().catch(e => { console.error(e); process.exit(1); });
-27
View File
@@ -1,27 +0,0 @@
#!/bin/sh
# Run server-side tests with c8, then frontend coverage with nyc
set -e
# 1. Server-side coverage (existing)
npx c8 --reporter=json --reports-dir=.nyc_output node tools/e2e-test.js
# 2. Instrument frontend
sh scripts/instrument-frontend.sh
# 3. Start instrumented server
COVERAGE=1 PORT=13581 node server.js &
SERVER_PID=$!
sleep 5
# 4. Run Playwright tests (exercises frontend code)
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
BASE_URL=http://localhost:13581 node test-e2e-interactions.js || true
# 5. Collect browser coverage
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js
# 6. Kill server
kill $SERVER_PID 2>/dev/null || true
# 7. Generate combined report
npx nyc report --reporter=text-summary --reporter=text
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# Instrument frontend JS for coverage tracking
rm -rf public-instrumented
npx nyc instrument public/ public-instrumented/ --compact=false
# Copy non-JS files (CSS, HTML, images) as-is
cp public/*.css public-instrumented/ 2>/dev/null
cp public/*.html public-instrumented/ 2>/dev/null
cp public/*.svg public-instrumented/ 2>/dev/null
cp public/*.png public-instrumented/ 2>/dev/null
echo "Frontend instrumented successfully"
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env node
/**
* Milestone 1: Packet Dedup Schema Migration
*
* Creates `transmissions` and `observations` tables from the existing `packets` table.
* Idempotent drops and recreates new tables on each run.
* Does NOT touch the original `packets` table.
*
* Usage: node scripts/migrate-dedup.js <path-to-meshcore.db>
*/
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = process.argv[2];
if (!dbPath) {
console.error('Usage: node scripts/migrate-dedup.js <path-to-meshcore.db>');
process.exit(1);
}
const start = Date.now();
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// --- Drop existing new tables (idempotent) ---
console.log('Dropping existing transmissions/observations tables if they exist...');
db.exec('DROP TABLE IF EXISTS observations');
db.exec('DROP TABLE IF EXISTS transmissions');
// --- Create new tables ---
console.log('Creating transmissions and observations tables...');
db.exec(`
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
hash TEXT NOT NULL,
observer_id TEXT,
observer_name TEXT,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_transmissions_hash ON transmissions(hash);
CREATE INDEX idx_transmissions_first_seen ON transmissions(first_seen);
CREATE INDEX idx_transmissions_payload_type ON transmissions(payload_type);
CREATE INDEX idx_observations_hash ON observations(hash);
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_id ON observations(observer_id);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
`);
// --- Read all packets ordered by timestamp ---
console.log('Reading packets...');
const packets = db.prepare('SELECT * FROM packets ORDER BY timestamp ASC').all();
const totalPackets = packets.length;
console.log(`Total packets: ${totalPackets}`);
// --- Group by hash and migrate ---
const insertTransmission = db.prepare(`
INSERT OR IGNORE INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertObservation = db.prepare(`
INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const hashToTransmissionId = new Map();
let transmissionCount = 0;
const lookupTransmission = db.prepare('SELECT id FROM transmissions WHERE hash = ?');
const migrate = db.transaction(() => {
for (const pkt of packets) {
let txId = hashToTransmissionId.get(pkt.hash);
if (txId === undefined) {
const result = insertTransmission.run(
pkt.raw_hex, pkt.hash, pkt.timestamp,
pkt.route_type, pkt.payload_type, pkt.payload_version, pkt.decoded_json
);
if (result.changes > 0) {
txId = result.lastInsertRowid;
} else {
// Already inserted by dual-write, look up existing
txId = lookupTransmission.get(pkt.hash).id;
}
hashToTransmissionId.set(pkt.hash, txId);
transmissionCount++;
}
insertObservation.run(
txId, pkt.hash, pkt.observer_id, pkt.observer_name, pkt.direction,
pkt.snr, pkt.rssi, pkt.score, pkt.path_json, pkt.timestamp
);
}
});
migrate();
// --- Verify ---
const obsCount = db.prepare('SELECT COUNT(*) as c FROM observations').get().c;
const txCount = db.prepare('SELECT COUNT(*) as c FROM transmissions').get().c;
const distinctHash = db.prepare('SELECT COUNT(DISTINCT hash) as c FROM packets').get().c;
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
console.log('\n=== Migration Stats ===');
console.log(`Total packets (source): ${totalPackets}`);
console.log(`Unique transmissions created: ${transmissionCount}`);
console.log(`Observations created: ${obsCount}`);
console.log(`Dedup ratio: ${(totalPackets / transmissionCount).toFixed(2)}x`);
console.log(`Time taken: ${elapsed}s`);
console.log('\n=== Verification ===');
const obsOk = obsCount === totalPackets;
const txOk = txCount === distinctHash;
console.log(`observations (${obsCount}) = packets (${totalPackets}): ${obsOk ? 'PASS ✓' : 'FAIL ✗'}`);
console.log(`transmissions (${txCount}) = distinct hashes (${distinctHash}): ${txOk ? 'PASS ✓' : 'FAIL ✗'}`);
if (!obsOk || !txOk) {
console.error('\nVerification FAILED!');
process.exit(1);
}
console.log('\nMigration complete!');
db.close();
-30
View File
@@ -1,30 +0,0 @@
#!/bin/sh
# Pre-push validation — catches common JS errors before they hit prod
set -e
echo "=== Syntax check ==="
node -c server.js
for f in public/*.js; do node -c "$f"; done
echo "✅ All JS files parse OK"
echo "=== Checking for undefined common references ==="
ERRORS=0
# esc() should only exist inside IIFEs that define it, not in files that don't
for f in public/live.js public/map.js public/home.js public/nodes.js public/channels.js public/observers.js; do
if grep -q '\besc(' "$f" 2>/dev/null && ! grep -q 'function esc' "$f" 2>/dev/null; then
REFS=$(grep -n '\besc(' "$f" | grep -v escapeHtml | grep -v "desc\|Esc\|resc\|safeEsc" || true)
if [ -n "$REFS" ]; then
echo "$f uses esc() but doesn't define it:"
echo "$REFS"
ERRORS=$((ERRORS + 1))
fi
fi
done
if [ "$ERRORS" -gt 0 ]; then
echo "$ERRORS validation error(s) found"
exit 1
fi
echo "✅ Validation passed"
-322
View File
@@ -1,322 +0,0 @@
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
// Config file loading
const CONFIG_PATHS = [
path.join(__dirname, 'config.json'),
path.join(__dirname, 'data', 'config.json')
];
function loadConfigFile(configPaths) {
const paths = configPaths || CONFIG_PATHS;
for (const p of paths) {
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
}
return {};
}
// Theme file loading
const THEME_PATHS = [
path.join(__dirname, 'theme.json'),
path.join(__dirname, 'data', 'theme.json')
];
function loadThemeFile(themePaths) {
const paths = themePaths || THEME_PATHS;
for (const p of paths) {
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
}
return {};
}
// Health thresholds
function buildHealthConfig(config) {
const _ht = (config && config.healthThresholds) || {};
return {
infraDegradedMs: _ht.infraDegradedMs || 86400000,
infraSilentMs: _ht.infraSilentMs || 259200000,
nodeDegradedMs: _ht.nodeDegradedMs || 3600000,
nodeSilentMs: _ht.nodeSilentMs || 86400000
};
}
function getHealthMs(role, HEALTH) {
const isInfra = role === 'repeater' || role === 'room';
return {
degradedMs: isInfra ? HEALTH.infraDegradedMs : HEALTH.nodeDegradedMs,
silentMs: isInfra ? HEALTH.infraSilentMs : HEALTH.nodeSilentMs
};
}
// Hash size flip-flop detection (pure — operates on provided maps)
function isHashSizeFlipFlop(seq, allSizes) {
if (!seq || seq.length < 3) return false;
if (!allSizes || allSizes.size < 2) return false;
let transitions = 0;
for (let i = 1; i < seq.length; i++) {
if (seq[i] !== seq[i - 1]) transitions++;
}
return transitions >= 2;
}
// Compute content hash from raw hex
function computeContentHash(rawHex) {
try {
const buf = Buffer.from(rawHex, 'hex');
if (buf.length < 2) return rawHex.slice(0, 16);
const pathByte = buf[1];
const hashSize = ((pathByte >> 6) & 0x3) + 1;
const hashCount = pathByte & 0x3F;
const pathBytes = hashSize * hashCount;
const payloadStart = 2 + pathBytes;
const payload = buf.subarray(payloadStart);
const toHash = Buffer.concat([Buffer.from([buf[0]]), payload]);
return crypto.createHash('sha256').update(toHash).digest('hex').slice(0, 16);
} catch { return rawHex.slice(0, 16); }
}
// Distance helper (degrees)
function geoDist(lat1, lon1, lat2, lon2) {
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
}
// Derive hashtag channel key
function deriveHashtagChannelKey(channelName) {
return crypto.createHash('sha256').update(channelName).digest('hex').slice(0, 32);
}
// Build hex breakdown ranges for packet detail view
function buildBreakdown(rawHex, decoded, decodePacketFn, channelKeys) {
if (!rawHex) return {};
const buf = Buffer.from(rawHex, 'hex');
const ranges = [];
ranges.push({ start: 0, end: 0, color: 'red', label: 'Header' });
if (buf.length < 2) return { ranges };
ranges.push({ start: 1, end: 1, color: 'orange', label: 'Path Length' });
const header = decodePacketFn ? decodePacketFn(rawHex, channelKeys || {}) : null;
let offset = 2;
if (header && header.transportCodes) {
ranges.push({ start: 2, end: 5, color: 'blue', label: 'Transport Codes' });
offset = 6;
}
const pathByte = buf[1];
const hashSize = (pathByte >> 6) + 1;
const hashCount = pathByte & 0x3F;
const pathBytes = hashSize * hashCount;
if (pathBytes > 0) {
ranges.push({ start: offset, end: offset + pathBytes - 1, color: 'green', label: 'Path' });
}
const payloadStart = offset + pathBytes;
if (payloadStart < buf.length) {
ranges.push({ start: payloadStart, end: buf.length - 1, color: 'yellow', label: 'Payload' });
if (decoded && decoded.type === 'ADVERT') {
const ps = payloadStart;
const subRanges = [];
subRanges.push({ start: ps, end: ps + 31, color: '#FFD700', label: 'PubKey' });
subRanges.push({ start: ps + 32, end: ps + 35, color: '#FFA500', label: 'Timestamp' });
subRanges.push({ start: ps + 36, end: ps + 99, color: '#FF6347', label: 'Signature' });
if (buf.length > ps + 100) {
subRanges.push({ start: ps + 100, end: ps + 100, color: '#7FFFD4', label: 'Flags' });
let off = ps + 101;
const flags = buf[ps + 100];
if (flags & 0x10 && buf.length >= off + 8) {
subRanges.push({ start: off, end: off + 3, color: '#87CEEB', label: 'Latitude' });
subRanges.push({ start: off + 4, end: off + 7, color: '#87CEEB', label: 'Longitude' });
off += 8;
}
if (flags & 0x80 && off < buf.length) {
subRanges.push({ start: off, end: buf.length - 1, color: '#DDA0DD', label: 'Name' });
}
}
ranges.push(...subRanges);
}
}
return { ranges };
}
// Disambiguate hop prefixes to full nodes
function disambiguateHops(hops, allNodes, maxHopDist) {
const MAX_HOP_DIST = maxHopDist || 1.8;
if (!allNodes._prefixIdx) {
allNodes._prefixIdx = {};
allNodes._prefixIdxName = {};
for (const n of allNodes) {
const pk = n.public_key.toLowerCase();
for (let len = 1; len <= 3; len++) {
const p = pk.slice(0, len * 2);
if (!allNodes._prefixIdx[p]) allNodes._prefixIdx[p] = [];
allNodes._prefixIdx[p].push(n);
if (!allNodes._prefixIdxName[p]) allNodes._prefixIdxName[p] = n;
}
}
}
const resolved = hops.map(hop => {
const h = hop.toLowerCase();
const withCoords = (allNodes._prefixIdx[h] || []).filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0));
if (withCoords.length === 1) {
return { hop, name: withCoords[0].name, lat: withCoords[0].lat, lon: withCoords[0].lon, pubkey: withCoords[0].public_key, known: true };
} else if (withCoords.length > 1) {
return { hop, name: hop, lat: null, lon: null, pubkey: null, known: false, candidates: withCoords };
}
const nameMatch = allNodes._prefixIdxName[h];
return { hop, name: nameMatch?.name || hop, lat: null, lon: null, pubkey: nameMatch?.public_key || null, known: false };
});
let lastPos = null;
for (const r of resolved) {
if (r.known && r.lat) { lastPos = [r.lat, r.lon]; continue; }
if (!r.candidates) continue;
if (lastPos) r.candidates.sort((a, b) => geoDist(a.lat, a.lon, lastPos[0], lastPos[1]) - geoDist(b.lat, b.lon, lastPos[0], lastPos[1]));
const best = r.candidates[0];
r.name = best.name; r.lat = best.lat; r.lon = best.lon; r.pubkey = best.public_key; r.known = true;
lastPos = [r.lat, r.lon];
}
let nextPos = null;
for (let i = resolved.length - 1; i >= 0; i--) {
const r = resolved[i];
if (r.known && r.lat) { nextPos = [r.lat, r.lon]; continue; }
if (!r.candidates || !nextPos) continue;
r.candidates.sort((a, b) => geoDist(a.lat, a.lon, nextPos[0], nextPos[1]) - geoDist(b.lat, b.lon, nextPos[0], nextPos[1]));
const best = r.candidates[0];
r.name = best.name; r.lat = best.lat; r.lon = best.lon; r.pubkey = best.public_key; r.known = true;
nextPos = [r.lat, r.lon];
}
// Distance sanity check
for (let i = 0; i < resolved.length; i++) {
const r = resolved[i];
if (!r.lat) continue;
const prev = i > 0 && resolved[i-1].lat ? resolved[i-1] : null;
const next = i < resolved.length-1 && resolved[i+1].lat ? resolved[i+1] : null;
if (!prev && !next) continue;
const dPrev = prev ? geoDist(r.lat, r.lon, prev.lat, prev.lon) : 0;
const dNext = next ? geoDist(r.lat, r.lon, next.lat, next.lon) : 0;
if ((prev && dPrev > MAX_HOP_DIST) && (next && dNext > MAX_HOP_DIST)) { r.unreliable = true; r.lat = null; r.lon = null; }
else if (prev && !next && dPrev > MAX_HOP_DIST) { r.unreliable = true; r.lat = null; r.lon = null; }
else if (!prev && next && dNext > MAX_HOP_DIST) { r.unreliable = true; r.lat = null; r.lon = null; }
}
return resolved.map(r => ({ hop: r.hop, name: r.name, lat: r.lat, lon: r.lon, pubkey: r.pubkey, known: !!r.known, ambiguous: !!r.candidates, unreliable: !!r.unreliable }));
}
// Update hash_size maps for a single packet
function updateHashSizeForPacket(p, hashSizeMap, hashSizeAllMap, hashSizeSeqMap) {
if (p.payload_type === 4 && p.raw_hex) {
try {
const d = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json || '{}') : (p.decoded_json || {});
const pk = d.pubKey || d.public_key;
if (pk) {
const pathByte = parseInt(p.raw_hex.slice(2, 4), 16);
const hs = ((pathByte >> 6) & 0x3) + 1;
hashSizeMap.set(pk, hs);
if (!hashSizeAllMap.has(pk)) hashSizeAllMap.set(pk, new Set());
hashSizeAllMap.get(pk).add(hs);
if (!hashSizeSeqMap.has(pk)) hashSizeSeqMap.set(pk, []);
hashSizeSeqMap.get(pk).push(hs);
}
} catch {}
} else if (p.path_json && p.decoded_json) {
try {
const d = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : p.decoded_json;
const pk = d.pubKey || d.public_key;
if (pk && !hashSizeMap.has(pk)) {
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : p.path_json;
if (hops.length > 0) {
const pathByte = p.raw_hex ? parseInt(p.raw_hex.slice(2, 4), 16) : -1;
const hs = pathByte >= 0 ? ((pathByte >> 6) & 0x3) + 1 : (hops[0].length / 2);
if (hs >= 1 && hs <= 4) hashSizeMap.set(pk, hs);
}
}
} catch {}
}
}
// Rebuild all hash size maps from packet store
function rebuildHashSizeMap(packets, hashSizeMap, hashSizeAllMap, hashSizeSeqMap) {
hashSizeMap.clear();
hashSizeAllMap.clear();
hashSizeSeqMap.clear();
// Pass 1: ADVERT packets
for (const p of packets) {
if (p.payload_type === 4 && p.raw_hex) {
try {
const d = JSON.parse(p.decoded_json || '{}');
const pk = d.pubKey || d.public_key;
if (pk) {
const pathByte = parseInt(p.raw_hex.slice(2, 4), 16);
const hs = ((pathByte >> 6) & 0x3) + 1;
if (!hashSizeMap.has(pk)) hashSizeMap.set(pk, hs);
if (!hashSizeAllMap.has(pk)) hashSizeAllMap.set(pk, new Set());
hashSizeAllMap.get(pk).add(hs);
if (!hashSizeSeqMap.has(pk)) hashSizeSeqMap.set(pk, []);
hashSizeSeqMap.get(pk).push(hs);
}
} catch {}
}
}
for (const [, seq] of hashSizeSeqMap) seq.reverse();
// Pass 2: fallback from path hops
for (const p of packets) {
if (p.path_json) {
try {
const hops = JSON.parse(p.path_json);
if (hops.length > 0) {
const hopLen = hops[0].length / 2;
if (hopLen >= 1 && hopLen <= 4) {
const pathByte = p.raw_hex ? parseInt(p.raw_hex.slice(2, 4), 16) : -1;
const hs = pathByte >= 0 ? ((pathByte >> 6) & 0x3) + 1 : hopLen;
if (p.decoded_json) {
const d = JSON.parse(p.decoded_json);
const pk = d.pubKey || d.public_key;
if (pk && !hashSizeMap.has(pk)) hashSizeMap.set(pk, hs);
}
}
}
} catch {}
}
}
}
// API key middleware factory
function requireApiKey(apiKey) {
return function(req, res, next) {
if (!apiKey) return next();
const provided = req.headers['x-api-key'] || req.query.apiKey;
if (provided === apiKey) return next();
return res.status(401).json({ error: 'Invalid or missing API key' });
};
}
module.exports = {
loadConfigFile,
loadThemeFile,
buildHealthConfig,
getHealthMs,
isHashSizeFlipFlop,
computeContentHash,
geoDist,
deriveHashtagChannelKey,
buildBreakdown,
disambiguateHops,
updateHashSizeForPacket,
rebuildHashSizeMap,
requireApiKey,
CONFIG_PATHS,
THEME_PATHS
};
+514 -1802
View File
File diff suppressed because it is too large Load Diff
-189
View File
@@ -1,189 +0,0 @@
/* Unit tests for node aging system */
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
// Load roles.js in a sandboxed context
const ctx = { window: {}, console, Date, Infinity, document: { readyState: 'complete', createElement: () => ({ id: '' }), head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {} }, fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }) };
vm.createContext(ctx);
vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx);
// The IIFE assigns to window.*, but the functions reference HEALTH_THRESHOLDS as a bare global
// In the VM context, window.X doesn't create a global X, so we need to copy them
for (const k of Object.keys(ctx.window)) {
ctx[k] = ctx.window[k];
}
const { getNodeStatus, getHealthThresholds, HEALTH_THRESHOLDS } = ctx.window;
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
console.log('\n=== HEALTH_THRESHOLDS ===');
test('infraSilentMs = 72h (259200000)', () => assert.strictEqual(HEALTH_THRESHOLDS.infraSilentMs, 259200000));
test('nodeSilentMs = 24h (86400000)', () => assert.strictEqual(HEALTH_THRESHOLDS.nodeSilentMs, 86400000));
console.log('\n=== getHealthThresholds ===');
test('repeater uses infra thresholds', () => {
const t = getHealthThresholds('repeater');
assert.strictEqual(t.silentMs, 259200000);
});
test('room uses infra thresholds', () => {
const t = getHealthThresholds('room');
assert.strictEqual(t.silentMs, 259200000);
});
test('companion uses node thresholds', () => {
const t = getHealthThresholds('companion');
assert.strictEqual(t.silentMs, 86400000);
});
console.log('\n=== getNodeStatus ===');
const now = Date.now();
const h = 3600000;
test('repeater seen 1h ago → active', () => assert.strictEqual(getNodeStatus('repeater', now - 1*h), 'active'));
test('repeater seen 71h ago → active', () => assert.strictEqual(getNodeStatus('repeater', now - 71*h), 'active'));
test('repeater seen 73h ago → stale', () => assert.strictEqual(getNodeStatus('repeater', now - 73*h), 'stale'));
test('room seen 73h ago → stale (same as repeater)', () => assert.strictEqual(getNodeStatus('room', now - 73*h), 'stale'));
test('companion seen 1h ago → active', () => assert.strictEqual(getNodeStatus('companion', now - 1*h), 'active'));
test('companion seen 23h ago → active', () => assert.strictEqual(getNodeStatus('companion', now - 23*h), 'active'));
test('companion seen 25h ago → stale', () => assert.strictEqual(getNodeStatus('companion', now - 25*h), 'stale'));
test('sensor seen 25h ago → stale', () => assert.strictEqual(getNodeStatus('sensor', now - 25*h), 'stale'));
test('unknown role → uses node (24h) threshold', () => assert.strictEqual(getNodeStatus('unknown', now - 25*h), 'stale'));
test('unknown role seen 23h ago → active', () => assert.strictEqual(getNodeStatus('unknown', now - 23*h), 'active'));
test('null lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', null), 'stale'));
test('undefined lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', undefined), 'stale'));
test('0 lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', 0), 'stale'));
// === getStatusInfo tests (inline since nodes.js has too many DOM deps) ===
console.log('\n=== getStatusInfo (logic validation) ===');
// Simulate getStatusInfo logic
function mockGetStatusInfo(n) {
const ROLE_COLORS = ctx.window.ROLE_COLORS;
const role = (n.role || '').toLowerCase();
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const lastHeardTime = n._lastHeard || n.last_heard || n.last_seen;
const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0;
const status = getNodeStatus(role, lastHeardMs);
const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale';
const isInfra = role === 'repeater' || role === 'room';
let explanation = '';
if (status === 'active') {
explanation = 'Last heard recently';
} else {
const reason = isInfra
? 'repeaters typically advertise every 12-24h'
: 'companions only advertise when user initiates, this may be normal';
explanation = 'Not heard — ' + reason;
}
return { status, statusLabel, roleColor, explanation, role };
}
test('active repeater → 🟢 Active, red color', () => {
const info = mockGetStatusInfo({ role: 'repeater', last_seen: new Date(now - 1*h).toISOString() });
assert.strictEqual(info.status, 'active');
assert.strictEqual(info.statusLabel, '🟢 Active');
assert.strictEqual(info.roleColor, '#dc2626');
});
test('stale companion → ⚪ Stale, explanation mentions "this may be normal"', () => {
const info = mockGetStatusInfo({ role: 'companion', last_seen: new Date(now - 25*h).toISOString() });
assert.strictEqual(info.status, 'stale');
assert.strictEqual(info.statusLabel, '⚪ Stale');
assert(info.explanation.includes('this may be normal'), 'should mention "this may be normal"');
});
test('missing last_seen → stale', () => {
const info = mockGetStatusInfo({ role: 'repeater' });
assert.strictEqual(info.status, 'stale');
});
test('missing role → defaults to empty string, uses node threshold', () => {
const info = mockGetStatusInfo({ last_seen: new Date(now - 25*h).toISOString() });
assert.strictEqual(info.status, 'stale');
assert.strictEqual(info.roleColor, '#6b7280');
});
test('prefers last_heard over last_seen', () => {
// last_seen is stale, but last_heard is recent
const info = mockGetStatusInfo({
role: 'companion',
last_seen: new Date(now - 48*h).toISOString(),
last_heard: new Date(now - 1*h).toISOString()
});
assert.strictEqual(info.status, 'active');
});
// === getStatusTooltip tests ===
console.log('\n=== getStatusTooltip ===');
// Load from nodes.js by extracting the function
// Since nodes.js is complex, I'll re-implement the tooltip function for testing
function getStatusTooltip(role, status) {
const isInfra = role === 'repeater' || role === 'room';
const threshold = isInfra ? '72h' : '24h';
if (status === 'active') {
return 'Active — heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
}
if (role === 'companion') {
return 'Stale — not heard for over ' + threshold + '. Companions only advertise when the user initiates — this may be normal.';
}
if (role === 'sensor') {
return 'Stale — not heard for over ' + threshold + '. This sensor may be offline.';
}
return 'Stale — not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.';
}
test('active repeater mentions "72h" and "advertise every 12-24h"', () => {
const tip = getStatusTooltip('repeater', 'active');
assert(tip.includes('72h'), 'should mention 72h');
assert(tip.includes('advertise every 12-24h'), 'should mention advertise frequency');
});
test('active companion mentions "24h"', () => {
const tip = getStatusTooltip('companion', 'active');
assert(tip.includes('24h'), 'should mention 24h');
});
test('stale companion mentions "24h" and "user initiates"', () => {
const tip = getStatusTooltip('companion', 'stale');
assert(tip.includes('24h'), 'should mention 24h');
assert(tip.includes('user initiates'), 'should mention user initiates');
});
test('stale repeater mentions "offline or out of range"', () => {
const tip = getStatusTooltip('repeater', 'stale');
assert(tip.includes('offline or out of range'), 'should mention offline or out of range');
});
test('stale sensor mentions "sensor may be offline"', () => {
const tip = getStatusTooltip('sensor', 'stale');
assert(tip.includes('sensor may be offline'));
});
test('stale room uses 72h threshold', () => {
const tip = getStatusTooltip('room', 'stale');
assert(tip.includes('72h'));
});
// === Bug check: renderRows uses last_seen instead of last_heard || last_seen ===
console.log('\n=== BUG CHECK ===');
const nodesJs = fs.readFileSync('public/nodes.js', 'utf8');
const renderRowsMatch = nodesJs.match(/const status = getNodeStatus\(n\.role[^;]+/);
if (renderRowsMatch) {
const line = renderRowsMatch[0];
console.log(` renderRows status line: ${line}`);
if (!line.includes('last_heard')) {
console.log(' 🐛 BUG: renderRows() uses only n.last_seen, ignoring n.last_heard!');
console.log(' Should be: n.last_heard || n.last_seen');
}
}
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
process.exit(failed > 0 ? 1 : 0);
-35
View File
@@ -1,35 +0,0 @@
#!/bin/sh
# Run all tests with coverage
set -e
echo "═══════════════════════════════════════"
echo " MeshCore Analyzer — Test Suite"
echo "═══════════════════════════════════════"
echo ""
# Unit tests (deterministic, fast)
echo "── Unit Tests ──"
node test-decoder.js
node test-decoder-spec.js
node test-packet-store.js
node test-packet-filter.js
node test-aging.js
node test-frontend-helpers.js
node test-regional-filter.js
node test-server-helpers.js
node test-server-routes.js
node test-db.js
node test-db-migration.js
# Integration tests (spin up temp servers)
echo ""
echo "── Integration Tests ──"
node tools/e2e-test.js
node tools/frontend-test.js
echo ""
echo "═══════════════════════════════════════"
echo " All tests passed"
echo "═══════════════════════════════════════"
node test-server-routes.js
# test trigger
-321
View File
@@ -1,321 +0,0 @@
'use strict';
// Test v3 migration: create old-schema DB, run db.js to migrate, verify results
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execSync } = require('child_process');
const Database = require('better-sqlite3');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(`${msg}`); }
else { failed++; console.error(`${msg}`); }
}
console.log('── db.js v3 migration tests ──\n');
// Helper: create a DB with old (v2) schema and test data
function createOldSchemaDB(dbPath) {
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE nodes (
public_key TEXT PRIMARY KEY,
name TEXT,
role TEXT,
lat REAL,
lon REAL,
last_seen TEXT,
first_seen TEXT,
advert_count INTEGER DEFAULT 0
);
CREATE TABLE observers (
id TEXT PRIMARY KEY,
name TEXT,
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0,
model TEXT,
firmware TEXT,
client_version TEXT,
radio TEXT,
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor INTEGER
);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
hash TEXT NOT NULL,
observer_id TEXT,
observer_name TEXT,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_transmissions_hash ON transmissions(hash);
CREATE INDEX idx_observations_hash ON observations(hash);
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_id ON observations(observer_id);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
CREATE UNIQUE INDEX idx_observations_dedup ON observations(hash, observer_id, COALESCE(path_json, ''));
`);
// Insert test observers
db.prepare(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES (?, ?, ?, ?, ?, ?)`).run(
'aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344', 'Observer Alpha', 'SFO',
'2025-06-01T12:00:00Z', '2025-01-01T00:00:00Z', 100
);
db.prepare(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES (?, ?, ?, ?, ?, ?)`).run(
'deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678', 'Observer Beta', 'LAX',
'2025-06-01T11:00:00Z', '2025-02-01T00:00:00Z', 50
);
// Insert test transmissions
db.prepare(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (?, ?, ?, ?, ?, ?)`).run(
'0400aabbccdd', 'hash-mig-001', '2025-06-01T10:00:00Z', 1, 4, '{"type":"ADVERT"}'
);
db.prepare(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (?, ?, ?, ?, ?, ?)`).run(
'0400deadbeef', 'hash-mig-002', '2025-06-01T10:30:00Z', 2, 5, '{"type":"GRP_TXT"}'
);
// Insert test observations (old schema: has hash, observer_id, observer_name, text timestamp)
db.prepare(`INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
1, 'hash-mig-001', 'aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344', 'Observer Alpha',
'rx', 12.5, -80, 85, '["aabb","ccdd"]', '2025-06-01T10:00:00Z'
);
db.prepare(`INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
1, 'hash-mig-001', 'deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678', 'Observer Beta',
'rx', 8.0, -92, 70, '["aabb"]', '2025-06-01T10:01:00Z'
);
db.prepare(`INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
2, 'hash-mig-002', 'aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344', 'Observer Alpha',
'rx', 15.0, -75, 90, null, '2025-06-01T10:30:00Z'
);
db.close();
}
// Helper: require db.js in a child process with a given DB_PATH, return schema info
function runDbModule(dbPath) {
const scriptPath = path.join(os.tmpdir(), 'meshcore-mig-test-script.js');
fs.writeFileSync(scriptPath, `
process.env.DB_PATH = ${JSON.stringify(dbPath)};
const db = require(${JSON.stringify(path.resolve(__dirname, 'db'))});
const cols = db.db.pragma('table_info(observations)').map(c => c.name);
const sv = db.db.pragma('user_version', { simple: true });
const obsCount = db.db.prepare('SELECT COUNT(*) as c FROM observations').get().c;
const viewRows = db.db.prepare('SELECT * FROM packets_v ORDER BY id').all();
const rawObs = db.db.prepare('SELECT * FROM observations ORDER BY id').all();
console.log(JSON.stringify({
columns: cols,
schemaVersion: sv || 0,
obsCount,
viewRows,
rawObs
}));
db.db.close();
`);
const result = execSync(`node ${JSON.stringify(scriptPath)}`, {
cwd: __dirname,
encoding: 'utf8',
timeout: 30000,
});
fs.unlinkSync(scriptPath);
const lines = result.trim().split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
try { return JSON.parse(lines[i]); } catch {}
}
throw new Error('No JSON output from child process: ' + result);
}
// --- Test 1: Migration from old schema ---
console.log('Migration from old schema:');
{
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshcore-mig-test-'));
const dbPath = path.join(tmpDir, 'test-mig.db');
createOldSchemaDB(dbPath);
// Run db.js which should trigger migration
const info = runDbModule(dbPath);
// Verify schema
assert(info.schemaVersion === 3, 'schema version is 3 after migration');
assert(info.columns.includes('observer_idx'), 'has observer_idx column');
assert(!info.columns.includes('observer_id'), 'no observer_id column');
assert(!info.columns.includes('observer_name'), 'no observer_name column');
assert(!info.columns.includes('hash'), 'no hash column');
// Verify row count
assert(info.obsCount === 3, `all 3 rows migrated (got ${info.obsCount})`);
// Verify raw observation data
const obs0 = info.rawObs[0];
assert(typeof obs0.timestamp === 'number', 'timestamp is integer');
assert(obs0.timestamp === Math.floor(new Date('2025-06-01T10:00:00Z').getTime() / 1000), 'timestamp epoch correct');
assert(obs0.observer_idx !== null, 'observer_idx populated');
// Verify view backward compat
const vr0 = info.viewRows[0];
assert(vr0.observer_id === 'aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344', 'view observer_id correct');
assert(vr0.observer_name === 'Observer Alpha', 'view observer_name correct');
assert(typeof vr0.timestamp === 'string', 'view timestamp is string');
assert(vr0.hash === 'hash-mig-001', 'view hash correct');
assert(vr0.snr === 12.5, 'view snr correct');
assert(vr0.path_json === '["aabb","ccdd"]', 'view path_json correct');
// Third row has null path_json
const vr2 = info.viewRows[2];
assert(vr2.path_json === null, 'null path_json preserved');
// Verify backup file created
const backups1 = fs.readdirSync(tmpDir).filter(f => f.includes('.pre-v3-backup-'));
assert(backups1.length === 1, 'backup file exists');
fs.rmSync(tmpDir, { recursive: true });
}
// --- Test 2: Migration doesn't re-run ---
console.log('\nMigration idempotency:');
{
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshcore-mig-test2-'));
const dbPath = path.join(tmpDir, 'test-mig2.db');
createOldSchemaDB(dbPath);
// First run — triggers migration
let info = runDbModule(dbPath);
assert(info.schemaVersion === 3, 'first run migrates to v3');
// Second run — should NOT re-run migration (no backup overwrite, same data)
const backups2pre = fs.readdirSync(tmpDir).filter(f => f.includes('.pre-v3-backup-'));
const backupMtime = fs.statSync(path.join(tmpDir, backups2pre[0])).mtimeMs;
info = runDbModule(dbPath);
assert(info.schemaVersion === 3, 'second run still v3');
assert(info.obsCount === 3, 'rows still intact');
fs.rmSync(tmpDir, { recursive: true });
}
// --- Test 3: Each migration creates a unique backup ---
console.log('\nUnique backup per migration:');
{
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshcore-mig-test3-'));
const dbPath = path.join(tmpDir, 'test-mig3.db');
createOldSchemaDB(dbPath);
const info = runDbModule(dbPath);
// Migration should have completed
assert(info.columns.includes('observer_idx'), 'migration completed');
assert(info.schemaVersion === 3, 'schema version is 3');
// A timestamped backup should exist
const backups = fs.readdirSync(tmpDir).filter(f => f.includes('.pre-v3-backup-'));
assert(backups.length === 1, 'exactly one backup created');
assert(fs.statSync(path.join(tmpDir, backups[0])).size > 0, 'backup is non-empty');
fs.rmSync(tmpDir, { recursive: true });
}
// --- Test 4: v3 ingestion via child process ---
console.log('\nv3 ingestion test:');
{
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshcore-mig-test4-'));
const dbPath = path.join(tmpDir, 'test-v3-ingest.db');
const scriptPath = path.join(os.tmpdir(), 'meshcore-ingest-test-script.js');
fs.writeFileSync(scriptPath, `
process.env.DB_PATH = ${JSON.stringify(dbPath)};
const db = require(${JSON.stringify(path.resolve(__dirname, 'db'))});
db.upsertObserver({ id: 'test-obs', name: 'Test Obs' });
const r = db.insertTransmission({
raw_hex: '0400ff',
hash: 'h-001',
timestamp: '2025-06-01T12:00:00Z',
observer_id: 'test-obs',
observer_name: 'Test Obs',
direction: 'rx',
snr: 10,
rssi: -85,
path_json: '["aa"]',
route_type: 1,
payload_type: 4,
});
const r2 = db.insertTransmission({
raw_hex: '0400ff',
hash: 'h-001',
timestamp: '2025-06-01T12:00:00Z',
observer_id: 'test-obs',
direction: 'rx',
snr: 10,
rssi: -85,
path_json: '["aa"]',
});
const pkt = db.db.prepare('SELECT * FROM packets_v WHERE hash = ?').get('h-001');
console.log(JSON.stringify({
r1_ok: r !== null && r.transmissionId > 0,
r2_deduped: r2.observationId === 0,
obs_count: db.db.prepare('SELECT COUNT(*) as c FROM observations').get().c,
view_observer_id: pkt.observer_id,
view_observer_name: pkt.observer_name,
view_ts_type: typeof pkt.timestamp,
}));
db.db.close();
`);
const result = execSync(`node ${JSON.stringify(scriptPath)}`, {
cwd: __dirname, encoding: 'utf8', timeout: 30000,
});
fs.unlinkSync(scriptPath);
const lines = result.trim().split('\n');
let info;
for (let i = lines.length - 1; i >= 0; i--) {
try { info = JSON.parse(lines[i]); break; } catch {}
}
assert(info.r1_ok, 'first insertion succeeded');
assert(info.r2_deduped, 'duplicate caught by dedup');
assert(info.obs_count === 1, 'only one observation row');
assert(info.view_observer_id === 'test-obs', 'view resolves observer_id');
assert(info.view_observer_name === 'Test Obs', 'view resolves observer_name');
assert(info.view_ts_type === 'string', 'view timestamp is string');
fs.rmSync(tmpDir, { recursive: true });
}
console.log(`\n═══════════════════════════════════════`);
console.log(` PASSED: ${passed}`);
console.log(` FAILED: ${failed}`);
console.log(`═══════════════════════════════════════`);
if (failed > 0) process.exit(1);
-371
View File
@@ -1,371 +0,0 @@
'use strict';
// Test db.js functions with a temp database
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshcore-db-test-'));
const dbPath = path.join(tmpDir, 'test.db');
process.env.DB_PATH = dbPath;
// Now require db.js — it will use our temp DB
const db = require('./db');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(`${msg}`); }
else { failed++; console.error(`${msg}`); }
}
function cleanup() {
try { db.db.close(); } catch {}
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
console.log('── db.js tests ──\n');
// --- Schema ---
console.log('Schema:');
{
const tables = db.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name);
assert(tables.includes('nodes'), 'nodes table exists');
assert(tables.includes('observers'), 'observers table exists');
assert(tables.includes('transmissions'), 'transmissions table exists');
assert(tables.includes('observations'), 'observations table exists');
}
// --- upsertNode ---
console.log('\nupsertNode:');
{
db.upsertNode({ public_key: 'aabbccdd11223344aabbccdd11223344', name: 'TestNode', role: 'repeater', lat: 37.0, lon: -122.0 });
const node = db.getNode('aabbccdd11223344aabbccdd11223344');
assert(node !== null, 'node inserted');
assert(node.name === 'TestNode', 'name correct');
assert(node.role === 'repeater', 'role correct');
assert(node.lat === 37.0, 'lat correct');
// Update
db.upsertNode({ public_key: 'aabbccdd11223344aabbccdd11223344', name: 'UpdatedNode', role: 'room' });
const node2 = db.getNode('aabbccdd11223344aabbccdd11223344');
assert(node2.name === 'UpdatedNode', 'name updated');
assert(node2.advert_count === 2, 'advert_count incremented');
}
// --- upsertObserver ---
console.log('\nupsertObserver:');
{
db.upsertObserver({ id: 'obs-1', name: 'Observer One', iata: 'SFO' });
const observers = db.getObservers();
assert(observers.length >= 1, 'observer inserted');
assert(observers.some(o => o.id === 'obs-1'), 'observer found by id');
assert(observers.find(o => o.id === 'obs-1').name === 'Observer One', 'observer name correct');
// Upsert again
db.upsertObserver({ id: 'obs-1', name: 'Observer Updated' });
const obs2 = db.getObservers().find(o => o.id === 'obs-1');
assert(obs2.name === 'Observer Updated', 'observer name updated');
assert(obs2.packet_count === 2, 'packet_count incremented');
}
// --- updateObserverStatus ---
console.log('\nupdateObserverStatus:');
{
db.updateObserverStatus({ id: 'obs-2', name: 'Status Observer', iata: 'LAX', model: 'T-Deck' });
const obs = db.getObservers().find(o => o.id === 'obs-2');
assert(obs !== null, 'observer created via status update');
assert(obs.model === 'T-Deck', 'model set');
assert(obs.packet_count === 0, 'packet_count stays 0 for status update');
}
// --- insertTransmission ---
console.log('\ninsertTransmission:');
{
const result = db.insertTransmission({
raw_hex: '0400aabbccdd',
hash: 'hash-001',
timestamp: '2025-01-01T00:00:00Z',
observer_id: 'obs-1',
observer_name: 'Observer One',
direction: 'rx',
snr: 10.5,
rssi: -85,
route_type: 1,
payload_type: 4,
payload_version: 1,
path_json: '["aabb","ccdd"]',
decoded_json: '{"type":"ADVERT","pubKey":"aabbccdd11223344aabbccdd11223344","name":"TestNode"}',
});
assert(result !== null, 'transmission inserted');
assert(result.transmissionId > 0, 'has transmissionId');
assert(result.observationId > 0, 'has observationId');
// Duplicate hash = same transmission, new observation
const result2 = db.insertTransmission({
raw_hex: '0400aabbccdd',
hash: 'hash-001',
timestamp: '2025-01-01T00:01:00Z',
observer_id: 'obs-2',
observer_name: 'Observer Two',
direction: 'rx',
snr: 8.0,
rssi: -90,
route_type: 1,
payload_type: 4,
path_json: '["aabb"]',
decoded_json: '{"type":"ADVERT","pubKey":"aabbccdd11223344aabbccdd11223344","name":"TestNode"}',
});
assert(result2.transmissionId === result.transmissionId, 'same transmissionId for duplicate hash');
// No hash = null
const result3 = db.insertTransmission({ raw_hex: '0400' });
assert(result3 === null, 'no hash returns null');
}
// --- getPackets ---
console.log('\ngetPackets:');
{
const { rows, total } = db.getPackets({ limit: 10 });
assert(total >= 1, 'has packets');
assert(rows.length >= 1, 'returns rows');
assert(rows[0].hash === 'hash-001', 'correct hash');
// Filter by type
const { rows: r2 } = db.getPackets({ type: 4 });
assert(r2.length >= 1, 'filter by type works');
const { rows: r3 } = db.getPackets({ type: 99 });
assert(r3.length === 0, 'filter by nonexistent type returns empty');
// Filter by hash
const { rows: r4 } = db.getPackets({ hash: 'hash-001' });
assert(r4.length >= 1, 'filter by hash works');
}
// --- getPacket ---
console.log('\ngetPacket:');
{
const { rows } = db.getPackets({ limit: 1 });
const pkt = db.getPacket(rows[0].id);
assert(pkt !== null, 'getPacket returns packet');
assert(pkt.hash === 'hash-001', 'correct packet');
const missing = db.getPacket(999999);
assert(missing === null, 'missing packet returns null');
}
// --- getTransmission ---
console.log('\ngetTransmission:');
{
const tx = db.getTransmission(1);
assert(tx !== null, 'getTransmission returns data');
assert(tx.hash === 'hash-001', 'correct hash');
const missing = db.getTransmission(999999);
assert(missing === null, 'missing transmission returns null');
}
// --- getNodes ---
console.log('\ngetNodes:');
{
const { rows, total } = db.getNodes({ limit: 10 });
assert(total >= 1, 'has nodes');
assert(rows.length >= 1, 'returns node rows');
// Sort by name
const { rows: r2 } = db.getNodes({ sortBy: 'name' });
assert(r2.length >= 1, 'sort by name works');
// Invalid sort falls back to last_seen
const { rows: r3 } = db.getNodes({ sortBy: 'DROP TABLE nodes' });
assert(r3.length >= 1, 'invalid sort is safe');
}
// --- getNode ---
console.log('\ngetNode:');
{
const node = db.getNode('aabbccdd11223344aabbccdd11223344');
assert(node !== null, 'getNode returns node');
assert(Array.isArray(node.recentPackets), 'has recentPackets');
const missing = db.getNode('nonexistent');
assert(missing === null, 'missing node returns null');
}
// --- searchNodes ---
console.log('\nsearchNodes:');
{
const results = db.searchNodes('Updated');
assert(results.length >= 1, 'search by name');
const r2 = db.searchNodes('aabbcc');
assert(r2.length >= 1, 'search by pubkey prefix');
const r3 = db.searchNodes('nonexistent_xyz');
assert(r3.length === 0, 'no results for nonexistent');
}
// --- getStats ---
console.log('\ngetStats:');
{
const stats = db.getStats();
assert(stats.totalNodes >= 1, 'totalNodes');
assert(stats.totalObservers >= 1, 'totalObservers');
assert(typeof stats.totalPackets === 'number', 'totalPackets is number');
assert(typeof stats.packetsLastHour === 'number', 'packetsLastHour is number');
}
// --- getNodeHealth ---
console.log('\ngetNodeHealth:');
{
const health = db.getNodeHealth('aabbccdd11223344aabbccdd11223344');
assert(health !== null, 'returns health data');
assert(health.node.name === 'UpdatedNode', 'has node info');
assert(typeof health.stats.totalPackets === 'number', 'has totalPackets stat');
assert(Array.isArray(health.observers), 'has observers array');
assert(Array.isArray(health.recentPackets), 'has recentPackets array');
const missing = db.getNodeHealth('nonexistent');
assert(missing === null, 'missing node returns null');
}
// --- getNodeAnalytics ---
console.log('\ngetNodeAnalytics:');
{
const analytics = db.getNodeAnalytics('aabbccdd11223344aabbccdd11223344', 7);
assert(analytics !== null, 'returns analytics');
assert(analytics.node.name === 'UpdatedNode', 'has node info');
assert(Array.isArray(analytics.activityTimeline), 'has activityTimeline');
assert(Array.isArray(analytics.snrTrend), 'has snrTrend');
assert(Array.isArray(analytics.packetTypeBreakdown), 'has packetTypeBreakdown');
assert(Array.isArray(analytics.observerCoverage), 'has observerCoverage');
assert(Array.isArray(analytics.hopDistribution), 'has hopDistribution');
assert(Array.isArray(analytics.peerInteractions), 'has peerInteractions');
assert(Array.isArray(analytics.uptimeHeatmap), 'has uptimeHeatmap');
assert(typeof analytics.computedStats.availabilityPct === 'number', 'has availabilityPct');
assert(typeof analytics.computedStats.signalGrade === 'string', 'has signalGrade');
const missing = db.getNodeAnalytics('nonexistent', 7);
assert(missing === null, 'missing node returns null');
}
// --- seed ---
console.log('\nseed:');
{
if (typeof db.seed === 'function') {
// Already has data, should return false
const result = db.seed();
assert(result === false, 'seed returns false when data exists');
} else {
console.log(' (skipped — seed not exported)');
}
}
// --- v3 schema tests (fresh DB should be v3) ---
console.log('\nv3 schema:');
{
assert(db.schemaVersion >= 3, 'fresh DB creates v3 schema');
// observations table should have observer_idx, not observer_id
const cols = db.db.pragma('table_info(observations)').map(c => c.name);
assert(cols.includes('observer_idx'), 'observations has observer_idx column');
assert(!cols.includes('observer_id'), 'observations does NOT have observer_id column');
assert(!cols.includes('observer_name'), 'observations does NOT have observer_name column');
assert(!cols.includes('hash'), 'observations does NOT have hash column');
assert(!cols.includes('created_at'), 'observations does NOT have created_at column');
// timestamp should be integer
const obsRow = db.db.prepare('SELECT typeof(timestamp) as t FROM observations LIMIT 1').get();
if (obsRow) {
assert(obsRow.t === 'integer', 'timestamp is stored as integer');
}
// packets_v view should still expose observer_id, observer_name, ISO timestamp
const viewRow = db.db.prepare('SELECT * FROM packets_v LIMIT 1').get();
if (viewRow) {
assert('observer_id' in viewRow, 'packets_v exposes observer_id');
assert('observer_name' in viewRow, 'packets_v exposes observer_name');
assert(typeof viewRow.timestamp === 'string', 'packets_v timestamp is ISO string');
}
// user_version is 3
const sv = db.db.pragma('user_version', { simple: true });
assert(sv === 3, 'user_version is 3');
}
// --- v3 ingestion: observer resolved via observer_idx ---
console.log('\nv3 ingestion with observer resolution:');
{
// Insert a new observer
db.upsertObserver({ id: 'obs-v3-test', name: 'V3 Test Observer' });
// Insert observation referencing that observer
const result = db.insertTransmission({
raw_hex: '0400deadbeef',
hash: 'hash-v3-001',
timestamp: '2025-06-01T12:00:00Z',
observer_id: 'obs-v3-test',
observer_name: 'V3 Test Observer',
direction: 'rx',
snr: 12.0,
rssi: -80,
route_type: 1,
payload_type: 4,
path_json: '["aabb"]',
});
assert(result !== null, 'v3 insertion succeeded');
assert(result.transmissionId > 0, 'v3 has transmissionId');
// Verify via packets_v view
const pkt = db.db.prepare('SELECT * FROM packets_v WHERE hash = ?').get('hash-v3-001');
assert(pkt !== null, 'v3 packet found via view');
assert(pkt.observer_id === 'obs-v3-test', 'v3 observer_id resolved in view');
assert(pkt.observer_name === 'V3 Test Observer', 'v3 observer_name resolved in view');
assert(typeof pkt.timestamp === 'string', 'v3 timestamp is ISO string in view');
assert(pkt.timestamp.includes('2025-06-01'), 'v3 timestamp date correct');
// Raw observation should have integer timestamp
const obs = db.db.prepare('SELECT * FROM observations ORDER BY id DESC LIMIT 1').get();
assert(typeof obs.timestamp === 'number', 'v3 raw observation timestamp is integer');
assert(obs.observer_idx !== null, 'v3 observation has observer_idx');
}
// --- v3 dedup ---
console.log('\nv3 dedup:');
{
// Insert same observation again — should be deduped
const result = db.insertTransmission({
raw_hex: '0400deadbeef',
hash: 'hash-v3-001',
timestamp: '2025-06-01T12:00:00Z',
observer_id: 'obs-v3-test',
direction: 'rx',
snr: 12.0,
rssi: -80,
path_json: '["aabb"]',
});
assert(result.observationId === 0, 'duplicate caught by in-memory dedup');
// Different observer = not a dupe
db.upsertObserver({ id: 'obs-v3-test-2', name: 'V3 Test Observer 2' });
const result2 = db.insertTransmission({
raw_hex: '0400deadbeef',
hash: 'hash-v3-001',
timestamp: '2025-06-01T12:01:00Z',
observer_id: 'obs-v3-test-2',
direction: 'rx',
snr: 9.0,
rssi: -88,
path_json: '["ccdd"]',
});
assert(result2.observationId > 0, 'different observer is not a dupe');
}
cleanup();
delete process.env.DB_PATH;
console.log(`\n═══════════════════════════════════════`);
console.log(` PASSED: ${passed}`);
console.log(` FAILED: ${failed}`);
console.log(`═══════════════════════════════════════`);
if (failed > 0) process.exit(1);
-582
View File
@@ -1,582 +0,0 @@
/**
* Spec-driven tests for MeshCore decoder.
*
* Section 1: Spec assertions (from firmware/docs/packet_format.md + payloads.md)
* Section 2: Golden fixtures (from production data at analyzer.00id.net)
*/
'use strict';
const { decodePacket, validateAdvert, ROUTE_TYPES, PAYLOAD_TYPES } = require('./decoder');
let passed = 0;
let failed = 0;
let noted = 0;
function assert(condition, msg) {
if (condition) { passed++; }
else { failed++; console.error(` FAIL: ${msg}`); }
}
function assertEq(actual, expected, msg) {
if (actual === expected) { passed++; }
else { failed++; console.error(` FAIL: ${msg} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); }
}
function assertDeepEq(actual, expected, msg) {
const a = JSON.stringify(actual);
const b = JSON.stringify(expected);
if (a === b) { passed++; }
else { failed++; console.error(` FAIL: ${msg}\n expected: ${b}\n got: ${a}`); }
}
function note(msg) {
noted++;
console.log(` NOTE: ${msg}`);
}
// ═══════════════════════════════════════════════════════════
// Section 1: Spec-based assertions
// ═══════════════════════════════════════════════════════════
console.log('── Spec Tests: Header Parsing ──');
// Header byte: bits 1-0 = routeType, bits 5-2 = payloadType, bits 7-6 = payloadVersion
{
// 0x11 = 0b00_0100_01 → routeType=1(FLOOD), payloadType=4(ADVERT), version=0
const p = decodePacket('1100' + '00'.repeat(101)); // min advert = 100 bytes payload
assertEq(p.header.routeType, 1, 'header: routeType from bits 1-0');
assertEq(p.header.payloadType, 4, 'header: payloadType from bits 5-2');
assertEq(p.header.payloadVersion, 0, 'header: payloadVersion from bits 7-6');
assertEq(p.header.routeTypeName, 'FLOOD', 'header: routeTypeName');
assertEq(p.header.payloadTypeName, 'ADVERT', 'header: payloadTypeName');
}
// All four route types
{
const routeNames = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' };
for (const [val, name] of Object.entries(routeNames)) {
assertEq(ROUTE_TYPES[val], name, `ROUTE_TYPES[${val}] = ${name}`);
}
}
// All payload types from spec
{
const specTypes = {
0x00: 'REQ', 0x01: 'RESPONSE', 0x02: 'TXT_MSG', 0x03: 'ACK',
0x04: 'ADVERT', 0x05: 'GRP_TXT', 0x07: 'ANON_REQ',
0x08: 'PATH', 0x09: 'TRACE',
};
for (const [val, name] of Object.entries(specTypes)) {
assertEq(PAYLOAD_TYPES[val], name, `PAYLOAD_TYPES[${val}] = ${name}`);
}
}
// Spec defines 0x06=GRP_DATA, 0x0A=MULTIPART, 0x0B=CONTROL, 0x0F=RAW_CUSTOM — decoder may not have them
{
if (!PAYLOAD_TYPES[0x06]) note('Decoder missing PAYLOAD_TYPE 0x06 (GRP_DATA) — spec defines it');
if (!PAYLOAD_TYPES[0x0A]) note('Decoder missing PAYLOAD_TYPE 0x0A (MULTIPART) — spec defines it');
if (!PAYLOAD_TYPES[0x0B]) note('Decoder missing PAYLOAD_TYPE 0x0B (CONTROL) — spec defines it');
if (!PAYLOAD_TYPES[0x0F]) note('Decoder missing PAYLOAD_TYPE 0x0F (RAW_CUSTOM) — spec defines it');
}
console.log('── Spec Tests: Path Byte Parsing ──');
// path_length: bits 5-0 = hop count, bits 7-6 = hash_size - 1
{
// 0x00: 0 hops, 1-byte hashes
const p0 = decodePacket('0500' + '00'.repeat(10));
assertEq(p0.path.hashCount, 0, 'path 0x00: hashCount=0');
assertEq(p0.path.hashSize, 1, 'path 0x00: hashSize=1');
assertDeepEq(p0.path.hops, [], 'path 0x00: no hops');
}
{
// 0x05: 5 hops, 1-byte hashes → 5 path bytes
const p5 = decodePacket('0505' + 'AABBCCDDEE' + '00'.repeat(10));
assertEq(p5.path.hashCount, 5, 'path 0x05: hashCount=5');
assertEq(p5.path.hashSize, 1, 'path 0x05: hashSize=1');
assertEq(p5.path.hops.length, 5, 'path 0x05: 5 hops');
assertEq(p5.path.hops[0], 'AA', 'path 0x05: first hop');
assertEq(p5.path.hops[4], 'EE', 'path 0x05: last hop');
}
{
// 0x45: 5 hops, 2-byte hashes (bits 7-6 = 01) → 10 path bytes
const p45 = decodePacket('0545' + 'AA11BB22CC33DD44EE55' + '00'.repeat(10));
assertEq(p45.path.hashCount, 5, 'path 0x45: hashCount=5');
assertEq(p45.path.hashSize, 2, 'path 0x45: hashSize=2');
assertEq(p45.path.hops.length, 5, 'path 0x45: 5 hops');
assertEq(p45.path.hops[0], 'AA11', 'path 0x45: first hop (2-byte)');
}
{
// 0x8A: 10 hops, 3-byte hashes (bits 7-6 = 10) → 30 path bytes
const p8a = decodePacket('058A' + 'AA11FF'.repeat(10) + '00'.repeat(10));
assertEq(p8a.path.hashCount, 10, 'path 0x8A: hashCount=10');
assertEq(p8a.path.hashSize, 3, 'path 0x8A: hashSize=3');
assertEq(p8a.path.hops.length, 10, 'path 0x8A: 10 hops');
}
console.log('── Spec Tests: Transport Codes ──');
{
// Route type 0 (TRANSPORT_FLOOD) and 3 (TRANSPORT_DIRECT) should have 4-byte transport codes
// Route type 0: header byte = 0bPPPPPP00, e.g. 0x14 = payloadType 5 (GRP_TXT), routeType 0
const hex = '1400' + 'AABB' + 'CCDD' + '1A' + '00'.repeat(10); // transport codes + GRP_TXT payload
const p = decodePacket(hex);
assertEq(p.header.routeType, 0, 'transport: routeType=0 (TRANSPORT_FLOOD)');
assert(p.transportCodes !== null, 'transport: transportCodes present for TRANSPORT_FLOOD');
assertEq(p.transportCodes.nextHop, 'AABB', 'transport: nextHop');
assertEq(p.transportCodes.lastHop, 'CCDD', 'transport: lastHop');
}
{
// Route type 1 (FLOOD) should NOT have transport codes
const p = decodePacket('0500' + '00'.repeat(10));
assertEq(p.transportCodes, null, 'no transport codes for FLOOD');
}
console.log('── Spec Tests: Advert Payload ──');
// Advert: pubkey(32) + timestamp(4 LE) + signature(64) + appdata
{
const pubkey = 'AA'.repeat(32);
const timestamp = '78563412'; // 0x12345678 LE = 305419896
const signature = 'BB'.repeat(64);
// flags: 0x92 = repeater(2) | hasLocation(0x10) | hasName(0x80)
const flags = '92';
// lat: 37000000 = 0x02353A80 LE → 80 3A 35 02
const lat = '40933402';
// lon: -122100000 = 0xF8B9E260 LE → 60 E2 B9 F8
const lon = 'E0E6B8F8';
const name = Buffer.from('TestNode').toString('hex');
const hex = '1200' + pubkey + timestamp + signature + flags + lat + lon + name;
const p = decodePacket(hex);
assertEq(p.payload.type, 'ADVERT', 'advert: payload type');
assertEq(p.payload.pubKey, pubkey.toLowerCase(), 'advert: 32-byte pubkey');
assertEq(p.payload.timestamp, 0x12345678, 'advert: uint32 LE timestamp');
assertEq(p.payload.signature, signature.toLowerCase().repeat(1), 'advert: 64-byte signature');
// Flags
assertEq(p.payload.flags.raw, 0x92, 'advert flags: raw byte');
assertEq(p.payload.flags.type, 2, 'advert flags: type enum = 2 (repeater)');
assertEq(p.payload.flags.repeater, true, 'advert flags: repeater');
assertEq(p.payload.flags.room, false, 'advert flags: not room');
assertEq(p.payload.flags.chat, false, 'advert flags: not chat');
assertEq(p.payload.flags.sensor, false, 'advert flags: not sensor');
assertEq(p.payload.flags.hasLocation, true, 'advert flags: hasLocation (bit 4)');
assertEq(p.payload.flags.hasName, true, 'advert flags: hasName (bit 7)');
// Location: int32 at 1e6 scale
assert(Math.abs(p.payload.lat - 37.0) < 0.001, 'advert: lat decoded from int32/1e6');
assert(Math.abs(p.payload.lon - (-122.1)) < 0.001, 'advert: lon decoded from int32/1e6');
// Name
assertEq(p.payload.name, 'TestNode', 'advert: name from remaining appdata');
}
// Advert type enum values per spec
{
// type 0 = none (companion), 1 = chat/companion, 2 = repeater, 3 = room, 4 = sensor
const makeAdvert = (flagsByte) => {
const hex = '1200' + 'AA'.repeat(32) + '00000000' + 'BB'.repeat(64) + flagsByte.toString(16).padStart(2, '0');
return decodePacket(hex).payload;
};
const t1 = makeAdvert(0x01);
assertEq(t1.flags.type, 1, 'advert type 1 = chat/companion');
assertEq(t1.flags.chat, true, 'type 1: chat=true');
const t2 = makeAdvert(0x02);
assertEq(t2.flags.type, 2, 'advert type 2 = repeater');
assertEq(t2.flags.repeater, true, 'type 2: repeater=true');
const t3 = makeAdvert(0x03);
assertEq(t3.flags.type, 3, 'advert type 3 = room');
assertEq(t3.flags.room, true, 'type 3: room=true');
const t4 = makeAdvert(0x04);
assertEq(t4.flags.type, 4, 'advert type 4 = sensor');
assertEq(t4.flags.sensor, true, 'type 4: sensor=true');
}
// Advert with no location, no name (flags = 0x02, just repeater)
{
const hex = '1200' + 'CC'.repeat(32) + '00000000' + 'DD'.repeat(64) + '02';
const p = decodePacket(hex).payload;
assertEq(p.flags.hasLocation, false, 'advert no location: hasLocation=false');
assertEq(p.flags.hasName, false, 'advert no name: hasName=false');
assertEq(p.lat, undefined, 'advert no location: lat undefined');
assertEq(p.name, undefined, 'advert no name: name undefined');
}
console.log('── Spec Tests: Encrypted Payload Format ──');
// NOTE: Spec says v1 encrypted payloads have dest(1) + src(1) + MAC(2) + ciphertext
// But decoder reads dest(6) + src(6) + MAC(4) + ciphertext
// This is a known discrepancy — the decoder matches production behavior, not the spec.
// The spec may describe the firmware's internal addressing while the OTA format differs,
// or the decoder may be parsing the fields differently. Production data validates the decoder.
{
note('Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher, but decoder reads dest(6)+src(6)+MAC(4)+cipher — decoder matches prod data');
}
console.log('── Spec Tests: validateAdvert ──');
{
const good = { pubKey: 'aa'.repeat(32), flags: { repeater: true, room: false, sensor: false } };
assertEq(validateAdvert(good).valid, true, 'validateAdvert: good advert');
assertEq(validateAdvert(null).valid, false, 'validateAdvert: null');
assertEq(validateAdvert({ error: 'bad' }).valid, false, 'validateAdvert: error advert');
assertEq(validateAdvert({ pubKey: 'aa' }).valid, false, 'validateAdvert: short pubkey');
assertEq(validateAdvert({ pubKey: '00'.repeat(32) }).valid, false, 'validateAdvert: all-zero pubkey');
const badLat = { pubKey: 'aa'.repeat(32), lat: 999 };
assertEq(validateAdvert(badLat).valid, false, 'validateAdvert: invalid lat');
const badLon = { pubKey: 'aa'.repeat(32), lon: -999 };
assertEq(validateAdvert(badLon).valid, false, 'validateAdvert: invalid lon');
const badName = { pubKey: 'aa'.repeat(32), name: 'test\x00name' };
assertEq(validateAdvert(badName).valid, false, 'validateAdvert: control chars in name');
const longName = { pubKey: 'aa'.repeat(32), name: 'x'.repeat(65) };
assertEq(validateAdvert(longName).valid, false, 'validateAdvert: name too long');
}
// ═══════════════════════════════════════════════════════════
// Section 2: Golden fixtures (from production)
// ═══════════════════════════════════════════════════════════
console.log('── Golden Tests: Production Packets ──');
const goldenFixtures = [
{
"raw_hex": "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976",
"payload_type": 2,
"route_type": 2,
"decoded": "{\"type\":\"TXT_MSG\",\"destHash\":\"d6\",\"srcHash\":\"9f\",\"mac\":\"d7a5\",\"encryptedData\":\"a7475db07337749ae61fa53a4788e976\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "0A009FD605771EE2EB0CDC46D100232B455947E3C2D4B9DD0B8880EACA99A3C5F7EF63183D6D",
"payload_type": 2,
"route_type": 2,
"decoded": "{\"type\":\"TXT_MSG\",\"destHash\":\"9f\",\"srcHash\":\"d6\",\"mac\":\"0577\",\"encryptedData\":\"1ee2eb0cdc46d100232b455947e3c2d4b9dd0b8880eaca99a3c5f7ef63183d6d\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52",
"payload_type": 4,
"route_type": 2,
"decoded": "{\"type\":\"ADVERT\",\"pubKey\":\"46d62de27d4c5194d7821fc5a34a45565dcc2537b300b9ab6275255cefb65d84\",\"timestamp\":1774314764,\"timestampISO\":\"2026-03-24T01:12:44.000Z\",\"signature\":\"c94c9aed39e8bcb6cb6eb0335497a198b33a1a610cd3b03d8dcfc160900e5244280323ee0b44cacab8f02b5b38b91cfa18bd067b0b5e63e94cfc85f758a8530b\",\"flags\":{\"raw\":146,\"type\":2,\"chat\":false,\"repeater\":true,\"room\":false,\"sensor\":false,\"hasLocation\":true,\"hasName\":true},\"lat\":37,\"lon\":-122.1,\"name\":\"MRR2-R\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "120073CFF971E1CB5754A742C152B2D2E0EB108A19B246D663ED8898A72C4A5AD86EA6768E66694B025EDF6939D5C44CFF719C5D5520E5F06B20680A83AD9C2C61C3227BBB977A85EE462F3553445FECF8EDD05C234ECE217272E503F14D6DF2B1B9B133890C923CDF3002F8FDC1F85045414BF09F8CB3",
"payload_type": 4,
"route_type": 2,
"decoded": "{\"type\":\"ADVERT\",\"pubKey\":\"73cff971e1cb5754a742c152b2d2e0eb108a19b246d663ed8898a72c4a5ad86e\",\"timestamp\":1720612518,\"timestampISO\":\"2024-07-10T11:55:18.000Z\",\"signature\":\"694b025edf6939d5c44cff719c5d5520e5f06b20680a83ad9c2c61c3227bbb977a85ee462f3553445fecf8edd05c234ece217272e503f14d6df2b1b9b133890c\",\"flags\":{\"raw\":146,\"type\":2,\"chat\":false,\"repeater\":true,\"room\":false,\"sensor\":false,\"hasLocation\":true,\"hasName\":true},\"lat\":36.757308,\"lon\":-121.504264,\"name\":\"PEAK🌳\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "06001f33e1bef15f5596b394adf03a77d46b89afa2e3",
"payload_type": 1,
"route_type": 2,
"decoded": "{\"type\":\"RESPONSE\",\"destHash\":\"1f\",\"srcHash\":\"33\",\"mac\":\"e1be\",\"encryptedData\":\"f15f5596b394adf03a77d46b89afa2e3\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "0200331fe52805e05cf6f4bae6a094ac258d57baf045",
"payload_type": 0,
"route_type": 2,
"decoded": "{\"type\":\"REQ\",\"destHash\":\"33\",\"srcHash\":\"1f\",\"mac\":\"e528\",\"encryptedData\":\"05e05cf6f4bae6a094ac258d57baf045\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "15001ABC314305D3CCC94EB3F398D3054B4E95899229027B027E450FD68B4FA4E0A0126AC1",
"payload_type": 5,
"route_type": 1,
"decoded": "{\"type\":\"GRP_TXT\",\"channelHash\":26,\"mac\":\"bc31\",\"encryptedData\":\"4305d3ccc94eb3f398d3054b4e95899229027b027e450fd68b4fa4e0a0126ac1\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "010673a210206cb51e42fee24c4847a99208b9fc1d7ab36c42b10748",
"payload_type": 0,
"route_type": 1,
"decoded": "{\"type\":\"REQ\",\"destHash\":\"1e\",\"srcHash\":\"42\",\"mac\":\"fee2\",\"encryptedData\":\"4c4847a99208b9fc1d7ab36c42b10748\"}",
"path": {
"hashSize": 1,
"hashCount": 6,
"hops": [
"73",
"A2",
"10",
"20",
"6C",
"B5"
]
}
},
{
"raw_hex": "0101731E42FEE24C4847A99208293810E4A3E335640D8E",
"payload_type": 0,
"route_type": 1,
"decoded": "{\"type\":\"REQ\",\"destHash\":\"1e\",\"srcHash\":\"42\",\"mac\":\"fee2\",\"encryptedData\":\"4c4847a99208293810e4a3e335640d8e\"}",
"path": {
"hashSize": 1,
"hashCount": 1,
"hops": [
"73"
]
}
},
{
"raw_hex": "0106FB10844070101E42BA859D1D939362F79D3F3865333629FF92E9",
"payload_type": 0,
"route_type": 1,
"decoded": "{\"type\":\"REQ\",\"destHash\":\"1e\",\"srcHash\":\"42\",\"mac\":\"ba85\",\"encryptedData\":\"9d1d939362f79d3f3865333629ff92e9\"}",
"path": {
"hashSize": 1,
"hashCount": 6,
"hops": [
"FB",
"10",
"84",
"40",
"70",
"10"
]
}
},
{
"raw_hex": "0102FB101E42BA859D1D939362F79D3F3865333629FF92D9",
"payload_type": 0,
"route_type": 1,
"decoded": "{\"type\":\"REQ\",\"destHash\":\"1e\",\"srcHash\":\"42\",\"mac\":\"ba85\",\"encryptedData\":\"9d1d939362f79d3f3865333629ff92d9\"}",
"path": {
"hashSize": 1,
"hashCount": 2,
"hops": [
"FB",
"10"
]
}
},
{
"raw_hex": "22009FD65B38857C5A7F6F0F28E999CF2632C03ACCCC",
"payload_type": 8,
"route_type": 2,
"decoded": "{\"type\":\"PATH\",\"destHash\":\"9f\",\"srcHash\":\"d6\",\"mac\":\"5b38\",\"pathData\":\"857c5a7f6f0f28e999cf2632c03acccc\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "0506701085AD8573D69F96FA7DD3B1AC3702794035442D9CDAD436D4",
"payload_type": 1,
"route_type": 1,
"decoded": "{\"type\":\"RESPONSE\",\"destHash\":\"d6\",\"srcHash\":\"9f\",\"mac\":\"96fa\",\"encryptedData\":\"7dd3b1ac3702794035442d9cdad436d4\"}",
"path": {
"hashSize": 1,
"hashCount": 6,
"hops": [
"70",
"10",
"85",
"AD",
"85",
"73"
]
}
},
{
"raw_hex": "0500D69F96FA7DD3B1AC3702794035442D9CDAD43654",
"payload_type": 1,
"route_type": 1,
"decoded": "{\"type\":\"RESPONSE\",\"destHash\":\"d6\",\"srcHash\":\"9f\",\"mac\":\"96fa\",\"encryptedData\":\"7dd3b1ac3702794035442d9cdad43654\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "1E009FD6DFC543C53E826A2B789B072FF9CBE922E57EA093E5643A0CA813E79F42EE9108F855B72A3E0B599C9AC80D3A211E7C7BA2",
"payload_type": 7,
"route_type": 2,
"decoded": "{\"type\":\"ANON_REQ\",\"destHash\":\"9f\",\"ephemeralPubKey\":\"d6dfc543c53e826a2b789b072ff9cbe922e57ea093e5643a0ca813e79f42ee91\",\"mac\":\"08f8\",\"encryptedData\":\"55b72a3e0b599c9ac80d3a211e7c7ba2\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "110146B7F1C45F2ED5888335F79E27085D0DE871A7C8ECB1EF5313435EBD0825BACDC181E3C1695556F51A89C9895E2114D1FECA91B58F82CBBBC1DD2B868ADDC0F7EB8C310D0887C2A2283D6F7D01A5E97B6C2F6A4CC899F27AFA513CC6B295E34ADC84A1F1019240933402E0E6B8F84D6574726F2D52",
"payload_type": 4,
"route_type": 1,
"decoded": "{\"type\":\"ADVERT\",\"pubKey\":\"b7f1c45f2ed5888335f79e27085d0de871a7c8ecb1ef5313435ebd0825bacdc1\",\"timestamp\":1774314369,\"timestampISO\":\"2026-03-24T01:06:09.000Z\",\"signature\":\"5556f51a89c9895e2114d1feca91b58f82cbbbc1dd2b868addc0f7eb8c310d0887c2a2283d6f7d01a5e97b6c2f6a4cc899f27afa513cc6b295e34adc84a1f101\",\"flags\":{\"raw\":146,\"type\":2,\"chat\":false,\"repeater\":true,\"room\":false,\"sensor\":false,\"hasLocation\":true,\"hasName\":true},\"lat\":37,\"lon\":-122.1,\"name\":\"Metro-R\"}",
"path": {
"hashSize": 1,
"hashCount": 1,
"hops": [
"46"
]
}
},
{
"raw_hex": "15001A901C5D927D90572BAF6135D226F91D180AD4F7B90DF20F82EEEA920312D9CCFD9C3F8CA9EFBEB1C37DFA31265F73483BD0640EC94E247902F617B2C320BFA332F50441AD234D8324A48ABAA9A16EB15BD50F2D67029F2424E0836010A635EB45B5DFDB4CDC080C09FC849040AB4B82769E0F",
"payload_type": 5,
"route_type": 1,
"decoded": "{\"type\":\"GRP_TXT\",\"channelHash\":26,\"mac\":\"901c\",\"encryptedData\":\"5d927d90572baf6135d226f91d180ad4f7b90df20f82eeea920312d9ccfd9c3f8ca9efbeb1c37dfa31265f73483bd0640ec94e247902f617b2c320bfa332f50441ad234d8324a48abaa9a16eb15bd50f2d67029f2424e0836010a635eb45b5dfdb4cdc080c09fc849040ab4b82769e0f\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "0A00D69F0E65C6CCDEBE8391ED093D3C76E2D064F525",
"payload_type": 2,
"route_type": 2,
"decoded": "{\"type\":\"TXT_MSG\",\"destHash\":\"d6\",\"srcHash\":\"9f\",\"mac\":\"0e65\",\"encryptedData\":\"c6ccdebe8391ed093d3c76e2d064f525\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "0A00D69F940E0BA255095E9540EE6E23895DA80AAC60",
"payload_type": 2,
"route_type": 2,
"decoded": "{\"type\":\"TXT_MSG\",\"destHash\":\"d6\",\"srcHash\":\"9f\",\"mac\":\"940e\",\"encryptedData\":\"0ba255095e9540ee6e23895da80aac60\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
},
{
"raw_hex": "06001f5d5acf699ea80c7ca1a9349b8af9a1b47d4a1a",
"payload_type": 1,
"route_type": 2,
"decoded": "{\"type\":\"RESPONSE\",\"destHash\":\"1f\",\"srcHash\":\"5d\",\"mac\":\"5acf\",\"encryptedData\":\"699ea80c7ca1a9349b8af9a1b47d4a1a\"}",
"path": {
"hashSize": 1,
"hashCount": 0,
"hops": []
}
}
];
// One special case: the advert with 1 hop from prod had raw_hex starting with "110146"
// but the API reported path ["46"]. Let me re-check — header 0x11 = routeType 1, payloadType 4.
// pathByte 0x01 = 1 hop, 1-byte hash. Next byte is 0x46 = the hop. Correct.
// However, the raw_hex I captured from the API was "110146B7F1..." but the actual prod JSON showed path ["46"].
// I need to use the correct raw_hex. Let me fix fixture 15 (Metro-R advert).
for (let i = 0; i < goldenFixtures.length; i++) {
const fix = goldenFixtures[i];
const expected = typeof fix.decoded === "string" ? JSON.parse(fix.decoded) : fix.decoded;
const label = `golden[${i}] ${expected.type}`;
try {
const result = decodePacket(fix.raw_hex);
// Verify header matches expected route/payload type
assertEq(result.header.routeType, fix.route_type, `${label}: routeType`);
assertEq(result.header.payloadType, fix.payload_type, `${label}: payloadType`);
// Verify path hops
assertDeepEq(result.path.hops, (fix.path.hops || fix.path), `${label}: path hops`);
// Verify payload matches prod decoded output
// Compare key fields rather than full deep equality (to handle minor serialization diffs)
assertEq(result.payload.type, expected.type, `${label}: payload type`);
if (expected.type === 'ADVERT') {
assertEq(result.payload.pubKey, expected.pubKey, `${label}: pubKey`);
assertEq(result.payload.timestamp, expected.timestamp, `${label}: timestamp`);
assertEq(result.payload.signature, expected.signature, `${label}: signature`);
if (expected.flags) {
assertEq(result.payload.flags.raw, expected.flags.raw, `${label}: flags.raw`);
assertEq(result.payload.flags.type, expected.flags.type, `${label}: flags.type`);
assertEq(result.payload.flags.hasLocation, expected.flags.hasLocation, `${label}: hasLocation`);
assertEq(result.payload.flags.hasName, expected.flags.hasName, `${label}: hasName`);
}
if (expected.lat != null) assert(Math.abs(result.payload.lat - expected.lat) < 0.001, `${label}: lat`);
if (expected.lon != null) assert(Math.abs(result.payload.lon - expected.lon) < 0.001, `${label}: lon`);
if (expected.name) assertEq(result.payload.name, expected.name, `${label}: name`);
// Spec checks on advert structure
assert(result.payload.pubKey.length === 64, `${label}: pubKey is 32 bytes (64 hex chars)`);
assert(result.payload.signature.length === 128, `${label}: signature is 64 bytes (128 hex chars)`);
} else if (expected.type === 'GRP_TXT' || expected.type === 'CHAN') {
assertEq(result.payload.channelHash, expected.channelHash, `${label}: channelHash`);
// If decoded as CHAN (with channel key), check sender/text; otherwise check mac/encrypted
if (expected.type === 'GRP_TXT') {
assertEq(result.payload.mac, expected.mac, `${label}: mac`);
assertEq(result.payload.encryptedData, expected.encryptedData, `${label}: encryptedData`);
}
} else if (expected.type === 'ANON_REQ') {
assertEq(result.payload.destHash, expected.destHash, `${label}: destHash`);
assertEq(result.payload.ephemeralPubKey, expected.ephemeralPubKey, `${label}: ephemeralPubKey`);
assertEq(result.payload.mac, expected.mac, `${label}: mac`);
} else {
// Encrypted payload types: REQ, RESPONSE, TXT_MSG, PATH
assertEq(result.payload.destHash, expected.destHash, `${label}: destHash`);
assertEq(result.payload.srcHash, expected.srcHash, `${label}: srcHash`);
assertEq(result.payload.mac, expected.mac, `${label}: mac`);
if (expected.encryptedData) assertEq(result.payload.encryptedData, expected.encryptedData, `${label}: encryptedData`);
if (expected.pathData) assertEq(result.payload.pathData, expected.pathData, `${label}: pathData`);
}
} catch (e) {
failed++;
console.error(` FAIL: ${label} — threw: ${e.message}`);
}
}
// ═══════════════════════════════════════════════════════════
// Summary
// ═══════════════════════════════════════════════════════════
console.log('');
console.log(`═══ Results: ${passed} passed, ${failed} failed, ${noted} notes ═══`);
if (failed > 0) process.exit(1);
-412
View File
@@ -1,412 +0,0 @@
/* Unit tests for decoder.js */
'use strict';
const assert = require('assert');
const { decodePacket, validateAdvert, ROUTE_TYPES, PAYLOAD_TYPES, VALID_ROLES } = require('./decoder');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// === Constants ===
console.log('\n=== Constants ===');
test('ROUTE_TYPES has 4 entries', () => assert.strictEqual(Object.keys(ROUTE_TYPES).length, 4));
test('PAYLOAD_TYPES has 13 entries', () => assert.strictEqual(Object.keys(PAYLOAD_TYPES).length, 13));
test('VALID_ROLES has repeater, companion, room, sensor', () => {
for (const r of ['repeater', 'companion', 'room', 'sensor']) assert(VALID_ROLES.has(r));
});
// === Header decoding ===
console.log('\n=== Header decoding ===');
test('FLOOD + ADVERT = 0x11', () => {
const p = decodePacket('1100' + '00'.repeat(101));
assert.strictEqual(p.header.routeType, 1);
assert.strictEqual(p.header.routeTypeName, 'FLOOD');
assert.strictEqual(p.header.payloadType, 4);
assert.strictEqual(p.header.payloadTypeName, 'ADVERT');
});
test('TRANSPORT_FLOOD = routeType 0', () => {
// 0x00 = TRANSPORT_FLOOD + REQ(0), needs transport codes + 16 byte payload
const hex = '0000' + 'AABB' + 'CCDD' + '00'.repeat(16);
const p = decodePacket(hex);
assert.strictEqual(p.header.routeType, 0);
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_FLOOD');
assert.notStrictEqual(p.transportCodes, null);
assert.strictEqual(p.transportCodes.nextHop, 'AABB');
assert.strictEqual(p.transportCodes.lastHop, 'CCDD');
});
test('TRANSPORT_DIRECT = routeType 3', () => {
const hex = '0300' + '1122' + '3344' + '00'.repeat(16);
const p = decodePacket(hex);
assert.strictEqual(p.header.routeType, 3);
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_DIRECT');
assert.strictEqual(p.transportCodes.nextHop, '1122');
});
test('DIRECT = routeType 2, no transport codes', () => {
const hex = '0200' + '00'.repeat(16);
const p = decodePacket(hex);
assert.strictEqual(p.header.routeType, 2);
assert.strictEqual(p.header.routeTypeName, 'DIRECT');
assert.strictEqual(p.transportCodes, null);
});
test('payload version extracted', () => {
// 0xC1 = 11_0000_01 → version=3, payloadType=0, routeType=1
const hex = 'C100' + '00'.repeat(16);
const p = decodePacket(hex);
assert.strictEqual(p.header.payloadVersion, 3);
});
// === Path decoding ===
console.log('\n=== Path decoding ===');
test('hashSize=1, hashCount=3', () => {
// pathByte = 0x03 → (0>>6)+1=1, 3&0x3F=3
const hex = '1103' + 'AABBCC' + '00'.repeat(101);
const p = decodePacket(hex);
assert.strictEqual(p.path.hashSize, 1);
assert.strictEqual(p.path.hashCount, 3);
assert.strictEqual(p.path.hops.length, 3);
assert.strictEqual(p.path.hops[0], 'AA');
assert.strictEqual(p.path.hops[1], 'BB');
assert.strictEqual(p.path.hops[2], 'CC');
});
test('hashSize=2, hashCount=2', () => {
// pathByte = 0x42 → (1>>0=1)+1=2, 2&0x3F=2
const hex = '1142' + 'AABB' + 'CCDD' + '00'.repeat(101);
const p = decodePacket(hex);
assert.strictEqual(p.path.hashSize, 2);
assert.strictEqual(p.path.hashCount, 2);
assert.strictEqual(p.path.hops[0], 'AABB');
assert.strictEqual(p.path.hops[1], 'CCDD');
});
test('hashSize=4 from pathByte 0xC1', () => {
// 0xC1 = 11_000001 → hashSize=(3)+1=4, hashCount=1
const hex = '11C1' + 'DEADBEEF' + '00'.repeat(101);
const p = decodePacket(hex);
assert.strictEqual(p.path.hashSize, 4);
assert.strictEqual(p.path.hashCount, 1);
assert.strictEqual(p.path.hops[0], 'DEADBEEF');
});
test('zero hops', () => {
const hex = '1100' + '00'.repeat(101);
const p = decodePacket(hex);
assert.strictEqual(p.path.hashCount, 0);
assert.strictEqual(p.path.hops.length, 0);
});
// === Payload types ===
console.log('\n=== ADVERT payload ===');
test('ADVERT with name and location', () => {
const pkt = decodePacket(
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
);
assert.strictEqual(pkt.payload.type, 'ADVERT');
assert.strictEqual(pkt.payload.name, 'Kpa Roof Solar');
assert(pkt.payload.pubKey.length === 64);
assert(pkt.payload.timestamp > 0);
assert(pkt.payload.timestampISO);
assert(pkt.payload.signature.length === 128);
});
test('ADVERT flags: chat type=1', () => {
const pubKey = 'AB'.repeat(32);
const ts = '01000000';
const sig = 'CC'.repeat(64);
const flags = '01'; // type=1 → chat
const hex = '1100' + pubKey + ts + sig + flags;
const p = decodePacket(hex);
assert.strictEqual(p.payload.flags.type, 1);
assert.strictEqual(p.payload.flags.chat, true);
assert.strictEqual(p.payload.flags.repeater, false);
});
test('ADVERT flags: repeater type=2', () => {
const pubKey = 'AB'.repeat(32);
const ts = '01000000';
const sig = 'CC'.repeat(64);
const flags = '02';
const hex = '1100' + pubKey + ts + sig + flags;
const p = decodePacket(hex);
assert.strictEqual(p.payload.flags.type, 2);
assert.strictEqual(p.payload.flags.repeater, true);
});
test('ADVERT flags: room type=3', () => {
const pubKey = 'AB'.repeat(32);
const ts = '01000000';
const sig = 'CC'.repeat(64);
const flags = '03';
const hex = '1100' + pubKey + ts + sig + flags;
const p = decodePacket(hex);
assert.strictEqual(p.payload.flags.type, 3);
assert.strictEqual(p.payload.flags.room, true);
});
test('ADVERT flags: sensor type=4', () => {
const pubKey = 'AB'.repeat(32);
const ts = '01000000';
const sig = 'CC'.repeat(64);
const flags = '04';
const hex = '1100' + pubKey + ts + sig + flags;
const p = decodePacket(hex);
assert.strictEqual(p.payload.flags.type, 4);
assert.strictEqual(p.payload.flags.sensor, true);
});
test('ADVERT flags: hasLocation', () => {
const pubKey = 'AB'.repeat(32);
const ts = '01000000';
const sig = 'CC'.repeat(64);
// flags=0x12 → type=2(repeater), hasLocation=true
const flags = '12';
const lat = '40420f00'; // 1000000 → 1.0 degrees
const lon = '80841e00'; // 2000000 → 2.0 degrees
const hex = '1100' + pubKey + ts + sig + flags + lat + lon;
const p = decodePacket(hex);
assert.strictEqual(p.payload.flags.hasLocation, true);
assert.strictEqual(p.payload.lat, 1.0);
assert.strictEqual(p.payload.lon, 2.0);
});
test('ADVERT flags: hasName', () => {
const pubKey = 'AB'.repeat(32);
const ts = '01000000';
const sig = 'CC'.repeat(64);
// flags=0x82 → type=2(repeater), hasName=true
const flags = '82';
const name = Buffer.from('MyNode').toString('hex');
const hex = '1100' + pubKey + ts + sig + flags + name;
const p = decodePacket(hex);
assert.strictEqual(p.payload.flags.hasName, true);
assert.strictEqual(p.payload.name, 'MyNode');
});
test('ADVERT too short', () => {
const hex = '1100' + '00'.repeat(50);
const p = decodePacket(hex);
assert(p.payload.error);
});
console.log('\n=== GRP_TXT payload ===');
test('GRP_TXT basic decode', () => {
// payloadType=5 → (5<<2)|1 = 0x15
const hex = '1500' + 'FF' + 'AABB' + 'CCDDEE';
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'GRP_TXT');
assert.strictEqual(p.payload.channelHash, 0xFF);
assert.strictEqual(p.payload.mac, 'aabb');
});
test('GRP_TXT too short', () => {
const hex = '1500' + 'FF' + 'AA';
const p = decodePacket(hex);
assert(p.payload.error);
});
console.log('\n=== TXT_MSG payload ===');
test('TXT_MSG decode', () => {
// payloadType=2 → (2<<2)|1 = 0x09
const hex = '0900' + '00'.repeat(20);
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'TXT_MSG');
assert(p.payload.destHash);
assert(p.payload.srcHash);
assert(p.payload.mac);
});
console.log('\n=== ACK payload ===');
test('ACK decode', () => {
// payloadType=3 → (3<<2)|1 = 0x0D
const hex = '0D00' + '00'.repeat(18);
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'ACK');
assert(p.payload.destHash);
assert(p.payload.srcHash);
assert(p.payload.extraHash);
});
test('ACK too short', () => {
const hex = '0D00' + '00'.repeat(3);
const p = decodePacket(hex);
assert(p.payload.error);
});
console.log('\n=== REQ payload ===');
test('REQ decode', () => {
// payloadType=0 → (0<<2)|1 = 0x01
const hex = '0100' + '00'.repeat(20);
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'REQ');
});
console.log('\n=== RESPONSE payload ===');
test('RESPONSE decode', () => {
// payloadType=1 → (1<<2)|1 = 0x05
const hex = '0500' + '00'.repeat(20);
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'RESPONSE');
});
console.log('\n=== ANON_REQ payload ===');
test('ANON_REQ decode', () => {
// payloadType=7 → (7<<2)|1 = 0x1D
const hex = '1D00' + '00'.repeat(50);
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'ANON_REQ');
assert(p.payload.destHash);
assert(p.payload.ephemeralPubKey);
assert(p.payload.mac);
});
test('ANON_REQ too short', () => {
const hex = '1D00' + '00'.repeat(20);
const p = decodePacket(hex);
assert(p.payload.error);
});
console.log('\n=== PATH payload ===');
test('PATH decode', () => {
// payloadType=8 → (8<<2)|1 = 0x21
const hex = '2100' + '00'.repeat(20);
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'PATH');
assert(p.payload.destHash);
assert(p.payload.srcHash);
});
test('PATH too short', () => {
const hex = '2100' + '00'.repeat(1);
const p = decodePacket(hex);
assert(p.payload.error);
});
console.log('\n=== TRACE payload ===');
test('TRACE decode', () => {
// payloadType=9 → (9<<2)|1 = 0x25
const hex = '2500' + '00'.repeat(12);
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'TRACE');
assert.strictEqual(p.payload.flags, 0);
assert(p.payload.tag !== undefined);
assert(p.payload.destHash);
});
test('TRACE too short', () => {
const hex = '2500' + '00'.repeat(5);
const p = decodePacket(hex);
assert(p.payload.error);
});
console.log('\n=== UNKNOWN payload ===');
test('Unknown payload type', () => {
// payloadType=6 → (6<<2)|1 = 0x19
const hex = '1900' + 'DEADBEEF';
const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'UNKNOWN');
assert(p.payload.raw);
});
// === Edge cases ===
console.log('\n=== Edge cases ===');
test('Packet too short throws', () => {
assert.throws(() => decodePacket('FF'), /too short/);
});
test('Packet with spaces in hex', () => {
const hex = '11 00 ' + '00'.repeat(101);
const p = decodePacket(hex);
assert.strictEqual(p.header.payloadTypeName, 'ADVERT');
});
test('Transport route too short throws', () => {
assert.throws(() => decodePacket('0000'), /too short for transport/);
});
// === Real packets from API ===
console.log('\n=== Real packets ===');
test('Real GRP_TXT packet', () => {
const p = decodePacket('150115D96CFF1FC90E7917B91729B76C1B509AE7789BBBD87D5AC3837E6C1487B47B0958AED8C7A6');
assert.strictEqual(p.header.payloadTypeName, 'GRP_TXT');
assert.strictEqual(p.header.routeTypeName, 'FLOOD');
assert.strictEqual(p.path.hashCount, 1);
});
test('Real ADVERT packet FLOOD with 3 hops', () => {
const p = decodePacket('11036CEF52206D763E1EACFD52FBAD4EF926887D0694C42A618AAF480A67C41120D3785950EFE0C1');
assert.strictEqual(p.header.payloadTypeName, 'ADVERT');
assert.strictEqual(p.header.routeTypeName, 'FLOOD');
assert.strictEqual(p.path.hashCount, 3);
assert.strictEqual(p.path.hashSize, 1);
// Payload is too short for full ADVERT but decoder handles it
assert.strictEqual(p.payload.type, 'ADVERT');
});
test('Real DIRECT TXT_MSG packet', () => {
// 0x0A = DIRECT(2) + TXT_MSG(2)
const p = decodePacket('0A403220AD034C0394C2C449810E3D86399C53AEE7FE355BA67002FFC3627B1175A257A181AE');
assert.strictEqual(p.header.payloadTypeName, 'TXT_MSG');
assert.strictEqual(p.header.routeTypeName, 'DIRECT');
});
// === validateAdvert ===
console.log('\n=== validateAdvert ===');
test('valid advert', () => {
const a = { pubKey: 'AB'.repeat(16), flags: { repeater: true, room: false, sensor: false } };
assert.deepStrictEqual(validateAdvert(a), { valid: true });
});
test('null advert', () => {
assert.strictEqual(validateAdvert(null).valid, false);
});
test('advert with error', () => {
assert.strictEqual(validateAdvert({ error: 'bad' }).valid, false);
});
test('pubkey too short', () => {
assert.strictEqual(validateAdvert({ pubKey: 'AABB' }).valid, false);
});
test('pubkey all zeros', () => {
assert.strictEqual(validateAdvert({ pubKey: '0'.repeat(64) }).valid, false);
});
test('invalid lat', () => {
assert.strictEqual(validateAdvert({ pubKey: 'AB'.repeat(16), lat: 200 }).valid, false);
});
test('invalid lon', () => {
assert.strictEqual(validateAdvert({ pubKey: 'AB'.repeat(16), lon: -200 }).valid, false);
});
test('name with control chars', () => {
assert.strictEqual(validateAdvert({ pubKey: 'AB'.repeat(16), name: 'test\x00bad' }).valid, false);
});
test('name too long', () => {
assert.strictEqual(validateAdvert({ pubKey: 'AB'.repeat(16), name: 'A'.repeat(65) }).valid, false);
});
test('valid name', () => {
assert.strictEqual(validateAdvert({ pubKey: 'AB'.repeat(16), name: 'My Node' }).valid, true);
});
test('valid lat/lon', () => {
const r = validateAdvert({ pubKey: 'AB'.repeat(16), lat: 37.3, lon: -121.9 });
assert.strictEqual(r.valid, true);
});
test('NaN lat invalid', () => {
assert.strictEqual(validateAdvert({ pubKey: 'AB'.repeat(16), lat: NaN }).valid, false);
});
// === Summary ===
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
-328
View File
@@ -1,328 +0,0 @@
/**
* Playwright E2E tests proof of concept
* Runs against prod (analyzer.00id.net), read-only.
* Usage: node test-e2e-playwright.js
*/
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
const results = [];
async function test(name, fn) {
try {
await fn();
results.push({ name, pass: true });
console.log(`${name}`);
} catch (err) {
results.push({ name, pass: false, error: err.message });
console.log(`${name}: ${err.message}`);
}
}
function assert(condition, msg) {
if (!condition) throw new Error(msg || 'Assertion failed');
}
async function run() {
console.log('Launching Chromium...');
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(15000);
console.log(`\nRunning E2E tests against ${BASE}\n`);
// Test 1: Home page loads
await test('Home page loads', async () => {
await page.goto(BASE, { waitUntil: 'networkidle' });
const title = await page.title();
assert(title.toLowerCase().includes('meshcore'), `Title "${title}" doesn't contain MeshCore`);
const nav = await page.$('nav, .navbar, .nav, [class*="nav"]');
assert(nav, 'Nav bar not found');
});
// Test 2: Nodes page loads with data
await test('Nodes page loads with data', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle' });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
await page.waitForTimeout(1000); // let SPA render
const headers = await page.$$eval('th', els => els.map(e => e.textContent.trim()));
for (const col of ['Name', 'Public Key', 'Role']) {
assert(headers.some(h => h.includes(col)), `Missing column: ${col}`);
}
assert(headers.some(h => h.includes('Last Seen') || h.includes('Last')), 'Missing Last Seen column');
const rows = await page.$$('table tbody tr');
assert(rows.length >= 1, `Expected >=1 nodes, got ${rows.length}`);
});
// Test 3: Map page loads with markers
await test('Map page loads with markers', async () => {
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle' });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
await page.waitForSelector('.leaflet-tile-loaded', { timeout: 10000 });
// Markers can be icons, SVG circles, or canvas-rendered; wait a bit for data
await page.waitForTimeout(3000);
const markers = await page.$$('.leaflet-marker-icon, .leaflet-interactive, circle, .marker-cluster, .leaflet-marker-pane > *, .leaflet-overlay-pane svg path, .leaflet-overlay-pane svg circle');
assert(markers.length > 0, 'No map markers/overlays found');
});
// Test 4: Packets page loads with filter
await test('Packets page loads with filter', async () => {
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle' });
await page.waitForSelector('table tbody tr', { timeout: 10000 });
const rowsBefore = await page.$$('table tbody tr');
assert(rowsBefore.length > 0, 'No packets visible');
// Use the specific filter input
const filterInput = await page.$('#packetFilterInput');
assert(filterInput, 'Packet filter input not found');
await filterInput.fill('type == ADVERT');
await page.waitForTimeout(1500);
// Verify filter was applied (count may differ)
const rowsAfter = await page.$$('table tbody tr');
assert(rowsAfter.length > 0, 'No packets after filtering');
});
// Test 5: Node detail loads
await test('Node detail loads', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle' });
await page.waitForSelector('table tbody tr', { timeout: 10000 });
// Click first row
const firstRow = await page.$('table tbody tr');
assert(firstRow, 'No node rows found');
await firstRow.click();
// Wait for side pane or detail
await page.waitForTimeout(1000);
const html = await page.content();
// Check for status indicator
const hasStatus = html.includes('🟢') || html.includes('⚪') || html.includes('status') || html.includes('Active') || html.includes('Stale');
assert(hasStatus, 'No status indicator found in node detail');
});
// Test 6: Theme customizer opens
await test('Theme customizer opens', async () => {
await page.goto(BASE, { waitUntil: 'networkidle' });
// Look for palette/customize button
const btn = await page.$('button[title*="ustom" i], button[aria-label*="theme" i], [class*="customize"], button:has-text("🎨")');
if (!btn) {
// Try finding by emoji content
const allButtons = await page.$$('button');
let found = false;
for (const b of allButtons) {
const text = await b.textContent();
if (text.includes('🎨')) {
await b.click();
found = true;
break;
}
}
assert(found, 'Could not find theme customizer button');
} else {
await btn.click();
}
await page.waitForTimeout(500);
const html = await page.content();
const hasCustomizer = html.includes('preset') || html.includes('Preset') || html.includes('theme') || html.includes('Theme');
assert(hasCustomizer, 'Customizer panel not found after clicking');
});
// Test 7: Dark mode toggle
await test('Dark mode toggle', async () => {
await page.goto(BASE, { waitUntil: 'networkidle' });
const themeBefore = await page.$eval('html', el => el.getAttribute('data-theme'));
// Find toggle button
const allButtons = await page.$$('button');
let toggled = false;
for (const b of allButtons) {
const text = await b.textContent();
if (text.includes('☀') || text.includes('🌙') || text.includes('🌑') || text.includes('🌕')) {
await b.click();
toggled = true;
break;
}
}
assert(toggled, 'Could not find dark mode toggle button');
await page.waitForTimeout(300);
const themeAfter = await page.$eval('html', el => el.getAttribute('data-theme'));
assert(themeBefore !== themeAfter, `Theme didn't change: before=${themeBefore}, after=${themeAfter}`);
});
// Test 8: Analytics page loads
await test('Analytics page loads', async () => {
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const html = await page.content();
// Check for any analytics content
const hasContent = html.includes('analytics') || html.includes('Analytics') || html.includes('tab') || html.includes('chart') || html.includes('topology');
assert(hasContent, 'Analytics page has no recognizable content');
});
// Test 9: Map heat checkbox persists across reload
await test('Map heat checkbox persists in localStorage', async () => {
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle' });
await page.waitForSelector('#mcHeatmap', { timeout: 5000 });
// Uncheck first to ensure clean state
await page.evaluate(() => localStorage.removeItem('meshcore-map-heatmap'));
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#mcHeatmap', { timeout: 5000 });
let checked = await page.$eval('#mcHeatmap', el => el.checked);
assert(!checked, 'Heat checkbox should be unchecked by default');
// Check it
await page.click('#mcHeatmap');
const stored = await page.evaluate(() => localStorage.getItem('meshcore-map-heatmap'));
assert(stored === 'true', `localStorage should be "true" but got "${stored}"`);
// Reload and verify persisted
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#mcHeatmap', { timeout: 5000 });
checked = await page.$eval('#mcHeatmap', el => el.checked);
assert(checked, 'Heat checkbox should be checked after reload');
// Clean up
await page.evaluate(() => localStorage.removeItem('meshcore-map-heatmap'));
});
// Test 10: Map heat checkbox is not disabled (unless matrix mode)
await test('Map heat checkbox is clickable', async () => {
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle' });
await page.waitForSelector('#mcHeatmap', { timeout: 5000 });
const disabled = await page.$eval('#mcHeatmap', el => el.disabled);
assert(!disabled, 'Heat checkbox should not be disabled');
// Click and verify state changes
const before = await page.$eval('#mcHeatmap', el => el.checked);
await page.click('#mcHeatmap');
const after = await page.$eval('#mcHeatmap', el => el.checked);
assert(before !== after, 'Heat checkbox state should toggle on click');
});
// Test 11: Live page heat checkbox disabled by matrix/ghosts mode
await test('Live heat disabled when ghosts mode active', async () => {
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle' });
await page.waitForSelector('#liveHeatToggle', { timeout: 10000 });
// Enable matrix mode if not already
const matrixEl = await page.$('#liveMatrixToggle');
if (matrixEl) {
await page.evaluate(() => {
const mt = document.getElementById('liveMatrixToggle');
if (mt && !mt.checked) mt.click();
});
await page.waitForTimeout(500);
const heatDisabled = await page.$eval('#liveHeatToggle', el => el.disabled);
assert(heatDisabled, 'Heat should be disabled when ghosts/matrix is on');
// Turn off matrix
await page.evaluate(() => {
const mt = document.getElementById('liveMatrixToggle');
if (mt && mt.checked) mt.click();
});
await page.waitForTimeout(500);
const heatEnabled = await page.$eval('#liveHeatToggle', el => !el.disabled);
assert(heatEnabled, 'Heat should be re-enabled when ghosts/matrix is off');
}
});
// Test 12: Live page heat checkbox persists across reload
await test('Live heat checkbox persists in localStorage', async () => {
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle' });
await page.waitForSelector('#liveHeatToggle', { timeout: 10000 });
// Clear state
await page.evaluate(() => localStorage.removeItem('meshcore-live-heatmap'));
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#liveHeatToggle', { timeout: 10000 });
// Default is checked (has `checked` attribute in HTML)
const defaultState = await page.$eval('#liveHeatToggle', el => el.checked);
// Uncheck it
if (defaultState) await page.click('#liveHeatToggle');
const stored = await page.evaluate(() => localStorage.getItem('meshcore-live-heatmap'));
assert(stored === 'false', `localStorage should be "false" after unchecking but got "${stored}"`);
// Reload and verify persisted
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#liveHeatToggle', { timeout: 10000 });
const afterReload = await page.$eval('#liveHeatToggle', el => el.checked);
assert(!afterReload, 'Live heat checkbox should stay unchecked after reload');
// Clean up
await page.evaluate(() => localStorage.removeItem('meshcore-live-heatmap'));
});
// Test 13: Heatmap opacity stored in localStorage
await test('Heatmap opacity persists in localStorage', async () => {
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.setItem('meshcore-heatmap-opacity', '0.5'));
// Enable heat to trigger layer creation with saved opacity
await page.evaluate(() => localStorage.setItem('meshcore-map-heatmap', 'true'));
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const opacity = await page.evaluate(() => localStorage.getItem('meshcore-heatmap-opacity'));
assert(opacity === '0.5', `Opacity should persist as "0.5" but got "${opacity}"`);
// Verify the canvas element has the opacity applied (if heat layer exists)
const canvasOpacity = await page.evaluate(() => {
if (window._meshcoreHeatLayer && window._meshcoreHeatLayer._canvas) {
return window._meshcoreHeatLayer._canvas.style.opacity;
}
return null; // no heat layer (no node data) — skip
});
if (canvasOpacity !== null) {
assert(canvasOpacity === '0.5', `Canvas opacity should be "0.5" but got "${canvasOpacity}"`);
}
// Clean up
await page.evaluate(() => {
localStorage.removeItem('meshcore-heatmap-opacity');
localStorage.removeItem('meshcore-map-heatmap');
});
});
await browser.close();
// Test 14: Live heatmap opacity stored in localStorage
await test('Live heatmap opacity persists in localStorage', async () => {
// Verify localStorage key works (no page load needed — reuse current page)
await page.evaluate(() => localStorage.setItem('meshcore-live-heatmap-opacity', '0.6'));
const opacity = await page.evaluate(() => localStorage.getItem('meshcore-live-heatmap-opacity'));
assert(opacity === '0.6', `Live opacity should persist as "0.6" but got "${opacity}"`);
await page.evaluate(() => localStorage.removeItem('meshcore-live-heatmap-opacity'));
});
// Test 15: Customizer has separate Map and Live opacity sliders
await test('Customizer has separate map and live opacity sliders', async () => {
// Verify by checking JS source — avoids heavy page reloads that crash ARM chromium
const custJs = await page.evaluate(async () => {
const res = await fetch('/customize.js?_=' + Date.now());
return res.text();
});
assert(custJs.includes('custHeatOpacity'), 'customize.js should have map opacity slider (custHeatOpacity)');
assert(custJs.includes('custLiveHeatOpacity'), 'customize.js should have live opacity slider (custLiveHeatOpacity)');
assert(custJs.includes('meshcore-heatmap-opacity'), 'customize.js should use meshcore-heatmap-opacity key');
assert(custJs.includes('meshcore-live-heatmap-opacity'), 'customize.js should use meshcore-live-heatmap-opacity key');
// Verify labels are distinct
assert(custJs.includes('Nodes Map') || custJs.includes('nodes map') || custJs.includes('🗺'), 'Map slider should have map-related label');
assert(custJs.includes('Live Map') || custJs.includes('live map') || custJs.includes('📡'), 'Live slider should have live-related label');
});
// Test 16: Map re-renders markers on resize (decollision recalculates)
await test('Map re-renders on resize', async () => {
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle' });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
await page.waitForTimeout(2000);
// Count markers before resize
const beforeCount = await page.$$eval('.leaflet-marker-icon, .leaflet-interactive', els => els.length);
// Resize viewport
await page.setViewportSize({ width: 600, height: 400 });
await page.waitForTimeout(1500);
// Markers should still be present after resize (re-rendered, not lost)
const afterCount = await page.$$eval('.leaflet-marker-icon, .leaflet-interactive', els => els.length);
assert(afterCount > 0, `Should have markers after resize, got ${afterCount}`);
// Restore
await page.setViewportSize({ width: 1280, height: 720 });
});
// Summary
const passed = results.filter(r => r.pass).length;
const failed = results.filter(r => !r.pass).length;
console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`);
process.exit(failed > 0 ? 1 : 0);
}
run().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
-407
View File
@@ -1,407 +0,0 @@
/* Unit tests for frontend helper functions (tested via VM sandbox) */
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// --- Build a browser-like sandbox ---
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {} },
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
},
console,
Date,
Infinity,
Math,
Array,
Object,
String,
Number,
JSON,
RegExp,
Error,
TypeError,
parseInt,
parseFloat,
isNaN,
isFinite,
encodeURIComponent,
decodeURIComponent,
setTimeout: () => {},
clearTimeout: () => {},
setInterval: () => {},
clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
localStorage: (() => {
const store = {};
return {
getItem: k => store[k] || null,
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
};
})(),
location: { hash: '' },
CustomEvent: class CustomEvent {},
Map,
Promise,
URLSearchParams,
addEventListener: () => {},
dispatchEvent: () => {},
requestAnimationFrame: (cb) => setTimeout(cb, 0),
};
vm.createContext(ctx);
return ctx;
}
function loadInCtx(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
// Copy window.* to global context so bare references work
for (const k of Object.keys(ctx.window)) {
ctx[k] = ctx.window[k];
}
}
// ===== APP.JS TESTS =====
console.log('\n=== app.js: timeAgo ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
const timeAgo = ctx.timeAgo;
test('null returns dash', () => assert.strictEqual(timeAgo(null), '—'));
test('undefined returns dash', () => assert.strictEqual(timeAgo(undefined), '—'));
test('empty string returns dash', () => assert.strictEqual(timeAgo(''), '—'));
test('30 seconds ago', () => {
const d = new Date(Date.now() - 30000).toISOString();
assert.strictEqual(timeAgo(d), '30s ago');
});
test('5 minutes ago', () => {
const d = new Date(Date.now() - 300000).toISOString();
assert.strictEqual(timeAgo(d), '5m ago');
});
test('2 hours ago', () => {
const d = new Date(Date.now() - 7200000).toISOString();
assert.strictEqual(timeAgo(d), '2h ago');
});
test('3 days ago', () => {
const d = new Date(Date.now() - 259200000).toISOString();
assert.strictEqual(timeAgo(d), '3d ago');
});
}
console.log('\n=== app.js: escapeHtml ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
const escapeHtml = ctx.escapeHtml;
test('escapes < and >', () => assert.strictEqual(escapeHtml('<script>'), '&lt;script&gt;'));
test('escapes &', () => assert.strictEqual(escapeHtml('a&b'), 'a&amp;b'));
test('escapes quotes', () => assert.strictEqual(escapeHtml('"hello"'), '&quot;hello&quot;'));
test('null returns empty', () => assert.strictEqual(escapeHtml(null), ''));
test('undefined returns empty', () => assert.strictEqual(escapeHtml(undefined), ''));
test('number coerced', () => assert.strictEqual(escapeHtml(42), '42'));
}
console.log('\n=== app.js: routeTypeName / payloadTypeName ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
test('routeTypeName(0) = TRANSPORT_FLOOD', () => assert.strictEqual(ctx.routeTypeName(0), 'TRANSPORT_FLOOD'));
test('routeTypeName(2) = DIRECT', () => assert.strictEqual(ctx.routeTypeName(2), 'DIRECT'));
test('routeTypeName(99) = UNKNOWN', () => assert.strictEqual(ctx.routeTypeName(99), 'UNKNOWN'));
test('payloadTypeName(4) = Advert', () => assert.strictEqual(ctx.payloadTypeName(4), 'Advert'));
test('payloadTypeName(2) = Direct Msg', () => assert.strictEqual(ctx.payloadTypeName(2), 'Direct Msg'));
test('payloadTypeName(99) = UNKNOWN', () => assert.strictEqual(ctx.payloadTypeName(99), 'UNKNOWN'));
}
console.log('\n=== app.js: truncate ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
const truncate = ctx.truncate;
test('short string unchanged', () => assert.strictEqual(truncate('hello', 10), 'hello'));
test('long string truncated', () => assert.strictEqual(truncate('hello world', 5), 'hello…'));
test('null returns empty', () => assert.strictEqual(truncate(null, 5), ''));
test('empty returns empty', () => assert.strictEqual(truncate('', 5), ''));
}
// ===== NODES.JS TESTS =====
console.log('\n=== nodes.js: getStatusInfo ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
// nodes.js is an IIFE that registers a page — we need to mock registerPage and other globals
ctx.registerPage = () => {};
ctx.api = () => Promise.resolve([]);
ctx.timeAgo = vm.runInContext(`(${fs.readFileSync('public/app.js', 'utf8').match(/function timeAgo[^}]+}/)[0]})`, ctx);
// Actually, let's load app.js first for its globals
loadInCtx(ctx, 'public/app.js');
ctx.RegionFilter = { init: () => {}, getSelected: () => null, onRegionChange: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.connectWS = () => {};
loadInCtx(ctx, 'public/nodes.js');
// getStatusInfo is inside the IIFE, not on window. We need to extract it differently.
// Let's use a modified approach - inject a hook before loading
}
// Since nodes.js functions are inside an IIFE, we need to extract them.
// Strategy: modify the IIFE to expose functions on window for testing
console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
// Extract the functions from nodes.js source by wrapping them
const nodesSource = fs.readFileSync('public/nodes.js', 'utf8');
// Extract function bodies using regex - getStatusTooltip, getStatusInfo, renderNodeBadges, sortNodes
const fnNames = ['getStatusTooltip', 'getStatusInfo', 'renderNodeBadges', 'renderStatusExplanation', 'sortNodes'];
// Instead, let's inject an exporter into the IIFE
const modifiedSource = nodesSource.replace(
/\(function \(\) \{/,
'(function () { window.__nodesExport = {};'
).replace(
/function getStatusTooltip/,
'window.__nodesExport.getStatusTooltip = getStatusTooltip; function getStatusTooltip'
).replace(
/function getStatusInfo/,
'window.__nodesExport.getStatusInfo = getStatusInfo; function getStatusInfo'
).replace(
/function renderNodeBadges/,
'window.__nodesExport.renderNodeBadges = renderNodeBadges; function renderNodeBadges'
).replace(
/function renderStatusExplanation/,
'window.__nodesExport.renderStatusExplanation = renderStatusExplanation; function renderStatusExplanation'
).replace(
/function sortNodes/,
'window.__nodesExport.sortNodes = sortNodes; function sortNodes'
);
// Provide required globals
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => {}, getSelected: () => null, onRegionChange: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.connectWS = () => {};
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
try {
vm.runInContext(modifiedSource, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
} catch (e) {
console.log(' ⚠️ Could not load nodes.js in sandbox:', e.message.slice(0, 100));
}
const ex = ctx.window.__nodesExport || {};
if (ex.getStatusTooltip) {
const gst = ex.getStatusTooltip;
test('active repeater tooltip mentions 72h', () => {
assert.ok(gst('repeater', 'active').includes('72h'));
});
test('stale companion tooltip mentions normal', () => {
assert.ok(gst('companion', 'stale').includes('normal'));
});
test('stale sensor tooltip mentions offline', () => {
assert.ok(gst('sensor', 'stale').includes('offline'));
});
test('active companion tooltip mentions 24h', () => {
assert.ok(gst('companion', 'active').includes('24h'));
});
}
if (ex.getStatusInfo) {
const gsi = ex.getStatusInfo;
test('active repeater status', () => {
const info = gsi({ role: 'repeater', last_heard: new Date().toISOString() });
assert.strictEqual(info.status, 'active');
assert.ok(info.statusLabel.includes('Active'));
});
test('stale companion status (old date)', () => {
const old = new Date(Date.now() - 48 * 3600000).toISOString();
const info = gsi({ role: 'companion', last_heard: old });
assert.strictEqual(info.status, 'stale');
});
test('repeater stale at 4 days', () => {
const old = new Date(Date.now() - 96 * 3600000).toISOString();
const info = gsi({ role: 'repeater', last_heard: old });
assert.strictEqual(info.status, 'stale');
});
test('repeater active at 2 days', () => {
const d = new Date(Date.now() - 48 * 3600000).toISOString();
const info = gsi({ role: 'repeater', last_heard: d });
assert.strictEqual(info.status, 'active');
});
}
if (ex.renderNodeBadges) {
test('renderNodeBadges includes role', () => {
const html = ex.renderNodeBadges({ role: 'repeater', public_key: 'abcdef1234', last_heard: new Date().toISOString() }, '#ff0000');
assert.ok(html.includes('repeater'));
});
}
if (ex.sortNodes) {
const sortNodes = ex.sortNodes;
// We need to set sortState — it's closure-captured. Test via the exposed function behavior.
// sortNodes uses the closure sortState, so we can't easily test different sort modes
// without calling toggleSort. Let's just verify it returns a sorted array.
test('sortNodes returns array', () => {
const arr = [
{ name: 'Bravo', last_heard: new Date().toISOString() },
{ name: 'Alpha', last_heard: new Date(Date.now() - 1000).toISOString() },
];
const result = sortNodes(arr);
assert.ok(Array.isArray(result));
});
}
}
// ===== HOP-RESOLVER TESTS =====
console.log('\n=== hop-resolver.js ===');
{
const ctx = makeSandbox();
ctx.IATA_COORDS_GEO = {};
loadInCtx(ctx, 'public/hop-resolver.js');
const HR = ctx.window.HopResolver;
test('ready() false before init', () => assert.strictEqual(HR.ready(), false));
test('init + ready', () => {
HR.init([{ public_key: 'abcdef1234567890', name: 'NodeA', lat: 37.3, lon: -122.0 }]);
assert.strictEqual(HR.ready(), true);
});
test('resolve single unique prefix', () => {
HR.init([
{ public_key: 'abcdef1234567890', name: 'NodeA', lat: 37.3, lon: -122.0 },
{ public_key: '123456abcdef0000', name: 'NodeB', lat: 37.4, lon: -122.1 },
]);
const result = HR.resolve(['ab'], null, null, null, null);
assert.strictEqual(result['ab'].name, 'NodeA');
});
test('resolve ambiguous prefix', () => {
HR.init([
{ public_key: 'abcdef1234567890', name: 'NodeA', lat: 37.3, lon: -122.0 },
{ public_key: 'abcd001234567890', name: 'NodeC', lat: 38.0, lon: -121.0 },
]);
const result = HR.resolve(['ab'], null, null, null, null);
assert.ok(result['ab'].ambiguous);
assert.strictEqual(result['ab'].candidates.length, 2);
});
test('resolve unknown prefix returns null name', () => {
HR.init([{ public_key: 'abcdef1234567890', name: 'NodeA' }]);
const result = HR.resolve(['ff'], null, null, null, null);
assert.strictEqual(result['ff'].name, null);
});
test('empty hops returns empty', () => {
const result = HR.resolve([], null, null, null, null);
assert.strictEqual(Object.keys(result).length, 0);
});
test('geo disambiguation with origin anchor', () => {
HR.init([
{ public_key: 'abcdef1234567890', name: 'NearNode', lat: 37.31, lon: -122.01 },
{ public_key: 'abcd001234567890', name: 'FarNode', lat: 50.0, lon: 10.0 },
]);
const result = HR.resolve(['ab'], 37.3, -122.0, null, null);
// Should prefer the nearer node
assert.strictEqual(result['ab'].name, 'NearNode');
});
test('regional filtering with IATA', () => {
HR.init(
[
{ public_key: 'abcdef1234567890', name: 'SFONode', lat: 37.6, lon: -122.4 },
{ public_key: 'abcd001234567890', name: 'LHRNode', lat: 51.5, lon: -0.1 },
],
{
observers: [{ id: 'obs1', iata: 'SFO' }],
iataCoords: { SFO: { lat: 37.6, lon: -122.4 } },
}
);
const result = HR.resolve(['ab'], null, null, null, null, 'obs1');
assert.strictEqual(result['ab'].name, 'SFONode');
assert.ok(!result['ab'].ambiguous);
});
}
// ===== SNR/RSSI Number casting =====
{
// These test the pattern used in observer-detail.js, home.js, traces.js, live.js
// Values from DB may be strings — Number() must be called before .toFixed()
test('Number(string snr).toFixed works', () => {
const snr = "7.5"; // string from DB
assert.strictEqual(Number(snr).toFixed(1), "7.5");
});
test('Number(number snr).toFixed works', () => {
const snr = 7.5;
assert.strictEqual(Number(snr).toFixed(1), "7.5");
});
test('Number(null) produces NaN, guarded by != null check', () => {
const snr = null;
assert.ok(!(snr != null) || !isNaN(Number(snr).toFixed(1)));
});
test('Number(string rssi).toFixed works', () => {
const rssi = "-85";
assert.strictEqual(Number(rssi).toFixed(0), "-85");
});
test('Number(negative string snr).toFixed works', () => {
const snr = "-3.2";
assert.strictEqual(Number(snr).toFixed(1), "-3.2");
});
test('Number(integer string).toFixed adds decimal', () => {
const snr = "10";
assert.strictEqual(Number(snr).toFixed(1), "10.0");
});
}
// ===== SUMMARY =====
console.log(`\n${'═'.repeat(40)}`);
console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);
console.log(`${'═'.repeat(40)}\n`);
if (failed > 0) process.exit(1);
-179
View File
@@ -1,179 +0,0 @@
/**
* Tests for Live page hash-based packet deduplication in the feed.
* Injects packets by intercepting WebSocket before page loads.
*
* Usage:
* CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:13581 node test-live-dedup.js
*/
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
const results = [];
async function test(name, fn) {
try {
await fn();
results.push({ name, pass: true });
console.log(`${name}`);
} catch (err) {
results.push({ name, pass: false, error: err.message });
console.log(`${name}: ${err.message}`);
}
}
function assert(condition, msg) {
if (!condition) throw new Error(msg || 'Assertion failed');
}
async function run() {
console.log('Launching Chromium for Live dedup tests...');
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const context = await browser.newContext();
// Patch WebSocket BEFORE any page script runs
await context.addInitScript(() => {
const OrigWS = window.WebSocket;
window.__capturedWS = [];
window.WebSocket = function(...args) {
const ws = new OrigWS(...args);
window.__capturedWS.push(ws);
return ws;
};
window.WebSocket.prototype = OrigWS.prototype;
window.WebSocket.CONNECTING = OrigWS.CONNECTING;
window.WebSocket.OPEN = OrigWS.OPEN;
window.WebSocket.CLOSING = OrigWS.CLOSING;
window.WebSocket.CLOSED = OrigWS.CLOSED;
});
const page = await context.newPage();
page.setDefaultTimeout(15000);
console.log(`\nRunning Live dedup tests against ${BASE}\n`);
// Helper: navigate to live page, wait for feed, clear initial items
async function setupLivePage() {
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle' });
await page.waitForSelector('#liveFeed', { timeout: 10000 });
await page.waitForTimeout(3000); // let WS connect + initial replay
// Clear feed
await page.evaluate(() => {
document.getElementById('liveFeed').querySelectorAll('.live-feed-item').forEach(el => el.remove());
});
}
// Helper: inject a packet via captured WS
function injectPkt(hash, observer, type, text, id) {
return page.evaluate(({hash, observer, type, text, id}) => {
// Find the last active WebSocket with an onmessage handler
const wsList = window.__capturedWS || [];
let ws = null;
for (let i = wsList.length - 1; i >= 0; i--) {
if (wsList[i].onmessage && wsList[i].readyState === 1) { ws = wsList[i]; break; }
}
if (!ws) throw new Error('No active WebSocket found (count: ' + wsList.length + ')');
ws.onmessage({ data: JSON.stringify({ type: 'packet', data: {
id: id || 'test-' + Math.random().toString(36).slice(2),
hash: hash || undefined,
raw: 'AABB' + (hash || '0000').slice(0, 4),
decoded: {
header: { payloadTypeName: type || 'GRP_TXT' },
payload: { text: text || 'test msg' },
path: { hops: ['ab', 'cd'] }
},
snr: 10, rssi: -85, observer_name: observer || 'obs1'
}})});
}, {hash, observer, type, text, id});
}
await setupLivePage();
await test('Duplicate hash packets produce single feed entry', async () => {
const HASH = 'aabbccdd11223344';
await injectPkt(HASH, 'observer-A', 'GRP_TXT', 'hello');
await injectPkt(HASH, 'observer-B', 'GRP_TXT', 'hello');
await page.waitForTimeout(300);
const items = await page.$$eval(`.live-feed-item[data-hash="${HASH}"]`, els => els.length);
assert(items === 1, `Expected 1 feed item for hash, got ${items}`);
// Check observation badge shows 2
const badgeText = await page.$eval(`.live-feed-item[data-hash="${HASH}"] .badge-obs`, el => el.textContent);
assert(badgeText.includes('2'), `Badge should show 2, got "${badgeText}"`);
});
// Clear feed between tests
await page.evaluate(() => {
document.getElementById('liveFeed').querySelectorAll('.live-feed-item').forEach(el => el.remove());
});
await test('Different hash packets produce separate feed entries', async () => {
await injectPkt('bbbb111122223333', 'obs1', 'ADVERT', '', 'b1');
await injectPkt('cccc444455556666', 'obs1', 'TXT_MSG', 'direct', 'c1');
await page.waitForTimeout(300);
const count = await page.$$eval('.live-feed-item', els => els.length);
assert(count === 2, `Expected 2 items, got ${count}`);
});
await page.evaluate(() => {
document.getElementById('liveFeed').querySelectorAll('.live-feed-item').forEach(el => el.remove());
});
await test('Rapid sequential duplicates (5 observers) aggregate correctly', async () => {
const HASH = 'dddddddd33333333';
for (let i = 0; i < 5; i++) {
await injectPkt(HASH, 'obs-' + i, 'GRP_TXT', 'flood', 'td-' + i);
}
await page.waitForTimeout(300);
const items = await page.$$eval(`.live-feed-item[data-hash="${HASH}"]`, els => els.length);
assert(items === 1, `Expected 1 feed item for 5 observations, got ${items}`);
const badgeText = await page.$eval(`.live-feed-item[data-hash="${HASH}"] .badge-obs`, el => el.textContent);
assert(badgeText.includes('5'), `Badge should show 5, got "${badgeText}"`);
});
await page.evaluate(() => {
document.getElementById('liveFeed').querySelectorAll('.live-feed-item').forEach(el => el.remove());
});
await test('Same hash same observer still deduplicates', async () => {
const HASH = 'eeeeeeee44444444';
await injectPkt(HASH, 'same-obs', 'GRP_TXT', 'dup', 'e1');
await injectPkt(HASH, 'same-obs', 'GRP_TXT', 'dup', 'e2');
await page.waitForTimeout(300);
const count = await page.$$eval(`.live-feed-item[data-hash="${HASH}"]`, els => els.length);
assert(count === 1, `Expected 1 feed item, got ${count}`);
});
await page.evaluate(() => {
document.getElementById('liveFeed').querySelectorAll('.live-feed-item').forEach(el => el.remove());
});
await test('Packets without hash are not deduplicated', async () => {
await injectPkt(null, 'obs1', 'ACK', '', 'nh1');
await injectPkt(null, 'obs2', 'ACK', '', 'nh2');
await page.waitForTimeout(300);
const count = await page.$$eval('.live-feed-item', els => els.length);
assert(count === 2, `Expected 2 items for no-hash packets, got ${count}`);
});
await browser.close();
const passed = results.filter(r => r.pass).length;
const failed = results.filter(r => !r.pass).length;
console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`);
process.exit(failed > 0 ? 1 : 0);
}
run().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
-149
View File
@@ -1,149 +0,0 @@
/* Unit tests for packet filter language */
'use strict';
const vm = require('vm');
const fs = require('fs');
const code = fs.readFileSync('public/packet-filter.js', 'utf8');
const ctx = { window: {}, console };
vm.createContext(ctx);
vm.runInContext(code, ctx);
const PF = ctx.window.PacketFilter;
let pass = 0, fail = 0;
function test(name, fn) {
try { fn(); pass++; }
catch (e) { console.log(`FAIL: ${name}${e.message}`); fail++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
const pkt = {
route_type: 1, payload_type: 5, snr: 8.5, rssi: -45,
hash: 'abc123def456', raw_hex: '110500aabbccdd',
path_json: '["8A","B5","97"]',
decoded_json: JSON.stringify({
name: "ESP1 Gilroy Repeater", lat: 37.005, lon: -121.567,
pubKey: "f81d265c03c5c1b2", text: "Hello mesh", sender: "KpaPocket",
flags: { raw: 147, type: 2, repeater: true, room: false, hasLocation: true, hasName: true }
}),
observer_name: 'kpabap', observer_id: '2301ACD8E9DCEDE5',
observation_count: 3, timestamp: new Date().toISOString(),
};
const nullSnrPkt = { ...pkt, snr: null, rssi: null };
// --- Firmware type names ---
test('type == GRP_TXT', () => { assert(PF.compile('type == GRP_TXT').filter(pkt)); });
test('type == grp_txt (case insensitive)', () => { assert(PF.compile('type == grp_txt').filter(pkt)); });
test('type == ADVERT is false', () => { assert(!PF.compile('type == ADVERT').filter(pkt)); });
test('type == TXT_MSG is false', () => { assert(!PF.compile('type == TXT_MSG').filter(pkt)); });
test('type != GRP_TXT is false', () => { assert(!PF.compile('type != GRP_TXT').filter(pkt)); });
test('type != ADVERT is true', () => { assert(PF.compile('type != ADVERT').filter(pkt)); });
// --- Type aliases ---
test('type == channel (alias)', () => { assert(PF.compile('type == channel').filter(pkt)); });
test('type == "Channel Msg" (alias)', () => { assert(PF.compile('type == "Channel Msg"').filter(pkt)); });
test('type == dm is false', () => { assert(!PF.compile('type == dm').filter(pkt)); });
test('type == request is false', () => { assert(!PF.compile('type == request').filter(pkt)); });
// --- Route ---
test('route == FLOOD', () => { assert(PF.compile('route == FLOOD').filter(pkt)); });
test('route == DIRECT is false', () => { assert(!PF.compile('route == DIRECT').filter(pkt)); });
// --- Hash ---
test('hash == abc123def456', () => { assert(PF.compile('hash == abc123def456').filter(pkt)); });
test('hash contains abc', () => { assert(PF.compile('hash contains abc').filter(pkt)); });
test('hash starts_with abc', () => { assert(PF.compile('hash starts_with abc').filter(pkt)); });
test('hash ends_with 456', () => { assert(PF.compile('hash ends_with 456').filter(pkt)); });
// --- Numeric ---
test('snr > 5', () => { assert(PF.compile('snr > 5').filter(pkt)); });
test('snr > 10 is false', () => { assert(!PF.compile('snr > 10').filter(pkt)); });
test('snr >= 8.5', () => { assert(PF.compile('snr >= 8.5').filter(pkt)); });
test('snr < 8.5 is false', () => { assert(!PF.compile('snr < 8.5').filter(pkt)); });
test('rssi < -40', () => { assert(PF.compile('rssi < -40').filter(pkt)); });
test('rssi < -50 is false', () => { assert(!PF.compile('rssi < -50').filter(pkt)); });
// --- Hops ---
test('hops == 3', () => { assert(PF.compile('hops == 3').filter(pkt)); });
test('hops > 2', () => { assert(PF.compile('hops > 2').filter(pkt)); });
test('hops > 3 is false', () => { assert(!PF.compile('hops > 3').filter(pkt)); });
// --- Observer ---
test('observer == kpabap', () => { assert(PF.compile('observer == kpabap').filter(pkt)); });
test('observer contains kpa', () => { assert(PF.compile('observer contains kpa').filter(pkt)); });
// --- Observations ---
test('observations > 1', () => { assert(PF.compile('observations > 1').filter(pkt)); });
test('observations == 3', () => { assert(PF.compile('observations == 3').filter(pkt)); });
// --- Size ---
test('size > 3', () => { assert(PF.compile('size > 3').filter(pkt)); });
// --- Payload dot notation ---
test('payload.name contains "Gilroy"', () => { assert(PF.compile('payload.name contains "Gilroy"').filter(pkt)); });
test('payload.name contains "Oakland" is false', () => { assert(!PF.compile('payload.name contains "Oakland"').filter(pkt)); });
test('payload.name starts_with "ESP1"', () => { assert(PF.compile('payload.name starts_with "ESP1"').filter(pkt)); });
test('payload.lat > 37', () => { assert(PF.compile('payload.lat > 37').filter(pkt)); });
test('payload.lat > 38 is false', () => { assert(!PF.compile('payload.lat > 38').filter(pkt)); });
test('payload.lon < -121', () => { assert(PF.compile('payload.lon < -121').filter(pkt)); });
test('payload.pubKey starts_with "f81d"', () => { assert(PF.compile('payload.pubKey starts_with "f81d"').filter(pkt)); });
test('payload.text contains "Hello"', () => { assert(PF.compile('payload.text contains "Hello"').filter(pkt)); });
test('payload.sender == "KpaPocket"', () => { assert(PF.compile('payload.sender == "KpaPocket"').filter(pkt)); });
test('payload.flags.hasLocation (truthy)', () => { assert(PF.compile('payload.flags.hasLocation').filter(pkt)); });
test('payload.flags.room is false (truthy)', () => { assert(!PF.compile('payload.flags.room').filter(pkt)); });
test('payload.flags.raw == 147', () => { assert(PF.compile('payload.flags.raw == 147').filter(pkt)); });
test('payload_hex contains "aabb"', () => { assert(PF.compile('payload_hex contains "aabb"').filter(pkt)); });
// --- Logic ---
test('type == GRP_TXT && snr > 5', () => { assert(PF.compile('type == GRP_TXT && snr > 5').filter(pkt)); });
test('type == GRP_TXT && snr > 10 is false', () => { assert(!PF.compile('type == GRP_TXT && snr > 10').filter(pkt)); });
test('type == ADVERT || snr > 5', () => { assert(PF.compile('type == ADVERT || snr > 5').filter(pkt)); });
test('type == ADVERT || snr > 10 is false', () => { assert(!PF.compile('type == ADVERT || snr > 10').filter(pkt)); });
test('!(type == ADVERT)', () => { assert(PF.compile('!(type == ADVERT)').filter(pkt)); });
test('!(type == GRP_TXT) is false', () => { assert(!PF.compile('!(type == GRP_TXT)').filter(pkt)); });
// --- Parentheses ---
test('(type == ADVERT || type == GRP_TXT) && snr > 5', () => {
assert(PF.compile('(type == ADVERT || type == GRP_TXT) && snr > 5').filter(pkt));
});
test('(type == ADVERT) && snr > 5 is false', () => {
assert(!PF.compile('(type == ADVERT) && snr > 5').filter(pkt));
});
// --- Complex ---
test('type == GRP_TXT && snr > 5 && hops > 2', () => {
assert(PF.compile('type == GRP_TXT && snr > 5 && hops > 2').filter(pkt));
});
test('!(type == ACK) && !(type == PATH)', () => {
assert(PF.compile('!(type == ACK) && !(type == PATH)').filter(pkt));
});
test('payload.lat >= 37 && payload.lat <= 38 && payload.lon >= -122 && payload.lon <= -121', () => {
assert(PF.compile('payload.lat >= 37 && payload.lat <= 38 && payload.lon >= -122 && payload.lon <= -121').filter(pkt));
});
// --- Edge cases: null fields ---
test('snr > 5 with null snr → false', () => { assert(!PF.compile('snr > 5').filter(nullSnrPkt)); });
test('rssi < -50 with null rssi → false', () => { assert(!PF.compile('rssi < -50').filter(nullSnrPkt)); });
test('payload.nonexistent == "x" → false', () => { assert(!PF.compile('payload.nonexistent == "x"').filter(pkt)); });
test('payload.flags.nonexistent (truthy) → false', () => { assert(!PF.compile('payload.flags.nonexistent').filter(pkt)); });
// --- Error handling ---
test('empty filter → no error', () => {
const c = PF.compile('');
assert(c.error === null, 'should have no error');
});
test('invalid syntax → error message', () => {
const c = PF.compile('== broken');
assert(c.error !== null, 'should have error');
});
test('@@@ garbage → error', () => {
const c = PF.compile('@@@ garbage');
assert(c.error !== null, 'should have error');
});
test('unclosed quote → error', () => {
const c = PF.compile('type == "hello');
assert(c.error !== null, 'should have error');
});
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
process.exit(fail > 0 ? 1 : 0);
-374
View File
@@ -1,374 +0,0 @@
/* Unit tests for packet-store.js — uses a mock db module */
'use strict';
const assert = require('assert');
const PacketStore = require('./packet-store');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// Mock db module — minimal stubs for PacketStore
function createMockDb() {
let txIdCounter = 1;
let obsIdCounter = 1000;
return {
db: {
pragma: (query) => {
if (query.includes('table_info(observations)')) return [{ name: 'observer_idx' }];
return [];
},
prepare: (sql) => ({
get: (...args) => {
if (sql.includes('sqlite_master')) return { name: 'transmissions' };
if (sql.includes('nodes')) return null;
if (sql.includes('observers')) return [];
return null;
},
all: (...args) => [],
}),
},
insertTransmission: (data) => ({
transmissionId: txIdCounter++,
observationId: obsIdCounter++,
}),
};
}
function makePacketData(overrides = {}) {
return {
raw_hex: 'AABBCCDD',
hash: 'abc123',
timestamp: new Date().toISOString(),
route_type: 1,
payload_type: 5,
payload_version: 0,
decoded_json: JSON.stringify({ pubKey: 'DEADBEEF'.repeat(8) }),
observer_id: 'obs1',
observer_name: 'Observer1',
snr: 8.5,
rssi: -45,
path_json: '["AA","BB"]',
direction: 'rx',
...overrides,
};
}
// === Constructor ===
console.log('\n=== PacketStore constructor ===');
test('creates empty store', () => {
const store = new PacketStore(createMockDb());
assert.strictEqual(store.packets.length, 0);
assert.strictEqual(store.loaded, false);
});
test('respects maxMemoryMB config', () => {
const store = new PacketStore(createMockDb(), { maxMemoryMB: 512 });
assert.strictEqual(store.maxBytes, 512 * 1024 * 1024);
});
// === Load ===
console.log('\n=== Load ===');
test('load sets loaded flag', () => {
const store = new PacketStore(createMockDb());
store.load();
assert.strictEqual(store.loaded, true);
});
test('sqliteOnly mode skips RAM', () => {
const orig = process.env.NO_MEMORY_STORE;
process.env.NO_MEMORY_STORE = '1';
const store = new PacketStore(createMockDb());
store.load();
assert.strictEqual(store.sqliteOnly, true);
assert.strictEqual(store.packets.length, 0);
process.env.NO_MEMORY_STORE = orig || '';
if (!orig) delete process.env.NO_MEMORY_STORE;
});
// === Insert ===
console.log('\n=== Insert ===');
test('insert adds packet to memory', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData());
assert.strictEqual(store.packets.length, 1);
assert.strictEqual(store.stats.inserts, 1);
});
test('insert deduplicates by hash', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'dup1' }));
store.insert(makePacketData({ hash: 'dup1', observer_id: 'obs2' }));
assert.strictEqual(store.packets.length, 1);
assert.strictEqual(store.packets[0].observations.length, 2);
assert.strictEqual(store.packets[0].observation_count, 2);
});
test('insert dedup: same observer+path skipped', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'dup2' }));
store.insert(makePacketData({ hash: 'dup2' })); // same observer_id + path_json
assert.strictEqual(store.packets[0].observations.length, 1);
});
test('insert indexes by node pubkey', () => {
const store = new PacketStore(createMockDb());
store.load();
const pk = 'DEADBEEF'.repeat(8);
store.insert(makePacketData({ hash: 'n1', decoded_json: JSON.stringify({ pubKey: pk }) }));
assert(store.byNode.has(pk));
assert.strictEqual(store.byNode.get(pk).length, 1);
});
test('insert indexes byObserver', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ observer_id: 'obs-test' }));
assert(store.byObserver.has('obs-test'));
});
test('insert updates first_seen for earlier timestamp', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'ts1', timestamp: '2025-01-02T00:00:00Z', observer_id: 'o1' }));
store.insert(makePacketData({ hash: 'ts1', timestamp: '2025-01-01T00:00:00Z', observer_id: 'o2' }));
assert.strictEqual(store.packets[0].first_seen, '2025-01-01T00:00:00Z');
});
test('insert indexes ADVERT observer', () => {
const store = new PacketStore(createMockDb());
store.load();
const pk = 'AA'.repeat(32);
store.insert(makePacketData({ hash: 'adv1', payload_type: 4, decoded_json: JSON.stringify({ pubKey: pk }), observer_id: 'obs-adv' }));
assert(store._advertByObserver.has(pk));
assert(store._advertByObserver.get(pk).has('obs-adv'));
});
// === Query ===
console.log('\n=== Query ===');
test('query returns all packets', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'q1' }));
store.insert(makePacketData({ hash: 'q2' }));
const r = store.query();
assert.strictEqual(r.total, 2);
assert.strictEqual(r.packets.length, 2);
});
test('query by type filter', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qt1', payload_type: 4 }));
store.insert(makePacketData({ hash: 'qt2', payload_type: 5 }));
const r = store.query({ type: 4 });
assert.strictEqual(r.total, 1);
assert.strictEqual(r.packets[0].payload_type, 4);
});
test('query by route filter', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qr1', route_type: 0 }));
store.insert(makePacketData({ hash: 'qr2', route_type: 1 }));
const r = store.query({ route: 1 });
assert.strictEqual(r.total, 1);
});
test('query by hash (index path)', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qh1' }));
store.insert(makePacketData({ hash: 'qh2' }));
const r = store.query({ hash: 'qh1' });
assert.strictEqual(r.total, 1);
assert.strictEqual(r.packets[0].hash, 'qh1');
});
test('query by observer (index path)', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qo1', observer_id: 'obsA' }));
store.insert(makePacketData({ hash: 'qo2', observer_id: 'obsB' }));
const r = store.query({ observer: 'obsA' });
assert.strictEqual(r.total, 1);
});
test('query with limit and offset', () => {
const store = new PacketStore(createMockDb());
store.load();
for (let i = 0; i < 10; i++) store.insert(makePacketData({ hash: `ql${i}`, observer_id: `o${i}` }));
const r = store.query({ limit: 3, offset: 2 });
assert.strictEqual(r.packets.length, 3);
assert.strictEqual(r.total, 10);
});
test('query by since filter', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qs1', timestamp: '2025-01-01T00:00:00Z' }));
store.insert(makePacketData({ hash: 'qs2', timestamp: '2025-06-01T00:00:00Z', observer_id: 'o2' }));
const r = store.query({ since: '2025-03-01T00:00:00Z' });
assert.strictEqual(r.total, 1);
});
test('query by until filter', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qu1', timestamp: '2025-01-01T00:00:00Z' }));
store.insert(makePacketData({ hash: 'qu2', timestamp: '2025-06-01T00:00:00Z', observer_id: 'o2' }));
const r = store.query({ until: '2025-03-01T00:00:00Z' });
assert.strictEqual(r.total, 1);
});
test('query ASC order', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qa1', timestamp: '2025-06-01T00:00:00Z' }));
store.insert(makePacketData({ hash: 'qa2', timestamp: '2025-01-01T00:00:00Z', observer_id: 'o2' }));
const r = store.query({ order: 'ASC' });
assert(r.packets[0].timestamp < r.packets[1].timestamp);
});
// === queryGrouped ===
console.log('\n=== queryGrouped ===');
test('queryGrouped returns grouped data', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'qg1' }));
store.insert(makePacketData({ hash: 'qg1', observer_id: 'obs2' }));
store.insert(makePacketData({ hash: 'qg2', observer_id: 'obs3' }));
const r = store.queryGrouped();
assert.strictEqual(r.total, 2);
const g1 = r.packets.find(p => p.hash === 'qg1');
assert(g1);
assert.strictEqual(g1.observation_count, 2);
assert.strictEqual(g1.observer_count, 2);
});
// === getNodesByAdvertObservers ===
console.log('\n=== getNodesByAdvertObservers ===');
test('finds nodes by observer', () => {
const store = new PacketStore(createMockDb());
store.load();
const pk = 'BB'.repeat(32);
store.insert(makePacketData({ hash: 'nao1', payload_type: 4, decoded_json: JSON.stringify({ pubKey: pk }), observer_id: 'obs-x' }));
const result = store.getNodesByAdvertObservers(['obs-x']);
assert(result.has(pk));
});
test('returns empty for unknown observer', () => {
const store = new PacketStore(createMockDb());
store.load();
const result = store.getNodesByAdvertObservers(['nonexistent']);
assert.strictEqual(result.size, 0);
});
// === Other methods ===
console.log('\n=== Other methods ===');
test('getById returns observation', () => {
const store = new PacketStore(createMockDb());
store.load();
const id = store.insert(makePacketData({ hash: 'gbi1' }));
const obs = store.getById(id);
assert(obs);
});
test('getSiblings returns observations for hash', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'sib1' }));
store.insert(makePacketData({ hash: 'sib1', observer_id: 'obs2' }));
const sibs = store.getSiblings('sib1');
assert.strictEqual(sibs.length, 2);
});
test('getSiblings empty for unknown hash', () => {
const store = new PacketStore(createMockDb());
store.load();
assert.deepStrictEqual(store.getSiblings('nope'), []);
});
test('all() returns packets', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'all1' }));
assert.strictEqual(store.all().length, 1);
});
test('filter() works', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'f1', payload_type: 4 }));
store.insert(makePacketData({ hash: 'f2', payload_type: 5, observer_id: 'o2' }));
assert.strictEqual(store.filter(p => p.payload_type === 4).length, 1);
});
test('countForNode returns counts', () => {
const store = new PacketStore(createMockDb());
store.load();
const pk = 'CC'.repeat(32);
store.insert(makePacketData({ hash: 'cn1', decoded_json: JSON.stringify({ pubKey: pk }) }));
store.insert(makePacketData({ hash: 'cn1', decoded_json: JSON.stringify({ pubKey: pk }), observer_id: 'o2' }));
const c = store.countForNode(pk);
assert.strictEqual(c.transmissions, 1);
assert.strictEqual(c.observations, 2);
});
test('getStats returns stats object', () => {
const store = new PacketStore(createMockDb());
store.load();
const s = store.getStats();
assert.strictEqual(s.inMemory, 0);
assert(s.indexes);
assert.strictEqual(s.sqliteOnly, false);
});
test('getTimestamps returns timestamps', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'gt1', timestamp: '2025-06-01T00:00:00Z' }));
store.insert(makePacketData({ hash: 'gt2', timestamp: '2025-06-02T00:00:00Z', observer_id: 'o2' }));
const ts = store.getTimestamps('2025-05-01T00:00:00Z');
assert.strictEqual(ts.length, 2);
});
// === Eviction ===
console.log('\n=== Eviction ===');
test('evicts oldest when over maxPackets', () => {
const store = new PacketStore(createMockDb(), { maxMemoryMB: 1, estimatedPacketBytes: 500000 });
// maxPackets will be very small
store.load();
for (let i = 0; i < 10; i++) store.insert(makePacketData({ hash: `ev${i}`, observer_id: `o${i}` }));
assert(store.packets.length <= store.maxPackets);
assert(store.stats.evicted > 0);
});
// === findPacketsForNode ===
console.log('\n=== findPacketsForNode ===');
test('finds by pubkey', () => {
const store = new PacketStore(createMockDb());
store.load();
const pk = 'DD'.repeat(32);
store.insert(makePacketData({ hash: 'fpn1', decoded_json: JSON.stringify({ pubKey: pk }) }));
store.insert(makePacketData({ hash: 'fpn2', decoded_json: JSON.stringify({ pubKey: 'other' }), observer_id: 'o2' }));
const r = store.findPacketsForNode(pk);
assert.strictEqual(r.packets.length, 1);
assert.strictEqual(r.pubkey, pk);
});
test('finds by text search in decoded_json', () => {
const store = new PacketStore(createMockDb());
store.load();
store.insert(makePacketData({ hash: 'fpn3', decoded_json: JSON.stringify({ name: 'MySpecialNode' }) }));
const r = store.findPacketsForNode('MySpecialNode');
assert.strictEqual(r.packets.length, 1);
});
// === Summary ===
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
-135
View File
@@ -1,135 +0,0 @@
#!/usr/bin/env node
// Test: Regional hop resolution filtering
// Validates that resolve-hops correctly filters candidates by geography and observer region
const { IATA_COORDS, haversineKm, nodeNearRegion } = require('./iata-coords');
let pass = 0, fail = 0;
function assert(condition, msg) {
if (condition) { pass++; console.log(`${msg}`); }
else { fail++; console.error(` ❌ FAIL: ${msg}`); }
}
// === 1. Haversine distance tests ===
console.log('\n=== Haversine Distance ===');
const sjcToSea = haversineKm(37.3626, -121.9290, 47.4502, -122.3088);
assert(sjcToSea > 1100 && sjcToSea < 1150, `SJC→SEA = ${Math.round(sjcToSea)}km (expect ~1125km)`);
const sjcToOak = haversineKm(37.3626, -121.9290, 37.7213, -122.2208);
assert(sjcToOak > 40 && sjcToOak < 55, `SJC→OAK = ${Math.round(sjcToOak)}km (expect ~48km)`);
const sjcToSjc = haversineKm(37.3626, -121.9290, 37.3626, -121.9290);
assert(sjcToSjc === 0, `SJC→SJC = ${sjcToSjc}km (expect 0)`);
const sjcToEug = haversineKm(37.3626, -121.9290, 44.1246, -123.2119);
assert(sjcToEug > 750 && sjcToEug < 780, `SJC→EUG = ${Math.round(sjcToEug)}km (expect ~762km)`);
// === 2. nodeNearRegion tests ===
console.log('\n=== Node Near Region ===');
// Node in San Jose, check against SJC region
const sjNode = nodeNearRegion(37.35, -121.95, 'SJC');
assert(sjNode && sjNode.near, `San Jose node near SJC: ${sjNode.distKm}km`);
// Node in Seattle, check against SJC region — should NOT be near
const seaNode = nodeNearRegion(47.45, -122.30, 'SJC');
assert(seaNode && !seaNode.near, `Seattle node NOT near SJC: ${seaNode.distKm}km`);
// Node in Seattle, check against SEA region — should be near
const seaNodeSea = nodeNearRegion(47.45, -122.30, 'SEA');
assert(seaNodeSea && seaNodeSea.near, `Seattle node near SEA: ${seaNodeSea.distKm}km`);
// Node in Eugene, check against EUG — should be near
const eugNode = nodeNearRegion(44.05, -123.10, 'EUG');
assert(eugNode && eugNode.near, `Eugene node near EUG: ${eugNode.distKm}km`);
// Eugene node should NOT be near SJC (~762km)
const eugNodeSjc = nodeNearRegion(44.05, -123.10, 'SJC');
assert(eugNodeSjc && !eugNodeSjc.near, `Eugene node NOT near SJC: ${eugNodeSjc.distKm}km`);
// Node with no location — returns null
const noLoc = nodeNearRegion(null, null, 'SJC');
assert(noLoc === null, 'Null lat/lon returns null');
// Node at 0,0 — returns null
const zeroLoc = nodeNearRegion(0, 0, 'SJC');
assert(zeroLoc === null, 'Zero lat/lon returns null');
// Unknown IATA — returns null
const unkIata = nodeNearRegion(37.35, -121.95, 'ZZZ');
assert(unkIata === null, 'Unknown IATA returns null');
// === 3. Edge cases: nodes just inside/outside 300km radius ===
console.log('\n=== Boundary Tests (300km radius) ===');
// Sacramento is ~145km from SJC — inside
const smfNode = nodeNearRegion(38.58, -121.49, 'SJC');
assert(smfNode && smfNode.near, `Sacramento near SJC: ${smfNode.distKm}km (expect ~145)`);
// Fresno is ~235km from SJC — inside
const fatNode = nodeNearRegion(36.74, -119.79, 'SJC');
assert(fatNode && fatNode.near, `Fresno near SJC: ${fatNode.distKm}km (expect ~235)`);
// Redding is ~400km from SJC — outside
const rddNode = nodeNearRegion(40.59, -122.39, 'SJC');
assert(rddNode && !rddNode.near, `Redding NOT near SJC: ${rddNode.distKm}km (expect ~400)`);
// === 4. Simulate the core issue: 1-byte hop with cross-regional collision ===
console.log('\n=== Cross-Regional Collision Simulation ===');
// Two nodes with pubkeys starting with "D6": one in SJC area, one in SEA area
const candidates = [
{ name: 'Redwood Mt. Tam', pubkey: 'D6...sjc', lat: 37.92, lon: -122.60 }, // Marin County, CA
{ name: 'VE7RSC North Repeater', pubkey: 'D6...sea', lat: 49.28, lon: -123.12 }, // Vancouver, BC
{ name: 'KK7RXY Lynden', pubkey: 'D6...bel', lat: 48.94, lon: -122.47 }, // Bellingham, WA
];
// Packet observed in SJC region
const packetIata = 'SJC';
const geoFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, packetIata);
return check && check.near;
});
assert(geoFiltered.length === 1, `Geo filter SJC: ${geoFiltered.length} candidates (expect 1)`);
assert(geoFiltered[0].name === 'Redwood Mt. Tam', `Winner: ${geoFiltered[0].name} (expect Redwood Mt. Tam)`);
// Packet observed in SEA region
const seaFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, 'SEA');
return check && check.near;
});
assert(seaFiltered.length === 2, `Geo filter SEA: ${seaFiltered.length} candidates (expect 2 — Vancouver + Bellingham)`);
// Packet observed in EUG region — Eugene is ~300km from SEA nodes
const eugFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, 'EUG');
return check && check.near;
});
assert(eugFiltered.length === 0, `Geo filter EUG: ${eugFiltered.length} candidates (expect 0 — all too far)`);
// === 5. Layered fallback logic ===
console.log('\n=== Layered Fallback ===');
const nodeWithGps = { lat: 37.92, lon: -122.60 }; // has GPS
const nodeNoGps = { lat: null, lon: null }; // no GPS
const observerSawNode = true; // observer-based filter says yes
// Layer 1: GPS check
const gpsCheck = nodeNearRegion(nodeWithGps.lat, nodeWithGps.lon, 'SJC');
assert(gpsCheck && gpsCheck.near, 'Layer 1 (GPS): node with GPS near SJC');
// Layer 2: No GPS, fall back to observer
const gpsCheckNoLoc = nodeNearRegion(nodeNoGps.lat, nodeNoGps.lon, 'SJC');
assert(gpsCheckNoLoc === null, 'Layer 2: no GPS returns null → use observer-based fallback');
// Bridged WA node with GPS — should be REJECTED by SJC even though observer saw it
const bridgedWaNode = { lat: 47.45, lon: -122.30 }; // Seattle
const bridgedCheck = nodeNearRegion(bridgedWaNode.lat, bridgedWaNode.lon, 'SJC');
assert(bridgedCheck && !bridgedCheck.near, `Bridge test: WA node rejected by SJC geo filter (${bridgedCheck.distKm}km)`);
// === Summary ===
console.log(`\n${'='.repeat(40)}`);
console.log(`Results: ${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
// Integration test: Verify layered filtering works against live prod API
// Tests that resolve-hops returns regional metadata and correct filtering
const https = require('https');
const BASE = 'https://analyzer.00id.net';
function apiGet(path) {
return new Promise((resolve, reject) => {
https.get(BASE + path, { timeout: 10000 }, (res) => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
}).on('error', reject);
});
}
let pass = 0, fail = 0;
function assert(condition, msg) {
if (condition) { pass++; console.log(`${msg}`); }
else { fail++; console.error(` ❌ FAIL: ${msg}`); }
}
async function run() {
console.log('\n=== Integration: resolve-hops API with regional filtering ===\n');
// 1. Get a packet with short hops and a known observer
const packets = await apiGet('/api/packets?limit=100&groupByHash=true');
const pkt = packets.packets.find(p => {
const path = JSON.parse(p.path_json || '[]');
return path.length > 0 && path.some(h => h.length <= 2) && p.observer_id;
});
if (!pkt) {
console.log(' ⚠ No packets with short hops found — skipping API tests');
return;
}
const path = JSON.parse(pkt.path_json);
const shortHops = path.filter(h => h.length <= 2);
console.log(` Using packet ${pkt.hash.slice(0,12)} observed by ${pkt.observer_name || pkt.observer_id.slice(0,12)}`);
console.log(` Path: ${path.join(' → ')} (${shortHops.length} short hops)`);
// 2. Resolve WITH observer (should get regional filtering)
const withObs = await apiGet(`/api/resolve-hops?hops=${path.join(',')}&observer=${pkt.observer_id}`);
assert(withObs.region != null, `Response includes region: ${withObs.region}`);
// 3. Check that conflicts have filterMethod field
let hasFilterMethod = false;
let hasDistKm = false;
for (const [hop, info] of Object.entries(withObs.resolved)) {
if (info.conflicts && info.conflicts.length > 0) {
for (const c of info.conflicts) {
if (c.filterMethod) hasFilterMethod = true;
if (c.distKm != null) hasDistKm = true;
}
}
if (info.filterMethods) {
assert(Array.isArray(info.filterMethods), `Hop ${hop}: filterMethods is array: ${JSON.stringify(info.filterMethods)}`);
}
}
assert(hasFilterMethod, 'At least one conflict has filterMethod');
// 4. Resolve WITHOUT observer (no regional filtering)
const withoutObs = await apiGet(`/api/resolve-hops?hops=${path.join(',')}`);
assert(withoutObs.region === null, `Without observer: region is null`);
// 5. Compare: with observer should have same or fewer candidates per ambiguous hop
for (const hop of shortHops) {
const withInfo = withObs.resolved[hop];
const withoutInfo = withoutObs.resolved[hop];
if (withInfo && withoutInfo && withInfo.conflicts && withoutInfo.conflicts) {
const withCount = withInfo.totalRegional || withInfo.conflicts.length;
const withoutCount = withoutInfo.totalGlobal || withoutInfo.conflicts.length;
assert(withCount <= withoutCount + 1,
`Hop ${hop}: regional(${withCount}) <= global(${withoutCount}) — ${withInfo.name || '?'}`);
}
}
// 6. Check that geo-filtered candidates have distKm
for (const [hop, info] of Object.entries(withObs.resolved)) {
if (info.conflicts) {
const geoFiltered = info.conflicts.filter(c => c.filterMethod === 'geo');
for (const c of geoFiltered) {
assert(c.distKm != null, `Hop ${hop} candidate ${c.name}: has distKm=${c.distKm}km (geo filter)`);
}
}
}
console.log(`\n${'='.repeat(40)}`);
console.log(`Results: ${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);
}
run().catch(e => { console.error('Test error:', e); process.exit(1); });
-319
View File
@@ -1,319 +0,0 @@
'use strict';
const helpers = require('./server-helpers');
const path = require('path');
const fs = require('fs');
const os = require('os');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(`${msg}`); }
else { failed++; console.error(`${msg}`); }
}
console.log('── server-helpers tests ──\n');
// --- loadConfigFile ---
console.log('loadConfigFile:');
{
// Returns {} when no files exist
const result = helpers.loadConfigFile(['/nonexistent/path.json']);
assert(typeof result === 'object' && Object.keys(result).length === 0, 'returns {} for missing files');
// Loads valid JSON
const tmp = path.join(os.tmpdir(), `test-config-${Date.now()}.json`);
fs.writeFileSync(tmp, JSON.stringify({ hello: 'world' }));
const result2 = helpers.loadConfigFile([tmp]);
assert(result2.hello === 'world', 'loads valid JSON file');
fs.unlinkSync(tmp);
// Falls back to second path
const tmp2 = path.join(os.tmpdir(), `test-config2-${Date.now()}.json`);
fs.writeFileSync(tmp2, JSON.stringify({ fallback: true }));
const result3 = helpers.loadConfigFile(['/nonexistent.json', tmp2]);
assert(result3.fallback === true, 'falls back to second path');
fs.unlinkSync(tmp2);
// Handles malformed JSON
const tmp3 = path.join(os.tmpdir(), `test-config3-${Date.now()}.json`);
fs.writeFileSync(tmp3, 'not json{{{');
const result4 = helpers.loadConfigFile([tmp3]);
assert(Object.keys(result4).length === 0, 'returns {} for malformed JSON');
fs.unlinkSync(tmp3);
}
// --- loadThemeFile ---
console.log('\nloadThemeFile:');
{
const result = helpers.loadThemeFile(['/nonexistent/theme.json']);
assert(typeof result === 'object' && Object.keys(result).length === 0, 'returns {} for missing files');
const tmp = path.join(os.tmpdir(), `test-theme-${Date.now()}.json`);
fs.writeFileSync(tmp, JSON.stringify({ theme: { accent: '#ff0000' } }));
const result2 = helpers.loadThemeFile([tmp]);
assert(result2.theme.accent === '#ff0000', 'loads theme file');
fs.unlinkSync(tmp);
}
// --- buildHealthConfig ---
console.log('\nbuildHealthConfig:');
{
const h = helpers.buildHealthConfig({});
assert(h.infraDegradedMs === 86400000, 'default infraDegradedMs');
assert(h.infraSilentMs === 259200000, 'default infraSilentMs');
assert(h.nodeDegradedMs === 3600000, 'default nodeDegradedMs');
assert(h.nodeSilentMs === 86400000, 'default nodeSilentMs');
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedMs: 1000 } });
assert(h2.infraDegradedMs === 1000, 'custom infraDegradedMs');
assert(h2.nodeDegradedMs === 3600000, 'other defaults preserved');
const h3 = helpers.buildHealthConfig(null);
assert(h3.infraDegradedMs === 86400000, 'handles null config');
}
// --- getHealthMs ---
console.log('\ngetHealthMs:');
{
const HEALTH = helpers.buildHealthConfig({});
const rep = helpers.getHealthMs('repeater', HEALTH);
assert(rep.degradedMs === 86400000, 'repeater uses infra degraded');
assert(rep.silentMs === 259200000, 'repeater uses infra silent');
const room = helpers.getHealthMs('room', HEALTH);
assert(room.degradedMs === 86400000, 'room uses infra degraded');
const comp = helpers.getHealthMs('companion', HEALTH);
assert(comp.degradedMs === 3600000, 'companion uses node degraded');
assert(comp.silentMs === 86400000, 'companion uses node silent');
const sensor = helpers.getHealthMs('sensor', HEALTH);
assert(sensor.degradedMs === 3600000, 'sensor uses node degraded');
const undef = helpers.getHealthMs(undefined, HEALTH);
assert(undef.degradedMs === 3600000, 'undefined role uses node degraded');
}
// --- isHashSizeFlipFlop ---
console.log('\nisHashSizeFlipFlop:');
{
assert(helpers.isHashSizeFlipFlop(null, null) === false, 'null seq returns false');
assert(helpers.isHashSizeFlipFlop([1, 2], new Set([1, 2])) === false, 'too few samples');
assert(helpers.isHashSizeFlipFlop([1, 1, 1], new Set([1])) === false, 'single size');
assert(helpers.isHashSizeFlipFlop([1, 1, 1, 2, 2, 2], new Set([1, 2])) === false, 'clean upgrade (1 transition)');
assert(helpers.isHashSizeFlipFlop([1, 2, 1], new Set([1, 2])) === true, 'flip-flop detected');
assert(helpers.isHashSizeFlipFlop([1, 2, 1, 2], new Set([1, 2])) === true, 'repeated flip-flop');
assert(helpers.isHashSizeFlipFlop([2, 1, 2], new Set([1, 2])) === true, 'reverse flip-flop');
assert(helpers.isHashSizeFlipFlop([1, 2, 3], new Set([1, 2, 3])) === true, 'three sizes, 2 transitions');
}
// --- computeContentHash ---
console.log('\ncomputeContentHash:');
{
// Minimal packet: header + path byte + payload
// header=0x04, path_byte=0x00 (hash_size=1, 0 hops), payload=0xABCD
const hex1 = '0400abcd';
const h1 = helpers.computeContentHash(hex1);
assert(typeof h1 === 'string' && h1.length === 16, 'returns 16-char hash');
// Same payload, different path should give same hash
// header=0x04, path_byte=0x41 (hash_size=2, 1 hop), path=0x1234, payload=0xABCD
const hex2 = '04411234abcd';
const h2 = helpers.computeContentHash(hex2);
assert(h1 === h2, 'same content different path = same hash');
// Different payload = different hash
const hex3 = '0400ffff';
const h3 = helpers.computeContentHash(hex3);
assert(h3 !== h1, 'different payload = different hash');
// Very short hex
const h4 = helpers.computeContentHash('04');
assert(h4 === '04', 'short hex returns prefix');
// Invalid hex
const h5 = helpers.computeContentHash('xyz');
assert(typeof h5 === 'string', 'handles invalid hex gracefully');
}
// --- geoDist ---
console.log('\ngeoDist:');
{
assert(helpers.geoDist(0, 0, 0, 0) === 0, 'same point = 0');
assert(helpers.geoDist(0, 0, 3, 4) === 5, 'pythagorean triple');
assert(helpers.geoDist(37.7749, -122.4194, 37.7749, -122.4194) === 0, 'SF to SF = 0');
const d = helpers.geoDist(37.0, -122.0, 38.0, -122.0);
assert(Math.abs(d - 1.0) < 0.001, '1 degree latitude diff');
}
// --- deriveHashtagChannelKey ---
console.log('\nderiveHashtagChannelKey:');
{
const k1 = helpers.deriveHashtagChannelKey('test');
assert(typeof k1 === 'string' && k1.length === 32, 'returns 32-char key');
const k2 = helpers.deriveHashtagChannelKey('test');
assert(k1 === k2, 'deterministic');
const k3 = helpers.deriveHashtagChannelKey('other');
assert(k3 !== k1, 'different input = different key');
}
// --- buildBreakdown ---
console.log('\nbuildBreakdown:');
{
const r1 = helpers.buildBreakdown(null, null, null, null);
assert(JSON.stringify(r1) === '{}', 'null rawHex returns empty');
const r2 = helpers.buildBreakdown('04', null, null, null);
assert(r2.ranges.length === 1, 'single-byte returns header only');
assert(r2.ranges[0].label === 'Header', 'header range');
// 2 bytes: header + path byte, no payload
const r3 = helpers.buildBreakdown('0400', null, null, null);
assert(r3.ranges.length === 2, 'two bytes: header + path length');
assert(r3.ranges[1].label === 'Path Length', 'path length range');
// With payload: header=04, path_byte=00, payload=abcd
const r4 = helpers.buildBreakdown('0400abcd', null, null, null);
assert(r4.ranges.some(r => r.label === 'Payload'), 'has payload range');
// With path hops: header=04, path_byte=0x41 (size=2, count=1), path=1234, payload=ff
const r5 = helpers.buildBreakdown('04411234ff', null, null, null);
assert(r5.ranges.some(r => r.label === 'Path'), 'has path range');
// ADVERT with enough payload
// flags=0x90 (0x10=GPS + 0x80=Name)
const advertHex = '0400' + 'aa'.repeat(32) + 'bb'.repeat(4) + 'cc'.repeat(64) + '90' + 'dddddddddddddddd' + '48656c6c6f';
const r6 = helpers.buildBreakdown(advertHex, { type: 'ADVERT' }, null, null);
assert(r6.ranges.some(r => r.label === 'PubKey'), 'ADVERT has PubKey sub-range');
assert(r6.ranges.some(r => r.label === 'Flags'), 'ADVERT has Flags sub-range');
assert(r6.ranges.some(r => r.label === 'Latitude'), 'ADVERT with GPS flag has Latitude');
assert(r6.ranges.some(r => r.label === 'Name'), 'ADVERT with name flag has Name');
}
// --- disambiguateHops ---
console.log('\ndisambiguateHops:');
{
const nodes = [
{ public_key: 'aabb11223344', name: 'Node-A', lat: 37.0, lon: -122.0 },
{ public_key: 'ccdd55667788', name: 'Node-C', lat: 37.1, lon: -122.1 },
];
// Single unique match
const r1 = helpers.disambiguateHops(['aabb'], nodes);
assert(r1.length === 1, 'resolves single hop');
assert(r1[0].name === 'Node-A', 'resolves to correct node');
assert(r1[0].pubkey === 'aabb11223344', 'includes pubkey');
// Unknown hop
delete nodes._prefixIdx; delete nodes._prefixIdxName;
const r2 = helpers.disambiguateHops(['ffff'], nodes);
assert(r2[0].name === 'ffff', 'unknown hop uses hex as name');
// Multiple hops
delete nodes._prefixIdx; delete nodes._prefixIdxName;
const r3 = helpers.disambiguateHops(['aabb', 'ccdd'], nodes);
assert(r3.length === 2, 'resolves multiple hops');
assert(r3[0].name === 'Node-A' && r3[1].name === 'Node-C', 'both resolved');
}
// --- updateHashSizeForPacket ---
console.log('\nupdateHashSizeForPacket:');
{
const map = new Map(), allMap = new Map(), seqMap = new Map();
// ADVERT packet (payload_type=4)
// path byte 0x40 = hash_size 2 (bits 7-6 = 01)
const p1 = {
payload_type: 4,
raw_hex: '0440' + 'aa'.repeat(100),
decoded_json: JSON.stringify({ pubKey: 'abc123' }),
path_json: null
};
helpers.updateHashSizeForPacket(p1, map, allMap, seqMap);
assert(map.get('abc123') === 2, 'ADVERT sets hash_size=2');
assert(allMap.get('abc123').has(2), 'all map has size 2');
assert(seqMap.get('abc123')[0] === 2, 'seq map records size');
// Non-ADVERT with path_json fallback
const map2 = new Map(), allMap2 = new Map(), seqMap2 = new Map();
const p2 = {
payload_type: 1,
raw_hex: '0140ff', // path byte 0x40 = hash_size 2
decoded_json: JSON.stringify({ pubKey: 'def456' }),
path_json: JSON.stringify(['aabb'])
};
helpers.updateHashSizeForPacket(p2, map2, allMap2, seqMap2);
assert(map2.get('def456') === 2, 'non-ADVERT falls back to path byte');
// Already-parsed decoded_json (object, not string)
const map3 = new Map(), allMap3 = new Map(), seqMap3 = new Map();
const p3 = {
payload_type: 4,
raw_hex: '04c0' + 'aa'.repeat(100), // 0xC0 = bits 7-6 = 11 = hash_size 4
decoded_json: { pubKey: 'ghi789' },
path_json: null
};
helpers.updateHashSizeForPacket(p3, map3, allMap3, seqMap3);
assert(map3.get('ghi789') === 4, 'handles object decoded_json');
}
// --- rebuildHashSizeMap ---
console.log('\nrebuildHashSizeMap:');
{
const map = new Map(), allMap = new Map(), seqMap = new Map();
const packets = [
// Newest first (as packet store provides)
{ payload_type: 4, raw_hex: '0480' + 'bb'.repeat(50), decoded_json: JSON.stringify({ pubKey: 'node1' }), path_json: null },
{ payload_type: 4, raw_hex: '0440' + 'aa'.repeat(50), decoded_json: JSON.stringify({ pubKey: 'node1' }), path_json: null },
];
helpers.rebuildHashSizeMap(packets, map, allMap, seqMap);
assert(map.get('node1') === 3, 'first seen (newest) wins for map');
assert(allMap.get('node1').size === 2, 'all map has both sizes');
// Seq should be reversed to chronological: [2, 3]
const seq = seqMap.get('node1');
assert(seq[0] === 2 && seq[1] === 3, 'sequence is chronological (reversed)');
// Pass 2 fallback: node without advert
const map2 = new Map(), allMap2 = new Map(), seqMap2 = new Map();
const packets2 = [
{ payload_type: 1, raw_hex: '0140ff', decoded_json: JSON.stringify({ pubKey: 'node2' }), path_json: JSON.stringify(['aabb']) },
];
helpers.rebuildHashSizeMap(packets2, map2, allMap2, seqMap2);
assert(map2.get('node2') === 2, 'pass 2 fallback from path');
}
// --- requireApiKey ---
console.log('\nrequireApiKey:');
{
// No API key configured
const mw1 = helpers.requireApiKey(null);
let nextCalled = false;
mw1({headers: {}, query: {}}, {}, () => { nextCalled = true; });
assert(nextCalled, 'no key configured = passes through');
// Valid key
const mw2 = helpers.requireApiKey('secret123');
nextCalled = false;
mw2({headers: {'x-api-key': 'secret123'}, query: {}}, {}, () => { nextCalled = true; });
assert(nextCalled, 'valid header key passes');
// Valid key via query
nextCalled = false;
mw2({headers: {}, query: {apiKey: 'secret123'}}, {}, () => { nextCalled = true; });
assert(nextCalled, 'valid query key passes');
// Invalid key
let statusCode = null, jsonBody = null;
const mockRes = {
status(code) { statusCode = code; return { json(body) { jsonBody = body; } }; }
};
nextCalled = false;
mw2({headers: {'x-api-key': 'wrong'}, query: {}}, mockRes, () => { nextCalled = true; });
assert(!nextCalled && statusCode === 401, 'invalid key returns 401');
}
console.log(`\n═══════════════════════════════════════`);
console.log(` PASSED: ${passed}`);
console.log(` FAILED: ${failed}`);
console.log(`═══════════════════════════════════════`);
if (failed > 0) process.exit(1);
File diff suppressed because it is too large Load Diff
+15 -20
View File
@@ -291,7 +291,7 @@ async function main() {
console.log('── Stats ──');
const stats = (await get('/api/stats')).data;
// totalPackets includes seed packet, so should be >= injected.length
assert(stats.totalPackets > 0, `stats.totalPackets (${stats.totalPackets}) >= ${injected.length}`);
assert(stats.totalPackets >= injected.length, `stats.totalPackets (${stats.totalPackets}) >= ${injected.length}`);
assert(stats.totalNodes > 0, `stats.totalNodes > 0 (${stats.totalNodes})`);
assert(stats.totalObservers >= OBSERVERS.length, `stats.totalObservers >= ${OBSERVERS.length} (${stats.totalObservers})`);
console.log(` totalPackets=${stats.totalPackets} totalNodes=${stats.totalNodes} totalObservers=${stats.totalObservers}\n`);
@@ -299,7 +299,7 @@ async function main() {
// 5b. Packets API - basic list
console.log('── Packets API ──');
const pktsAll = (await get('/api/packets?limit=200')).data;
assert(pktsAll.total > 0, `packets total (${pktsAll.total}) > 0`);
assert(pktsAll.total >= injected.length, `packets total (${pktsAll.total}) >= injected (${injected.length})`);
assert(pktsAll.packets.length > 0, 'packets array not empty');
// Filter by type (ADVERT = 4)
@@ -311,7 +311,7 @@ async function main() {
const testObs = OBSERVERS[0].id;
const pktsObs = (await get(`/api/packets?observer=${testObs}&limit=200`)).data;
assert(pktsObs.total > 0, `filter by observer=${testObs} returns results`);
assert(pktsObs.packets.length > 0, 'observer filter returns packets');
assert(pktsObs.packets.every(p => p.observer_id === testObs), 'all filtered packets match observer');
// Filter by region
const pktsRegion = (await get('/api/packets?region=SJC&limit=200')).data;
@@ -370,18 +370,15 @@ async function main() {
// 5e. Channels
console.log('── Channels ──');
const chResp = (await get('/api/channels')).data;
const chList = chResp.channels || [];
assert(Array.isArray(chList), 'channels response is array');
if (chList.length > 0) {
const someCh = chList[0];
assert(someCh.messageCount > 0, `channel has messages (${someCh.messageCount})`);
const msgResp = (await get(`/api/channels/${encodeURIComponent(someCh.hash)}/messages`)).data;
assert(msgResp.messages.length > 0, 'channel has message list');
assert(msgResp.messages[0].sender !== undefined, 'message has sender');
console.log(` ✓ Channels: ${chList.length} channels\n`);
} else {
console.log(` ⚠ Channels: 0 (synthetic packets don't produce decodable channel messages)\n`);
}
assert(chResp.channels.length > 0, `channels found (${chResp.channels.length})`);
const someCh = chResp.channels[0];
assert(someCh.messageCount > 0, `channel has messages (${someCh.messageCount})`);
// Channel messages
const msgResp = (await get(`/api/channels/${someCh.hash}/messages`)).data;
assert(msgResp.messages.length > 0, 'channel has message list');
assert(msgResp.messages[0].sender !== undefined, 'message has sender');
console.log(` ✓ Channels: ${chResp.channels.length} channels\n`);
// 5f. Observers
console.log('── Observers ──');
@@ -400,11 +397,9 @@ async function main() {
console.log('── Traces ──');
if (traceHash) {
const traceResp = (await get(`/api/traces/${traceHash}`)).data;
assert(Array.isArray(traceResp.traces), 'trace response is array');
if (traceResp.traces.length >= 2) {
const traceObservers = new Set(traceResp.traces.map(t => t.observer));
assert(traceObservers.size >= 2, `trace has >= 2 distinct observers (${traceObservers.size})`);
}
assert(traceResp.traces.length >= 2, `trace hash ${traceHash} has >= 2 entries (${traceResp.traces.length})`);
const traceObservers = new Set(traceResp.traces.map(t => t.observer));
assert(traceObservers.size >= 2, `trace has >= 2 distinct observers (${traceObservers.size})`);
console.log(` ✓ Traces: ${traceResp.traces.length} entries for hash\n`);
} else {
console.log(' ⚠ No multi-observer hash available for trace test\n');
+9 -9
View File
@@ -205,7 +205,7 @@ async function main() {
console.log('\n── JS File References ──');
const jsFiles = ['app.js', 'packets.js', 'map.js', 'channels.js', 'nodes.js', 'traces.js', 'observers.js'];
for (const jsFile of jsFiles) {
assert(html.includes(`src="${jsFile}`) || html.includes(`src="${jsFile}?`), `index.html references ${jsFile}`);
assert(html.includes(`src="${jsFile}"`), `index.html references ${jsFile}`);
}
// ── JS Syntax Validation ───────────────────────────────────────────
@@ -263,14 +263,13 @@ async function main() {
console.log('\n── API: /api/channels (channels page) ──');
const ch = (await get('/api/channels')).data;
assert(Array.isArray(ch.channels), 'channels response has channels array');
if (ch.channels.length > 0) {
assert(ch.channels[0].hash !== undefined, 'channel has hash');
assert(ch.channels[0].messageCount !== undefined, 'channel has messageCount');
const chMsgs = (await get(`/api/channels/${ch.channels[0].hash}/messages`)).data;
assert(Array.isArray(chMsgs.messages || []), 'channel messages is array');
} else {
console.log(' ⚠ No channels (synthetic packets are not decodable channel messages)');
}
assert(ch.channels.length > 0, 'channels non-empty');
assert(ch.channels[0].hash !== undefined, 'channel has hash');
assert(ch.channels[0].messageCount !== undefined, 'channel has messageCount');
// Channel messages
const chMsgs = (await get(`/api/channels/${ch.channels[0].hash}/messages`)).data;
assert(Array.isArray(chMsgs.messages), 'channel messages is array');
console.log('\n── API: /api/nodes (nodes page) ──');
const nodes = (await get('/api/nodes?limit=10')).data;
@@ -298,6 +297,7 @@ async function main() {
const knownHash = crypto.createHash('md5').update(injected[0].hex).digest('hex').slice(0, 16);
const traces = (await get(`/api/traces/${knownHash}`)).data;
assert(Array.isArray(traces.traces), 'traces is array');
assert(traces.traces.length > 0, `trace for known hash has entries`);
// ── Summary ────────────────────────────────────────────────────────
cleanup();