diff --git a/CHANGELOG.md b/CHANGELOG.md index 2637bad..a2c9131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ All notable changes to MeshCore GUI are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). +--- +<<<<<<< HEAD +## [1.13.3] - 2026-03-12 — Active Panel Timer Gating + +### Changed +- 🔄 `meshcore_gui/gui/dashboard.py` — The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only +- 🔄 `meshcore_gui/gui/dashboard.py` — Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick +- 🔄 `meshcore_gui/gui/panels/map_panel.py` — Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists +- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists +- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.3` + +### Fixed +- 🛠 **Hidden panels still refreshed every 500 ms** — Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active +- 🛠 **Map bootstrap activity while panel is not visible** — Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic +- 🛠 **Slow navigation over VPN** — Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel + +### Impact +- Faster panel switching because the selected panel gets one direct refresh immediately +- Lower background UI/update load on dashboard level, especially when the map panel is not active +- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage +- No intended functional regression for route maps or visible panel behaviour + --- ## [1.13.2] - 2026-03-11 — Map Display Bugfix @@ -45,47 +67,36 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver - No breaking changes outside the three files listed above --- -## [1.13.1] - 2026-03-09 — Message Icon Consistency - -### Fixed -- Route map markers now use the same JS-rendered node icons as the main MAP instead of NiceGUI default blue markers. -- Route detail pages now bootstrap their Leaflet assets explicitly so the shared map icon runtime is available there too -- Route maps are now rendered browser-side through the shared Leaflet JS runtime for icon consistency with MAP, Messages, and Archive. - -### Changed -- 🔄 `meshcore_gui/gui/constants.py` — Added shared helper functions to resolve node-type icons and labels from the same contact type mapping used by the map and contacts panel -- 🔄 `meshcore_gui/core/models.py` — `Message.format_line()` now supports an optional sender prefix so message-related views can prepend the same node icon set without changing existing formatting logic -- 🔄 `meshcore_gui/gui/panels/messages_panel.py` — Message rows now prepend the sender with the same node icon mapping as the map/contact views -- 🔄 `meshcore_gui/gui/archive_page.py` — Archive rows now use the same sender icon mapping as the live messages panel and map/contact views -- 🔄 `meshcore_gui/gui/route_page.py` — Route header and route detail table now show node-type icons derived from the shared contact type mapping instead of generic hardcoded role icons - -### Impact -- Message-driven views now use one consistent icon language across map, contacts, messages, archive and route detail -- Existing map runtime and panel behavior remain unchanged -- No breaking changes outside icon rendering - +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b ## [1.13.0] - 2026-03-09 — Leaflet Map Runtime Stabilization ### Added -- ✅ `meshcore_gui/static/leaflet_map_panel.js` — Dedicated browser-side Leaflet runtime responsible for map lifecycle, marker registry and theme handling independent from NiceGUI redraw cycles -- ✅ `meshcore_gui/static/leaflet_map_panel.css` — Styling for browser-side node markers and map container +- ✅ `meshcore_gui/static/leaflet_map_panel.js` — Dedicated browser-side Leaflet runtime responsible for map lifecycle, marker registry, clustering and theme handling independent from NiceGUI redraw cycles +- ✅ `meshcore_gui/static/leaflet_map_panel.css` — Styling for browser-side node markers, cluster icons and map container - ✅ `meshcore_gui/services/map_snapshot_service.py` — Snapshot service that normalizes device/contact map data into a compact payload for the browser runtime - ✅ Browser-side map state management for center, zoom and theme - ✅ Theme persistence across reconnect events via browser storage fallback +- ✅ Browser-side contact clustering via `Leaflet.markercluster` +- ✅ Separate non-clustered device marker layer so the own device remains individually visible ### Changed - 🔄 `meshcore_gui/gui/panels/map_panel.py` — Replaced NiceGUI Leaflet wrapper usage with a pure browser-managed Leaflet container while preserving the existing card layout, theme toggle and center-on-device control - 🔄 Leaflet bootstrap moved out of inline Python into a dedicated browser runtime loaded from `/static` +- 🔄 Asset loading order is now explicit: Leaflet first, then `Leaflet.markercluster`, then the MeshCore panel runtime - 🔄 Map initialization now occurs only once per container; NiceGUI refresh cycles no longer recreate the map - 🔄 Dashboard update loop now sends compact map snapshots instead of triggering redraws - 🔄 Snapshot processing in the browser is coalesced so only the newest payload is applied - 🔄 Map markers are managed in separate device/contact layers and updated incrementally by stable node id +- 🔄 Contact markers are rendered inside a persistent cluster layer while the device marker remains outside clustering - 🔄 Theme switching moved to a dedicated theme channel instead of being embedded in snapshot data ### Fixed - 🛠 **Map disappearing during dashboard refresh cycles** — prevented repeated map reinitialization caused by the 500 ms NiceGUI update loop - 🛠 **Markers disappearing between refreshes** — marker updates are now incremental and keyed by node id - 🛠 **Blank map container on load** — browser bootstrap now waits for DOM host, Leaflet runtime and panel runtime before initialization +- 🛠 **Leaflet clustering bootstrap failure (`L is not defined`)** — resolved by enforcing correct script dependency order before the panel runtime starts +- 🛠 **MarkerClusterGroup failure (`Map has no maxZoom specified`)** — the map now defines `maxZoom` during initial creation before the cluster layer is attached +- 🛠 **Half-initialized map retry cascade (`Map container is already initialized`)** — map state is now registered safely during initialization so a failed attempt cannot trigger a second `L.map(...)` on the same container - 🛠 **Race condition between queued snapshot and theme selection** — explicit theme changes can no longer be overwritten by stale snapshot payloads - 🛠 **Viewport jumping back to default center/zoom** — stored viewport is no longer reapplied on each snapshot update - 🛠 **Theme reverting to default during reconnect** — effective map theme is restored before snapshot processing resumes @@ -93,6 +104,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ### Impact - Leaflet map is now managed entirely in the browser and is no longer recreated on each dashboard refresh - Node markers remain stable and no longer flicker or disappear during the 500 ms update cycle +- Dense contact sets can now be rendered with clustering without violating the browser-owned map lifecycle - Theme switching and viewport state persist reliably across reconnect events - No breaking changes outside the map subsystem --- @@ -820,29 +832,3 @@ overwriting all historical data with only the new buffered messages. - explicit theme changes are now handled only via the dedicated theme channel - initial map render now sends an ensure_map command plus an immediate theme sync - added no-op ensure_map handling in the Leaflet runtime to avoid accidental fallback behaviour - -## [1.13.0] - 2026-03-09 - -### Added -- Leaflet marker clustering using Leaflet.markercluster for contact nodes. -- Browser-side cluster rendering with the device marker kept outside the cluster layer. -- Cluster performance tuning with `chunkedLoading: true`. -- Spiderfy support at max zoom for overlapping markers. - -### Fixed -- Wrong asset load order causing `L is not defined` in MarkerClusterGroup. -- Cluster initialization failure caused by missing `maxZoom` on map startup. -- Retry cascade causing `Map container is already initialized`. - -### Changed -- Map lifecycle is browser-owned: NiceGUI hosts the container, Leaflet owns map state. -- Contact markers are updated incrementally in the existing cluster layer. - -## 2026-03-12 map host bootstrap fix -- Fixed dashboard MAP bootstrap for hidden/inactive panels by rendering the Leaflet host as a real NiceGUI `div` element instead of injecting raw HTML inside a hidden container. -- Fixed browser bootstrap retries so a zero-size hidden host is retried instead of being dropped permanently. -- Simplified host waiting logic to polling-based retries, which avoids missing late NiceGUI DOM inserts while the MAP panel is still being mounted. - -## 2026-03-12 route map host mount fix -- Fixed Message and Archive route pages so the Leaflet route map host is rendered as a real NiceGUI DOM element instead of injected raw HTML. -- This aligns the route page bootstrap with the dashboard MAP fix and prevents route maps from staying blank after clicking a message. diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index 7c1b8fa..941b0ec 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.13.2" +VERSION: str = "1.13.3" # ============================================================================== @@ -293,7 +293,7 @@ CHANNEL_CACHE_ENABLED: bool = False # Fixed device name applied when the BOT checkbox is enabled. # The original device name is saved and restored when BOT is disabled. -BOT_DEVICE_NAME: str = "NL-OV-ZWL-STDSHGN-WKC Bot" +BOT_DEVICE_NAME: str = "ZwolsBotje" # Default device name used as fallback when restoring from BOT mode # and no original name was saved (e.g. after a restart). diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index 6f86e63..0c5607d 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -677,34 +677,17 @@ class DashboardPage: container.set_visibility(pid == panel_id) self._active_panel = panel_id - # Apply channel filter to messages panel if panel_id == 'messages' and self._messages: self._messages.set_active_channel(channel) - # Force immediate rebuild so the panel is populated the - # moment it becomes visible, instead of waiting for the - # next 500 ms timer tick (which caused the "empty on first - # click, populated on second click" symptom). - data = self._shared.get_snapshot() - self._messages.update( - data, - self._messages.channel_filters, - self._messages.last_channels, - room_pubkeys=( - self._room_server.get_room_pubkeys() - if self._room_server else None - ), - ) - # Apply channel filter to archive panel if panel_id == 'archive' and self._archive_page: self._archive_page.set_channel_filter(channel) - # Force map recenter when opening map panel (Leaflet may be hidden on load) - if panel_id == 'map' and self._map: - data = self._shared.get_snapshot() - data['force_center'] = True - self._map.update(data) +<<<<<<< HEAD +======= + self._refresh_active_panel_now(force_map_center=(panel_id == 'map')) +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b # Update active menu highlight (standalone buttons only) for pid, btn in self._menu_buttons.items(): if pid == panel_id: @@ -712,10 +695,89 @@ class DashboardPage: else: btn.classes(remove='domca-menu-active') + # Refresh only the selected panel immediately so the user does not + # need to wait for the next 500 ms dashboard tick. + self._refresh_active_panel_now() + # Close drawer after selection if self._drawer: self._drawer.hide() +<<<<<<< HEAD + def _refresh_active_panel_now(self) -> None: + """Refresh exactly the active panel once, outside the timer tick.""" + data = self._shared.get_snapshot() + + if self._active_panel == 'device' and self._device: + self._device.update(data) + elif self._active_panel == 'map' and self._map: + data = dict(data) + data['force_center'] = True + self._map.update(data) + elif self._active_panel == 'actions' and self._actions: + self._actions.update(data) + elif self._active_panel == 'contacts' and self._contacts: + self._contacts.update(data) + elif self._active_panel == 'messages' and self._messages: + if data.get('channels'): + self._messages.update_filters(data) + self._messages.update_channel_options(data['channels']) + self._update_submenus(data) +======= + def _refresh_active_panel_now(self, force_map_center: bool = False) -> None: + """Refresh only the currently visible panel. + + This is used directly after a panel switch so the user does not + need to wait for the next 500 ms dashboard tick. + """ + data = self._shared.get_snapshot() + + if data.get('channels'): + self._messages.update_filters(data) + self._messages.update_channel_options(data['channels']) + self._update_submenus(data) + + if self._active_panel == 'device': + self._device.update(data) + elif self._active_panel == 'map': + if force_map_center: + data['force_center'] = True + self._map.update(data) + elif self._active_panel == 'actions': + self._actions.update(data) + elif self._active_panel == 'contacts': + self._contacts.update(data) + elif self._active_panel == 'messages': +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b + self._messages.update( + data, + self._messages.channel_filters, + self._messages.last_channels, +<<<<<<< HEAD + room_pubkeys=self._room_server.get_room_pubkeys() if self._room_server else None, + ) + elif self._active_panel == 'rooms' and self._room_server: + if data.get('channels'): + self._messages.update_filters(data) + self._messages.update_channel_options(data['channels']) + self._update_submenus(data) + self._room_server.update(data) + elif self._active_panel == 'rxlog' and self._rxlog: + self._rxlog.update(data) + elif self._active_panel == 'archive' and self._archive_page: + self._archive_page.refresh() +======= + room_pubkeys=( + self._room_server.get_room_pubkeys() + if self._room_server else None + ), + ) + elif self._active_panel == 'rooms': + self._room_server.update(data) + elif self._active_panel == 'rxlog': + self._rxlog.update(data) +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b + # ------------------------------------------------------------------ # Room Server callback (from ContactsPanel) # ------------------------------------------------------------------ @@ -753,56 +815,78 @@ class DashboardPage: # Always update status self._status_label.text = data['status'] - # Device info - if data['device_updated'] or is_first: - self._device.update(data) - - # Map: always send a snapshot while the panel is active. - # The JS runtime coalesces pending payloads — only the newest - # is ever applied — so calling update() on every tick is cheap. - # This ensures the Leaflet runtime always gets at least one - # valid snapshot after it finishes loading, regardless of - # whether device_updated or is_first happened to be True - # on the tick that fired before MeshCoreLeafletBoot was defined. - if self._active_panel == 'map': - self._map.update(data) - - # Channel-dependent UI: always ensure consistency when - # channels exist. Because a single DashboardPage instance - # is shared across browser sessions (render() is called on - # each new connection), the old session's timer can steal - # the is_first flag before the new timer fires. Running - # these unconditionally is safe because each method has an - # internal fingerprint/equality check that prevents - # unnecessary DOM updates. +<<<<<<< HEAD + # Channel-dependent navigation/filter state remains global, but + # expensive panel refreshes must only run for the active panel. +======= + # Channel-dependent drawer/submenu state may stay global. + # The helpers below already contain equality checks, so this + # remains cheap while keeping navigation consistent. +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b if data['channels']: self._messages.update_filters(data) self._messages.update_channel_options(data['channels']) self._update_submenus(data) - # BOT checkbox state (only on actual change or first render - # to avoid overwriting user interaction mid-toggle) - if data['channels_updated'] or is_first: - self._actions.update(data) + if self._active_panel == 'device': + if data['device_updated'] or is_first: + self._device.update(data) +<<<<<<< HEAD + elif self._active_panel == 'map': + self._map.update(data) + elif self._active_panel == 'actions': + if data['channels_updated'] or is_first: + self._actions.update(data) + elif self._active_panel == 'contacts': + if data['contacts_updated'] or is_first: + self._contacts.update(data) +======= - # Contacts - if data['contacts_updated'] or is_first: - self._contacts.update(data) + elif self._active_panel == 'map': + # Keep sending snapshots while the map panel is active. + # The browser runtime coalesces pending payloads, so only + # the newest snapshot is applied. + self._map.update(data) - # Messages (always — for live filter changes) - self._messages.update( - data, - self._messages.channel_filters, - self._messages.last_channels, - room_pubkeys=self._room_server.get_room_pubkeys() if self._room_server else None, - ) + elif self._active_panel == 'actions': + if data['channels_updated'] or is_first: + self._actions.update(data) - # Room Server panels (always — for live messages + contact changes) - self._room_server.update(data) + elif self._active_panel == 'contacts': + if data['contacts_updated'] or is_first: + self._contacts.update(data) - # RX Log - if data['rxlog_updated']: - self._rxlog.update(data) +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b + elif self._active_panel == 'messages': + self._messages.update( + data, + self._messages.channel_filters, + self._messages.last_channels, +<<<<<<< HEAD + room_pubkeys=self._room_server.get_room_pubkeys() if self._room_server else None, + ) + elif self._active_panel == 'rooms': + self._room_server.update(data) + elif self._active_panel == 'rxlog': + if data['rxlog_updated'] or is_first: + self._rxlog.update(data) + elif self._active_panel == 'archive' and self._archive_page: + if data.get('messages_updated') or data.get('channels_updated') or is_first: + self._archive_page.refresh() +======= + room_pubkeys=( + self._room_server.get_room_pubkeys() + if self._room_server else None + ), + ) + + elif self._active_panel == 'rooms': + self._room_server.update(data) + + elif self._active_panel == 'rxlog': + if data['rxlog_updated'] or is_first: + self._rxlog.update(data) +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b # Signal worker that GUI is ready for data if is_first and data['channels'] and data['contacts']: diff --git a/meshcore_gui/gui/panels/map_panel.py b/meshcore_gui/gui/panels/map_panel.py index 2934350..f5a8e7b 100644 --- a/meshcore_gui/gui/panels/map_panel.py +++ b/meshcore_gui/gui/panels/map_panel.py @@ -48,7 +48,6 @@ class MapPanel: ui.element('div').props(f'id={self._container_id}').classes( 'meshcore-leaflet-host w-full h-72' ) - self._dispatch_to_browser(snapshot={'__command__': 'ensure_map'}) self._apply_theme_only() def set_ui_dark_mode(self, value: bool | None) -> None: diff --git a/meshcore_gui/static/leaflet_map_panel.js b/meshcore_gui/static/leaflet_map_panel.js index 3a21db1..03e79c7 100644 --- a/meshcore_gui/static/leaflet_map_panel.js +++ b/meshcore_gui/static/leaflet_map_panel.js @@ -634,6 +634,18 @@ } } +<<<<<<< HEAD + const hasSnapshotWork = Boolean(current.snapshot); + const hasLiveMap = maps.has(containerId); + + if (!hasSnapshotWork && !hasLiveMap) { +======= + if (!current.snapshot && current.theme && !maps.has(containerId)) { + pending.set(containerId, current); +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b + return; + } + pending.set(containerId, current); scheduleProcess(containerId, 0); };