Compare commits

..

187 Commits

Author SHA1 Message Date
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
23 changed files with 221 additions and 1705 deletions
-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
```
-14
View File
@@ -53,20 +53,6 @@ Full experience on your phone — proper touch controls, iOS safe area support,
- **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.0)
Two-layer caching architecture: in-memory packet store + TTL response cache. All packet reads served from RAM — SQLite is write-only. Heavy endpoints pre-warmed on startup.
| Endpoint | Before | After | Speedup |
|---|---|---|---|
| Bulk Health | 7,059 ms | 1 ms | **7,059×** |
| Node Analytics | 381 ms | 1 ms | **381×** |
| Topology | 685 ms | 2 ms | **342×** |
| Node Health | 195 ms | 1 ms | **195×** |
| Node Detail | 133 ms | 1 ms | **133×** |
See [PERFORMANCE.md](PERFORMANCE.md) for the full benchmark.
## Quick Start
### Prerequisites
-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); });
-26
View File
@@ -22,31 +22,5 @@
"OAK": "Oakland, US",
"MRY": "Monterey, US",
"LAR": "Los Angeles, US"
},
"cacheTTL": {
"stats": 10,
"nodeDetail": 300,
"nodeHealth": 300,
"nodeList": 90,
"bulkHealth": 600,
"networkStatus": 600,
"observers": 300,
"channels": 15,
"channelMessages": 10,
"analyticsRF": 1800,
"analyticsTopology": 1800,
"analyticsChannels": 1800,
"analyticsHashSizes": 3600,
"analyticsSubpaths": 3600,
"analyticsSubpathDetail": 3600,
"nodeAnalytics": 60,
"nodeSearch": 10,
"invalidationDebounce": 30,
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
},
"packetStore": {
"maxMemoryMB": 1024,
"estimatedPacketBytes": 450,
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. All packets loaded on startup, served from RAM."
}
}
+2 -2
View File
@@ -225,13 +225,13 @@ function seed() {
const now = new Date().toISOString();
const rawHex = '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172';
upsertObserver({ id: 'obs-sjc-001', name: 'Iavor Observer', iata: 'SJC', last_seen: now, first_seen: now });
upsertObserver({ id: 'obs-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: 'Iavor Observer',
observer_name: 'User Observer',
direction: 'rx',
snr: 10.5,
rssi: -85,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "meshcore-analyzer",
"version": "2.1.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": {
-342
View File
@@ -1,342 +0,0 @@
'use strict';
/**
* In-memory packet store — loads all packets from SQLite on startup,
* serves reads from RAM, writes to both RAM + SQLite.
* Caps memory at configurable limit (default 1GB).
*/
class PacketStore {
constructor(dbModule, config = {}) {
this.dbModule = dbModule; // The full db module (has .db, .insertPacket, .getPacket)
this.db = dbModule.db; // Raw better-sqlite3 instance for queries
this.maxBytes = (config.maxMemoryMB || 1024) * 1024 * 1024;
this.estPacketBytes = config.estimatedPacketBytes || 450;
this.maxPackets = Math.floor(this.maxBytes / this.estPacketBytes);
// SQLite-only mode: skip RAM loading, all reads go to DB
this.sqliteOnly = process.env.NO_MEMORY_STORE === '1';
// Core storage: array sorted by timestamp DESC (newest first)
this.packets = [];
// Indexes
this.byId = new Map();
this.byHash = new Map(); // hash → [packet, ...]
this.byObserver = new Map(); // observer_id → [packet, ...]
this.byNode = new Map(); // pubkey → [packet, ...]
this.loaded = false;
this.stats = { totalLoaded: 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();
const rows = this.db.prepare(
'SELECT * FROM packets ORDER BY timestamp DESC'
).all();
for (const row of rows) {
if (this.packets.length >= this.maxPackets) break;
this._index(row);
this.packets.push(row);
}
this.stats.totalLoaded = this.packets.length;
this.loaded = true;
const elapsed = Date.now() - t0;
console.log(`[PacketStore] Loaded ${this.packets.length} packets in ${elapsed}ms (${Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024)}MB est)`);
return this;
}
/** Index a packet into all lookup maps */
_index(pkt) {
this.byId.set(pkt.id, pkt);
if (pkt.hash) {
if (!this.byHash.has(pkt.hash)) this.byHash.set(pkt.hash, []);
this.byHash.get(pkt.hash).push(pkt);
}
if (pkt.observer_id) {
if (!this.byObserver.has(pkt.observer_id)) this.byObserver.set(pkt.observer_id, []);
this.byObserver.get(pkt.observer_id).push(pkt);
}
// Index by node pubkeys mentioned in decoded_json
this._indexByNode(pkt);
}
/** Extract node pubkeys/names from decoded_json and index */
_indexByNode(pkt) {
if (!pkt.decoded_json) return;
try {
const decoded = JSON.parse(pkt.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.byNode.has(k)) this.byNode.set(k, []);
this.byNode.get(k).push(pkt);
}
} catch {}
}
/** Remove oldest packets when over memory limit */
_evict() {
while (this.packets.length > this.maxPackets) {
const old = this.packets.pop();
this.byId.delete(old.id);
// Remove from hash index
if (old.hash && this.byHash.has(old.hash)) {
const arr = this.byHash.get(old.hash).filter(p => p.id !== old.id);
if (arr.length) this.byHash.set(old.hash, arr); else this.byHash.delete(old.hash);
}
// Remove from observer index
if (old.observer_id && this.byObserver.has(old.observer_id)) {
const arr = this.byObserver.get(old.observer_id).filter(p => p.id !== old.id);
if (arr.length) this.byObserver.set(old.observer_id, arr); else this.byObserver.delete(old.observer_id);
}
// Skip node index cleanup for eviction (expensive, low value)
this.stats.evicted++;
}
}
/** Insert a new packet (to both memory and SQLite) */
insert(packetData) {
const id = this.dbModule.insertPacket(packetData);
const row = this.dbModule.getPacket(id);
if (row) {
this.packets.unshift(row); // newest first
this._index(row);
this._evict();
this.stats.inserts++;
}
return id;
}
/** 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) {
results = this.byHash.get(hash) || [];
} else if (observer && !type && !route && !region && !hash && !since && !until && !node) {
results = this.byObserver.get(observer) || [];
} else if (node && !type && !route && !region && !observer && !hash && !since && !until) {
results = this.byNode.get(node) || [];
} 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 = results.filter(p => p.observer_id === observer);
if (hash) results = results.filter(p => p.hash === hash);
if (since) results = results.filter(p => p.timestamp > since);
if (until) results = results.filter(p => p.timestamp < until);
if (region) {
// Need to look up observers for this 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 => regionObservers.has(p.observer_id));
}
if (node) {
// Check indexed results first, fall back to text search
const indexed = this.byNode.get(node);
if (indexed) {
const idSet = new Set(indexed.map(p => p.id));
results = results.filter(p => idSet.has(p.id));
} else {
// Text search fallback (node name)
results = results.filter(p =>
p.decoded_json && p.decoded_json.includes(node)
);
}
}
}
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 };
}
/** Query with groupByHash — aggregate packets by content hash */
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 results first
const { packets: filtered, total: filteredTotal } = this.query({
limit: 999999, offset: 0, type, route, region, observer, hash, since, until, node
});
// Group by hash
const groups = new Map();
for (const p of filtered) {
const h = p.hash || p.id.toString();
if (!groups.has(h)) {
groups.set(h, {
hash: p.hash,
observer_count: new Set(),
count: 0,
latest: p.timestamp,
observer_id: p.observer_id,
observer_name: p.observer_name,
path_json: p.path_json,
payload_type: p.payload_type,
raw_hex: p.raw_hex,
decoded_json: p.decoded_json,
});
}
const g = groups.get(h);
g.count++;
if (p.observer_id) g.observer_count.add(p.observer_id);
if (p.timestamp > g.latest) {
g.latest = p.timestamp;
}
// Keep longest path
if (p.path_json && (!g.path_json || p.path_json.length > g.path_json.length)) {
g.path_json = p.path_json;
g.raw_hex = p.raw_hex;
}
}
// Sort by latest DESC, paginate
const sorted = [...groups.values()]
.map(g => ({ ...g, observer_count: g.observer_count.size }))
.sort((a, b) => b.latest.localeCompare(a.latest));
const total = sorted.length;
const paginated = sorted.slice(Number(offset), Number(offset) + Number(limit));
return { packets: paginated, total };
}
/** Get timestamps for sparkline */
getTimestamps(since) {
if (this.sqliteOnly) {
return this.db.prepare('SELECT timestamp FROM packets WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
}
const results = [];
for (const p of this.packets) {
if (p.timestamp <= since) break;
results.push(p.timestamp);
}
return results.reverse();
}
/** Get a single packet by ID */
getById(id) {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE id = ?').get(id) || null;
return this.byId.get(id) || null;
}
/** Get all siblings of a packet (same hash) */
getSiblings(hash) {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE hash = ? ORDER BY timestamp DESC').all(hash);
return this.byHash.get(hash) || [];
}
/** Get all packets (raw array reference — do not mutate) */
all() {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all();
return this.packets;
}
/** Get all packets matching a filter function */
filter(fn) {
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all().filter(fn);
return this.packets.filter(fn);
}
/** Memory stats */
getStats() {
return {
...this.stats,
inMemory: this.sqliteOnly ? 0 : this.packets.length,
sqliteOnly: this.sqliteOnly,
maxPackets: this.maxPackets,
estimatedMB: this.sqliteOnly ? 0 : Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024),
maxMB: Math.round(this.maxBytes / 1024 / 1024),
indexes: {
byHash: this.byHash.size,
byObserver: this.byObserver.size,
byNode: this.byNode.size,
}
};
}
/** SQLite fallback: query with filters */
_querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order }) {
const where = []; const params = [];
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
if (observer) { where.push('observer_id = ?'); params.push(observer); }
if (hash) { where.push('hash = ?'); params.push(hash); }
if (since) { where.push('timestamp > ?'); params.push(since); }
if (until) { where.push('timestamp < ?'); params.push(until); }
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
if (node) { where.push('decoded_json LIKE ?'); params.push(`%${node}%`); }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets ${w}`).get(...params).c;
const packets = this.db.prepare(`SELECT * FROM packets ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
return { packets, total };
}
/** SQLite fallback: grouped query */
_queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node }) {
const where = []; const params = [];
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
if (observer) { where.push('observer_id = ?'); params.push(observer); }
if (hash) { where.push('hash = ?'); params.push(hash); }
if (since) { where.push('timestamp > ?'); params.push(since); }
if (until) { where.push('timestamp < ?'); params.push(until); }
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
if (node) { where.push('decoded_json LIKE ?'); params.push(`%${node}%`); }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const sql = `SELECT hash, COUNT(*) as count, COUNT(DISTINCT observer_id) as observer_count,
MAX(timestamp) as latest, MIN(observer_id) as observer_id, MIN(observer_name) as observer_name,
MIN(path_json) as path_json, MIN(payload_type) as payload_type, MIN(raw_hex) as raw_hex,
MIN(decoded_json) as decoded_json
FROM packets ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
const packets = this.db.prepare(sql).all(...params, limit, offset);
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets ${w}`;
const total = this.db.prepare(countSql).get(...params).c;
return { packets, total };
}
}
module.exports = PacketStore;
+14 -22
View File
@@ -40,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);
@@ -109,10 +101,10 @@
try {
_analyticsData = {};
const [hashData, rfData, topoData, chanData] = await Promise.all([
api('/analytics/hash-sizes', { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/rf', { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/topology', { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/channels', { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/hash-sizes'),
api('/analytics/rf'),
api('/analytics/topology'),
api('/analytics/channels'),
]);
_analyticsData = { hashData, rfData, topoData, chanData };
renderTab('overview');
@@ -755,7 +747,7 @@
</div>
`;
let allNodes = [];
try { const nd = await api('/nodes?limit=2000', { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
try { const nd = await api('/nodes?limit=2000'); allNodes = nd.nodes || []; } catch {}
renderHashMatrix(data.topHops, allNodes);
renderCollisions(data.topHops, allNodes);
}
@@ -946,10 +938,10 @@
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
try {
const [d2, d3, d4, d5] = await Promise.all([
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: CLIENT_TTL.analyticsRF })
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50'),
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30'),
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20'),
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15')
]);
function renderTable(data, title) {
@@ -1040,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));
renderSubpathDetail(panel, data);
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
@@ -1149,9 +1141,9 @@
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
try {
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
api('/nodes?limit=200&sortBy=lastSeen', { ttl: CLIENT_TTL.nodeList }),
api('/nodes/bulk-health?limit=50', { ttl: CLIENT_TTL.analyticsRF }),
api('/nodes/network-status', { ttl: CLIENT_TTL.analyticsRF })
api('/nodes?limit=200&sortBy=lastSeen'),
api('/nodes/bulk-health?limit=50'),
api('/nodes/network-status')
]);
const nodes = nodesResp.nodes || nodesResp;
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
+15 -68
View File
@@ -11,60 +11,19 @@ function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; }
function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
// --- Utilities ---
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 _apiPerf = { calls: 0, totalMs: 0, log: [] };
async function api(path) {
const t0 = performance.now();
if (!bust && ttl > 0) {
const cached = _apiCache.get(path);
if (cached && Date.now() < cached.expires) {
_apiPerf.calls++;
_apiPerf.cacheHits++;
_apiPerf.log.push({ path, ms: 0, time: Date.now(), cached: true });
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
return cached.data;
}
}
// Deduplicate in-flight requests
if (_inflight.has(path)) return _inflight.get(path);
const promise = (async () => {
const res = await fetch('/api' + path);
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
const data = await res.json();
const ms = performance.now() - t0;
_apiPerf.calls++;
_apiPerf.totalMs += ms;
_apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() });
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`);
if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl });
return data;
})();
_inflight.set(path, promise);
promise.finally(() => _inflight.delete(path));
return promise;
}
function invalidateApiCache(prefix) {
for (const key of _apiCache.keys()) {
if (key.startsWith(prefix || '')) _apiCache.delete(key);
}
const res = await fetch('/api' + path);
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
const data = await res.json();
const ms = performance.now() - t0;
_apiPerf.calls++;
_apiPerf.totalMs += ms;
_apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() });
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`);
return data;
}
// Expose for console debugging: apiPerf()
window.apiPerf = function() {
@@ -80,10 +39,7 @@ window.apiPerf = function() {
totalMs: Math.round(s.totalMs)
})).sort((a, b) => b.totalMs - a.totalMs);
console.table(rows);
const hitRate = _apiPerf.calls ? Math.round(_apiPerf.cacheHits / _apiPerf.calls * 100) : 0;
const misses = _apiPerf.calls - _apiPerf.cacheHits;
console.log(`Cache: ${_apiPerf.cacheHits} hits / ${misses} misses (${hitRate}% hit rate)`);
return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (misses || 1)), cacheHits: _apiPerf.cacheHits, cacheMisses: misses, cacheHitRate: hitRate, endpoints: rows };
return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / _apiPerf.calls), endpoints: rows };
};
function timeAgo(iso) {
@@ -209,15 +165,6 @@ function connectWS() {
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
// Debounce cache invalidation — don't nuke on every packet
if (!api._invalidateTimer) {
api._invalidateTimer = setTimeout(() => {
api._invalidateTimer = null;
invalidateApiCache('/stats');
invalidateApiCache('/nodes');
invalidateApiCache('/channels');
}, 5000);
}
wsListeners.forEach(fn => fn(msg));
} catch {}
};
@@ -371,7 +318,7 @@ 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');
const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null;
const status = age === null ? '🔴' : age < 3600000 ? '🟢' : age < 86400000 ? '🟡' : '🔴';
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
@@ -475,7 +422,7 @@ window.addEventListener('DOMContentLoaded', () => {
// --- Nav Stats ---
async function updateNavStats() {
try {
const stats = await api('/stats', { ttl: CLIENT_TTL.stats });
const stats = await api('/stats');
const el = document.getElementById('navStats');
if (el) {
el.innerHTML = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs`;
+5 -5
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));
// Try exact match first, then case-insensitive, then contains
const nodes = data.nodes || [];
const match = nodes.find(n => n.name === name)
@@ -110,7 +110,7 @@
}
try {
const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: CLIENT_TTL.nodeDetail });
const detail = await api('/nodes/' + encodeURIComponent(node.public_key));
const n = detail.node;
const adverts = detail.recentAdverts || [];
const role = n.is_repeater ? '📡 Repeater' : n.is_room ? '🏠 Room' : n.is_sensor ? '🌡 Sensor' : '📻 Companion';
@@ -389,7 +389,7 @@
async function loadChannels(silent) {
try {
const data = await api('/channels', { ttl: CLIENT_TTL.channels });
const data = await api('/channels');
channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || ''));
renderChannelList();
} catch (e) {
@@ -451,7 +451,7 @@
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
try {
const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
const data = await api(`/channels/${hash}/messages?limit=200`);
messages = data.messages || [];
renderMessages();
scrollToBottom();
@@ -466,7 +466,7 @@
if (!msgEl) return;
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
try {
const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
const data = await api(`/channels/${selectedHash}/messages?limit=200`);
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 || '') : ''; };
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

+4 -4
View File
@@ -146,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));
const nodes = data.nodes || [];
if (!nodes.length) {
suggest.innerHTML = '<div class="suggest-empty">No nodes found</div>';
@@ -247,7 +247,7 @@
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');
const node = h.node || {};
const stats = h.stats || {};
const obs = h.observers || [];
@@ -369,7 +369,7 @@
// ==================== STATS ====================
async function loadStats() {
try {
const s = await api('/stats', { ttl: CLIENT_TTL.nodeSearch });
const s = await api('/stats');
const el = document.getElementById('homeStats');
if (!el) return;
el.innerHTML = `
@@ -391,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');
const node = h.node || {};
const stats = h.stats || {};
const packets = h.recentPackets || [];
+12 -15
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,7 +20,7 @@
<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=1773976827">
<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"
@@ -53,7 +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>
</div>
</div>
<div class="nav-right">
@@ -79,17 +76,17 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="app.js?v=1773977027"></script>
<script src="home.js?v=1773977027"></script>
<script src="packets.js?v=1773977027"></script>
<script src="map.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1773977027" 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=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="app.js?v=1773970465"></script>
<script src="home.js?v=1774079160"></script>
<script src="packets.js?v=1773969349"></script>
<script src="map.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1773961950" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1773961035" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1773964458" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1773961276" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+3 -3
View File
@@ -980,7 +980,7 @@
addNodeMarker(n);
}
});
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
} catch (e) { console.error('Failed to load nodes:', e); }
}
@@ -1066,7 +1066,7 @@
function animatePacket(pkt) {
packetCount++;
pktTimestamps.push(Date.now());
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
document.getElementById('livePktCount').textContent = packetCount;
const decoded = pkt.decoded || {};
const header = decoded.header || {};
@@ -1086,7 +1086,7 @@
const n = { public_key: key, name: payload.name || key.slice(0,8), role: payload.role || 'unknown', lat: payload.lat, lon: payload.lon };
nodeData[key] = n;
addNodeMarker(n);
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
}
}
+2 -2
View File
@@ -245,12 +245,12 @@
async function loadNodes() {
try {
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList });
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`);
nodes = data.nodes || [];
buildRoleChecks(data.counts || {});
// Load observers for jump buttons
const obsData = await api('/observers', { ttl: CLIENT_TTL.observers });
const obsData = await api('/observers');
observers = obsData.observers || [];
buildJumpButtons();
+1 -1
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);
} catch (e) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load analytics: ' + escapeHtml(e.message) + '</div>';
return;
+6 -6
View File
@@ -85,8 +85,8 @@
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)),
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
]);
const n = nodeData.node;
const adverts = nodeData.recentAdverts || [];
@@ -228,7 +228,7 @@
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: CLIENT_TTL.nodeList });
const data = await api('/nodes?' + params);
nodes = data.nodes || [];
counts = data.counts || {};
@@ -238,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)))
);
fetched.forEach(r => {
if (r.status === 'fulfilled' && r.value && r.value.public_key) nodes.push(r.value);
@@ -401,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)),
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
]);
data.healthData = healthData;
renderDetail(panel, data);
+1 -1
View File
@@ -38,7 +38,7 @@
async function loadObservers() {
try {
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
const data = await api('/observers');
observers = data.observers || [];
render();
} catch (e) {
+3 -74
View File
@@ -182,80 +182,9 @@
} catch {}
}
wsHandler = debouncedOnWS(function (msgs) {
const newPkts = msgs
.filter(m => m.type === 'packet' && m.data?.packet)
.map(m => m.data.packet);
if (!newPkts.length) return;
// Check if new packets pass current filters
const filtered = newPkts.filter(p => {
if (filters.type !== undefined && filters.type !== '' && p.payload_type !== Number(filters.type)) return false;
if (filters.observer && p.observer_id !== filters.observer) return false;
if (filters.hash && p.hash !== filters.hash) return false;
if (filters.region) {
const obs = observers.find(o => o.id === p.observer_id);
if (!obs || obs.iata !== filters.region) return false;
}
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
return true;
});
if (!filtered.length) return;
// Resolve any new hops, then update and re-render
const newHops = new Set();
for (const p of filtered) {
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
if (msgs.some(function (m) { return m.type === 'packet'; })) {
loadPackets();
}
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
if (groupByHash) {
// Update existing groups or create new ones
for (const p of filtered) {
const h = p.hash;
const existing = packets.find(g => g.hash === h);
if (existing) {
existing.count = (existing.count || 1) + 1;
existing.latest = p.timestamp > existing.latest ? p.timestamp : existing.latest;
// Track unique observers
if (p.observer_id && p.observer_id !== existing.observer_id) {
existing.observer_count = (existing.observer_count || 1) + 1;
}
// Keep longest path
if (p.path_json && (!existing.path_json || p.path_json.length > existing.path_json.length)) {
existing.path_json = p.path_json;
existing.raw_hex = p.raw_hex;
}
// Update decoded_json to latest
if (p.decoded_json) existing.decoded_json = p.decoded_json;
// Update expanded children if this group is expanded
if (expandedHashes.has(h) && existing._children) {
existing._children.unshift(p);
}
} else {
// New group
packets.unshift({
hash: h,
count: 1,
observer_count: 1,
latest: p.timestamp,
observer_id: p.observer_id,
observer_name: p.observer_name,
path_json: p.path_json,
payload_type: p.payload_type,
raw_hex: p.raw_hex,
decoded_json: p.decoded_json,
});
}
}
// Re-sort by latest DESC, cap size
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
packets = packets.slice(0, 200);
} else {
// Flat mode: prepend
packets = filtered.concat(packets).slice(0, 200);
}
totalCount += filtered.length;
renderTableRows();
});
});
}
@@ -278,7 +207,7 @@
async function loadObservers() {
try {
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
const data = await api('/observers');
observers = data.observers || [];
} catch {}
}
+1 -57
View File
@@ -18,72 +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 ? '#ef4444' : el.p95Ms > 100 ? '#f59e0b' : '#22c55e';
const memColor = m.heapUsed > m.heapTotal * 0.85 ? '#ef4444' : m.heapUsed > m.heapTotal * 0.7 ? '#f59e0b' : '#22c55e';
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
<div class="perf-card"><div class="perf-num" style="color:${elColor}">${el.p95Ms}ms</div><div class="perf-label">Event Loop p95</div></div>
<div class="perf-card"><div class="perf-num">${el.maxLagMs}ms</div><div class="perf-label">EL Max Lag</div></div>
<div class="perf-card"><div class="perf-num">${el.currentLagMs}ms</div><div class="perf-label">EL Current</div></div>
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
</div>`;
}
// Cache stats
if (server.cache) {
const c = server.cache;
const clientCache = _apiCache ? _apiCache.size : 0;
html += `<h3>Cache</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${c.size}</div><div class="perf-label">Server Entries</div></div>
<div class="perf-card"><div class="perf-num">${c.hits}</div><div class="perf-label">Server Hits</div></div>
<div class="perf-card"><div class="perf-num">${c.misses}</div><div class="perf-label">Server Misses</div></div>
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? '#22c55e' : c.hitRate > 20 ? '#f59e0b' : '#ef4444'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
<div class="perf-card"><div class="perf-num">${c.staleHits || 0}</div><div class="perf-label">Stale Hits (SWR)</div></div>
<div class="perf-card"><div class="perf-num">${c.recomputes || 0}</div><div class="perf-label">Recomputes</div></div>
<div class="perf-card"><div class="perf-num">${clientCache}</div><div class="perf-label">Client Entries</div></div>
</div>`;
if (client) {
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${client.cacheHits || 0}</div><div class="perf-label">Client Hits</div></div>
<div class="perf-card"><div class="perf-num">${client.cacheMisses || 0}</div><div class="perf-label">Client Misses</div></div>
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? '#22c55e' : '#f59e0b'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
</div>`;
}
}
// Packet Store stats
if (server.packetStore) {
const ps = server.packetStore;
html += `<h3>In-Memory Packet Store</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${ps.inMemory.toLocaleString()}</div><div class="perf-label">Packets in RAM</div></div>
<div class="perf-card"><div class="perf-num">${ps.estimatedMB}MB</div><div class="perf-label">Memory Used</div></div>
<div class="perf-card"><div class="perf-num">${ps.maxMB}MB</div><div class="perf-label">Memory Limit</div></div>
<div class="perf-card"><div class="perf-num">${ps.queries.toLocaleString()}</div><div class="perf-label">Queries Served</div></div>
<div class="perf-card"><div class="perf-num">${ps.inserts.toLocaleString()}</div><div class="perf-label">Live Inserts</div></div>
<div class="perf-card"><div class="perf-num">${ps.evicted.toLocaleString()}</div><div class="perf-label">Evicted</div></div>
<div class="perf-card"><div class="perf-num">${ps.indexes.byHash.toLocaleString()}</div><div class="perf-label">Unique Hashes</div></div>
<div class="perf-card"><div class="perf-num">${ps.indexes.byObserver}</div><div class="perf-label">Observers</div></div>
<div class="perf-card"><div class="perf-num">${ps.indexes.byNode.toLocaleString()}</div><div class="perf-label">Indexed Nodes</div></div>
</div>`;
}
// Server endpoints table
const eps = Object.entries(server.endpoints);
if (eps.length) {
+151 -605
View File
File diff suppressed because it is too large Load Diff