- server.js disambiguateHops() now delegates to server-helpers.js
(was a full copy of the same algorithm, ~70 lines removed)
- live.js resolveHopPositions() now delegates to shared HopResolver
(was a standalone reimplementation, ~50 lines removed)
- HopResolver.init() called when live page loads/updates node data
- Net -106 lines, same behavior, single source of truth
All unit tests pass (241). E2E 13/16 (3 pre-existing Chromium crashes).
zoomend handler was gated on filters.hashLabels — decollision only
re-ran on zoom when hash labels were enabled. Now always re-renders
markers on zoom so pixel offsets stay correct at every zoom level.
Added map.on('resize') handler that re-renders markers, recalculating
pixel-based decollision offsets for the new container size. Previously
only zoomend triggered re-render — resize left stale offsets.
Added E2E test verifying markers survive a viewport resize.
On SPA navigation, the map container may not have its final dimensions
when markers render, causing latLngToLayerPoint to return incorrect
pixel coordinates for decollision. This resulted in overlapping markers
that only resolved on a full page refresh.
Fix: call map.invalidateSize() at the start of every renderMarkers()
call, ensuring correct container dimensions before deconfliction runs.
Observer detail, home health timeline, and traces all called
.toFixed() on snr/rssi values that may be strings from the DB.
Wrapping in Number() matches what live.js already does.
Root cause: addFeedItem had no dedup logic — each WS message created
a new feed entry regardless of hash. Dedup only worked when the
'Realistic propagation' toggle was ON (which buffers by hash before
calling animateRealisticPropagation). Default mode called animatePacket
directly for every observation, producing duplicate feed entries.
Fix: Added feedHashMap (hash -> {element, count, pkt, addedAt}) that
tracks recent feed items by packet hash. When a packet with a known
hash arrives within 30s, the existing feed item is updated in-place:
- Observation count badge incremented
- Item flashed and moved to top of feed
- No duplicate DOM element created
Also adds data-hash attribute to feed items for testability.
Tests: 5 new Playwright tests in test-live-dedup.js covering:
- Same hash different observers → single entry
- Different hashes → separate entries
- 5 rapid sequential duplicates → single entry with count 5
- Same hash same observer → still deduplicates
- Packets without hash → not deduplicated
- Live page showHeatMap() now reads meshcore-live-heatmap-opacity from
localStorage and applies it to the canvas element (was hardcoded 0.3)
- Customizer now has two clearly labeled sliders:
🗺️ Nodes Map — controls the static map page heatmap
📡 Live Map — controls the live page heatmap
- Each uses its own localStorage key (meshcore-heatmap-opacity vs
meshcore-live-heatmap-opacity)
- Added E2E tests for live opacity persistence and dual slider existence
- 13/15 E2E tests pass locally (2 fail due to ARM chromium OOM after
heavy live page tests — CI on x64 will handle them)
Closes#119 properly this time.
Recover from stale localStorage state where heat checkbox stayed
disabled even after matrix/ghosts mode was turned off. Explicitly
sets ht.disabled = false in the else branch of matrix init.
13/13 E2E tests pass locally.
Live page liveHeatToggle now saves to localStorage (meshcore-live-heatmap).
Map page was already fixed but live page was missed.
Added E2E test that verifies persistence across reload.
13/13 E2E tests pass locally.
When new data arrived, toggleHeatmap() destroyed and recreated the
heat layer, causing a brief flash at full opacity before the CSS
opacity was applied via setTimeout. Now reuses the existing layer
via setLatLngs() for data updates, and hooks the 'add' event for
immediate opacity on first creation. No more flash.
All 12 E2E tests pass locally.
The previous implementation used L.heatLayer's minOpacity which only
controlled the opacity of the coolest (blue) gradient stops. Now sets
CSS opacity on the canvas element directly, affecting all gradient
colors uniformly. Closes#119 properly.
renderRows() in nodes.js and three places in map.js were using only
n.last_seen to compute active/stale status, ignoring the more recent
n.last_heard from in-memory packets. This caused nodes that were recently
heard but had an old DB last_seen to incorrectly show as stale.
Also adds 29 unit tests for the aging system (getNodeStatus,
getStatusInfo, getStatusTooltip, threshold values).
Keep the 📍map link in the Location metadata row (goes to app map).
Remove the redundant 📍 Map pill in the hex breakdown (went to Google Maps).
One link, one style.
Was making N API calls per observer for ambiguous hops on every page load,
plus another per packet detail view. All hop resolution now uses the
client-side HopResolver which already handles ambiguous prefixes.
Eliminates the main perf regression.
ReferenceError: node is not defined — was using wrong variable name.
Verified with 37 tests covering: firmware type names, aliases, route,
numeric ops, string ops, payload dot notation, hops, size, observations,
AND/OR/NOT, parentheses, and error handling.
Was using display names like 'Channel Msg' which aren't standard.
Now resolves to firmware names: GRP_TXT, TXT_MSG, REQ, ADVERT, etc.
Also accepts aliases: 'channel', 'dm', 'Channel Msg' all map to the
correct firmware name for convenience.
- Add getStatusTooltip() helper with role-aware explanations
- Tooltips on status labels in: node badges, status explanation, detail table
- Tooltips on map legend active/stale counts per role
- Native title attributes (long-press on mobile)
- Bump cache busters
Server now computes last_heard from in-memory packet store (all traffic
types) and includes it in /api/nodes response. Client prefers last_heard
over DB last_seen for display, sort, filter, and status calculation.
Fixes inconsistency where list showed '5d ago' but side pane showed
'26m ago' for the same node.
- Create getStatusInfo(), renderNodeBadges(), renderStatusExplanation(),
renderHashInconsistencyWarning() shared helpers
- Side pane (renderDetail) now uses shared helpers and shows status explanation
(was previously missing)
- Full page (loadFullNode) uses same shared helpers
- Both views now render identical status info
- Bump cache buster for nodes.js
Was fetching only 200 nodes with server-side filtering — missed nodes.
Now fetches full list once, caches it, filters by role/search/lastHeard
in the browser. Region change invalidates cache.
Two-state node freshness: Active vs Stale
- roles.js: add getNodeStatus(role, lastSeenMs) helper returning 'active'/'stale'
- Repeaters/Rooms: stale after 72h
- Companions/Sensors: stale after 24h
- Backward compat: getHealthThresholds() with degradedMs/silentMs still works
- map.js: stale markers get .marker-stale CSS class (opacity 0.35, grayscale 70%)
- Applied to both SVG shape markers and hash label markers
- makeMarkerIcon() and makeRepeaterLabelIcon() accept isStale parameter
- nodes.js: visual aging in table, side pane, and full detail
- Table: Last Seen column colored green (active) or muted (stale)
- Side pane: status shows 🟢 Active or ⚪ Stale (was 🟢/🟡/🔴)
- Full detail: Status row with role-appropriate explanation
- Stale repeaters: 'not heard for Xd — repeaters typically advertise every 12-24h'
- Stale companions: 'companions only advertise when user initiates'
- Fixed lastHeard fallback to n.last_seen when health API has no stats
- style.css: .marker-stale, .last-seen-active, .last-seen-stale classes
- All 5 columns (Name, Public Key, Role, Last Seen, Adverts) are now
sortable by clicking the column header
- Click toggles between ascending/descending sort
- Visual indicator (▲/▼) shows current sort column and direction
- Sort preference persisted to localStorage (meshcore-nodes-sort)
- Removed old Sort dropdown since headers replace it
- Client-side sorting on already-fetched data
- Default: Last Seen descending (most recent first)
- App Flags now shows human-readable type (Companion/Repeater/Room Server/Sensor)
instead of confusing individual flag names like 'chat, repeater'
- Boolean flags (location, name) shown separately after type: 'Room Server + location, name'
- Added Google Maps link on longitude row using existing detail-map-link style
TOC: #/analytics?tab=collisions§ion=inconsistentHashSection etc.
Back-to-top: #/analytics?tab=collisions (scrolls to top of tab)
All copyable, shareable, bookmarkable.
Sections: inconsistentHashSection, hashMatrixSection, collisionRiskSection
Use ?tab=collisions§ion=inconsistentHashSection to jump directly.
Scrolls after tab render completes (400ms delay for async content).
Added ids: node-stats, node-observers, fullPathsSection, node-packets.
Use ?section=<id> to scroll to any section on load.
e.g. #/nodes/<pubkey>?section=node-packets
Variable hash size badge and analytics links updated to use ?section=.
- Removed yellow text and redundant Status column
- Sizes Seen now uses colored badges (orange 1B, pale green 2B, bright green 3B)
- Row striping, card border/radius, accent-colored node links
- Current hash in mono with muted byte count
- Renamed 'Hash Collisions' tab to 'Hash Issues'
- New section at top: 'Inconsistent Hash Sizes' table listing all nodes
that have sent adverts with varying hash sizes
- Each node links to its detail page with ?highlight=hashsize for
per-advert hash size breakdown
- Shows current hash prefix, all sizes seen, and affected count
- Green checkmark when no inconsistencies detected
- Existing collision grid and risk table unchanged below
- Badge is now a link to the detail page with ?highlight=hashsize
- Detail page auto-scrolls to Recent Packets section
- Each advert shows its hash size badge (yellow if different from current)
- Detail page shows always-visible explanation banner (not hidden)
- Side pane badge links to detail page too
Badge shows cursor:help and clicking toggles a yellow-bordered info box
explaining the issue and suggesting firmware update. Stats row just shows
'⚠️ varies' with tooltip. Much less jarring than a dead yellow badge.
White semi-transparent square behind QR so black modules pop.
White rects in SVG already set to transparent by JS.
Same white backing in dark mode too (QR needs light bg to scan).
Tracks all hash_size values seen per node. If a node has sent adverts
with different hash sizes, flags it as hash_size_inconsistent with a
yellow ⚠️ badge on both side pane and detail page. Tooltip mentions
likely firmware bug (pre-1.14.1). Stats row shows all sizes seen.