Compare commits

..

1 Commits

Author SHA1 Message Date
Jack Kingsman 8843836bdf Use nginx docke 2026-03-31 21:37:44 -07:00
46 changed files with 970 additions and 2562 deletions
+1
View File
@@ -30,3 +30,4 @@ references/
docker-compose.yml
docker-compose.yaml
.docker-certs/
.docker-nginx/
+334 -353
View File
@@ -1,191 +1,172 @@
## [3.6.7] - 2026-03-31
* Misc: Remove armv7 (for now)
## [3.6.6] - 2026-03-31
* Misc: Please I'm begging for the build scripts to be working now
## [3.6.5] - 2026-03-31
* Bugfix: Maybe fix problem with publish script
## [3.6.4] - 2026-03-31
* Feature: Clarify New Channel/Contact button
* Bugfix: Rename "Best RSSI" to "Strongest Neighbor"
* Bugfix: Improve layout of Trace pane
* Misc: Docker setup improvements
## [3.6.3] - 2026-03-30
* Feature: Add multi-byte trace
* Feature: Show node name on discovered node if we know it
* Feature: Add docker installation script
* Feature: Add historical noise floor to stats
* Feature: Add trace tool
* Bugfix: 100x performance on statistics endpoint with indices and better queries
* Misc: Performance and correctness improvements for backend-of-the-frontend
* Misc: Reorganize scripts
Feature: Add multi-byte trace
Feature: Show node name on discovered node if we know it
Feature: Add docker installation script
Feature: Add historical noise floor to stats
Feature: Add trace tool
Bugfix: 100x performance on statistics endpoint with indices and better queries
Misc: Performance and correctness improvements for backend-of-the-frontend
Misc: Reorganize scripts
## [3.6.2] - 2026-03-29
* Feature: Be more flexible about timing and volume of full contact offload
* Feature: Improve room server and repeater ops to be much more clearer about auth status
* Feature: Show last error status on integrations
* Feature: Push multi-platform docker builds
* Bugfix: Fix advert interval time unit display
* Bugfix: Don't cast RSSI/SNR to string for community MQTT
* Bugfix: Map uploader follows redirect
* Misc: Thin out unnecessary cruft in unreads endpoint
* Misc: Fall back gracefully if linked to an unknown contact
Feature: Be more flexible about timing and volume of full contact offload
Feature: Improve room server and repeater ops to be much more clearer about auth status
Feature: Show last error status on integrations
Feature: Push multi-platform docker builds
Bugfix: Fix advert interval time unit display
Bugfix: Don't cast RSSI/SNR to string for community MQTT
Bugfix: Map uploader follows redirect
Misc: Thin out unnecessary cruft in unreads endpoint
Misc: Fall back gracefully if linked to an unknown contact
## [3.6.1] - 2026-03-26
* Feature: MeshCore Map integration
* Feature: Add warning screen about bots
* Feature: Favicon reflects unread message state
* Feature: Show hop map in larger modal
* Feature: Add prebuilt frontend install script
* Feature: Add clean service installer script
* Feature: Swipe in to show menu
* Bugfix: Invalid backend API path serves error, not fallback index
* Bugfix: Fix some spacing/page height issues
* Misc: Misc. bugfixes and performance and test improvements
Feature: MeshCore Map integration
Feature: Add warning screen about bots
Feature: Favicon reflects unread message state
Feature: Show hop map in larger modal
Feature: Add prebuilt frontend install script
Feature: Add clean service installer script
Feature: Swipe in to show menu
Bugfix: Invalid backend API path serves error, not fallback index
Bugfix: Fix some spacing/page height issues
Misc: Misc. bugfixes and performance and test improvements
## [3.6.0] - 2026-03-22
* Feature: Add incoming-packet analytics
* Feature: BYOPacket for analysis
* Feature: Add room activity to stats view
* Bugfix: Handle Heltec v3 serial noise
* Misc: Swap repeaters and room servers for better ordering
Feature: Add incoming-packet analytics
Feature: BYOPacket for analysis
Feature: Add room activity to stats view
Bugfix: Handle Heltec v3 serial noise
Misc: Swap repeaters and room servers for better ordering
## [3.5.0] - 2026-03-19
* Feature: Add room server alpha support
* Feature: Add option to force-reset node clock when it's too far ahead
* Feature: DMs auto-retry before resorting to flood
* Feature: Add impulse zero-hop advert
* Feature: Utilize PATH packets to correctly source a contact's route
* Feature: Metrics view on raw packet pane
* Feature: Metric, Imperial, and Smoots are now selectable for distance display
* Feature: Allow favorites to be sorted
* Feature: Add multi-ack support
* Feature: Password-remember checkbox on repeaters + room servers
* Bugfix: Serialize radio disconnect in a lock
* Bugfix: Fix contact bar layout issues
* Bugfix: Fix sidebar ordering for contacts by advert recency
* Bugfix: Fix version reporting in community MQTT
* Bugfix: Fix Apprise duplicate names
* Bugfix: Be better about identity resolution in the stats pane
* Misc: Docs, test, and performance enhancements
* Misc: Don't prompt "Are you sure" when leaving an unedited interation
* Misc: Log node time on startup
* Misc: Improve community MQTT error bubble-up
* Misc: Unread DMs always have a red unread counter
* Misc: Improve information in the debug view to show DB status
Feature: Add room server alpha support
Feature: Add option to force-reset node clock when it's too far ahead
Feature: DMs auto-retry before resorting to flood
Feature: Add impulse zero-hop advert
Feature: Utilize PATH packets to correctly source a contact's route
Feature: Metrics view on raw packet pane
Feature: Metric, Imperial, and Smoots are now selectable for distance display
Feature: Allow favorites to be sorted
Feature: Add multi-ack support
Feature: Password-remember checkbox on repeaters + room servers
Bugfix: Serialize radio disconnect in a lock
Bugfix: Fix contact bar layout issues
Bugfix: Fix sidebar ordering for contacts by advert recency
Bugfix: Fix version reporting in community MQTT
Bugfix: Fix Apprise duplicate names
Bugfix: Be better about identity resolution in the stats pane
Misc: Docs, test, and performance enhancements
Misc: Don't prompt "Are you sure" when leaving an unedited interation
Misc: Log node time on startup
Misc: Improve community MQTT error bubble-up
Misc: Unread DMs always have a red unread counter
Misc: Improve information in the debug view to show DB status
## [3.4.1] - 2026-03-16
* Bugfix: Improve handling of version information on prebuilt bundles
* Bugfix: Improve frontend usability on disconnected radio
* Misc: Docs and readme updates
* Misc: Overhaul DM ingest and frontend state handling
Bugfix: Improve handling of version information on prebuilt bundles
Bugfix: Improve frontend usability on disconnected radio
Misc: Docs and readme updates
Misc: Overhaul DM ingest and frontend state handling
## [3.4.0] - 2026-03-16
* Feature: Add radio model and stats display
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
* Bugfix: Misc. frontend performance and correctness fixes
* Bugfix: Fix same-second same-content DM send collition
* Bugfix: Discard clearly-wrong GPS data
* Bugfix: Prevent repeater clock skew drift on page nav
* Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
* Misc: Don't permit invalid fanout configs to be saved ever`
Feature: Add radio model and stats display
Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
Bugfix: Misc. frontend performance and correctness fixes
Bugfix: Fix same-second same-content DM send collition
Bugfix: Discard clearly-wrong GPS data
Bugfix: Prevent repeater clock skew drift on page nav
Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
Misc: Don't permit invalid fanout configs to be saved ever`
## [3.3.0] - 2026-03-13
* Feature: Use dashed lines to show collapsed ambiguous router results
* Feature: Jump to unred
* Feature: Local channel management to prevent need to reload channel every time
* Feature: Debug endpoint
* Feature: Force-singleton channel management
* Feature: Local node discovery
* Feature: Node routing discovery
* Bugfix: Don't tell users to us npm ci
* Bugfix: Fallback polling dm message persistence
* Bugfix: All native-JS inputs are now modals
* Bugfix: Same-second send collision resolution
* Bugfix: Proper browser updates on resend
* Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
* Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
* Misc: Visualizer data layer overhaul for future map work
* Misc: Parallelize docker tests
Feature: Use dashed lines to show collapsed ambiguous router results
Feature: Jump to unred
Feature: Local channel management to prevent need to reload channel every time
Feature: Debug endpoint
Feature: Force-singleton channel management
Feature: Local node discovery
Feature: Node routing discovery
Bugfix: Don't tell users to us npm ci
Bugfix: Fallback polling dm message persistence
Bugfix: All native-JS inputs are now modals
Bugfix: Same-second send collision resolution
Bugfix: Proper browser updates on resend
Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
Misc: Visualizer data layer overhaul for future map work
Misc: Parallelize docker tests
## [3.2.0] - 2026-03-12
* Feature: Improve ambiguous-sender DM handling and visibility
* Feature: Allow for toggling of node GPS broadcast
* Feature: Add path width to bot and move example to full kwargs
* Feature: Improve node map color contrast
* Bugfix: More accurate tracking of contact data
* Bugfix: Misc. frontend performance and bugfixes
* Misc: Clearer warnings on user-key linkage
* Misc: Documentation improvements
Feature: Improve ambiguous-sender DM handling and visibility
Feature: Allow for toggling of node GPS broadcast
Feature: Add path width to bot and move example to full kwargs
Feature: Improve node map color contrast
Bugfix: More accurate tracking of contact data
Bugfix: Misc. frontend performance and bugfixes
Misc: Clearer warnings on user-key linkage
Misc: Documentation improvements
## [3.1.1] - 2026-03-11
* Feature: Add basic auth
* Feature: SQS fanout
* Feature: Enrich contact info pane
* Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.1.0] - 2026-03-11
* Feature: Add basic auth
* Feature: SQS fanout
* Feature: Enrich contact info pane
* Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.0.0] - 2026-03-10
* Feature: Custom regions per-channel
* Feature: Add custom contact pathing
* Feature: Corrupt packets are more clear that they're corrupt
* Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
* Feature: More consistent icons
* Feature: Add per-channel local notifications
* Feature: New themes
* Feature: Massive codebase refactor and overhaul
* Bugfix: Fix packet parsing for trace packets
* Bugfix: Refetch channels on reconnect
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
* Bugfix: Timestamps in logs
* Bugfix: Correct wrong clock sync command
* Misc: Improve bot error bubble up
* Misc: Update to non-lib-included meshcore-decoder version
* Misc: Revise refactors to be more LLM friendly
* Misc: Fix script executability
* Misc: Better logging format with timestamp
* Misc: Repeater advert buttons separate flood and one-hop
* Misc: Preserve repeater pane on navigation away
* Misc: Clearer iconography and coloring for status bar buttons
* Misc: Search bar to top bar
Feature: Custom regions per-channel
Feature: Add custom contact pathing
Feature: Corrupt packets are more clear that they're corrupt
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
Feature: More consistent icons
Feature: Add per-channel local notifications
Feature: New themes
Feature: Massive codebase refactor and overhaul
Bugfix: Fix packet parsing for trace packets
Bugfix: Refetch channels on reconnect
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
Bugfix: Timestamps in logs
Bugfix: Correct wrong clock sync command
Misc: Improve bot error bubble up
Misc: Update to non-lib-included meshcore-decoder version
Misc: Revise refactors to be more LLM friendly
Misc: Fix script executability
Misc: Better logging format with timestamp
Misc: Repeater advert buttons separate flood and one-hop
Misc: Preserve repeater pane on navigation away
Misc: Clearer iconography and coloring for status bar buttons
Misc: Search bar to top bar
## [2.7.9] - 2026-03-08
* Bugfix: Don't obscure new integration dropdown on session boundary
Bugfix: Don't obscure new integration dropdown on session boundary
## [2.7.8] - 2026-03-08
@@ -193,287 +174,287 @@
## [2.7.8] - 2026-03-08
* Bugfix: Improve frontend asset resolution and fixup the build/push script
Bugfix: Improve frontend asset resolution and fixup the build/push script
## [2.7.1] - 2026-03-08
* Bugfix: Fix historical DM packet length passing
* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
* Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
Bugfix: Fix historical DM packet length passing
Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
## [2.7.0] - 2026-03-08
* Feature: Multibyte path support
* Feature: Add multibyte statistics to statistics pane
* Feature: Add path bittage to contact info pane
* Feature: Put tools in a collapsible
Feature: Multibyte path support
Feature: Add multibyte statistics to statistics pane
Feature: Add path bittage to contact info pane
Feature: Put tools in a collapsible
## [2.6.1] - 2026-03-08
* Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
## [2.6.0] - 2026-03-08
* Feature: A11y improvements
* Feature: New themes
* Feature: Backfill channel sender identity when available
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
* Bugfix: Unreads now respect blocklist
* Bugfix: Unreads can't accumulate on an open thread
* Bugfix: Channel name in broadcasts
* Bugfix: Add missing httpx dependency
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
* Misc: Improved button signage for app movement
* Misc: Test, performance, and documentation improvements
Feature: A11y improvements
Feature: New themes
Feature: Backfill channel sender identity when available
Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
Bugfix: Unreads now respect blocklist
Bugfix: Unreads can't accumulate on an open thread
Bugfix: Channel name in broadcasts
Bugfix: Add missing httpx dependency
Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
Misc: Improved button signage for app movement
Misc: Test, performance, and documentation improvements
## [2.5.0] - 2026-03-05
* Feature: Far better accessibility across the app (with far to go)
* Feature: Add community MQTT stats reporting, and improve over a few commits
* Feature: Color schemes and misc. settings reorg
* Feature: Add why-active to filtered nodes
* Feature: Add channel and contact info box
* Feature: Add contact blocking
* Feature: Add potential repeater path map display
* Feature: Add flood scoping/regions
* Feature: Global message search
* Feature: Fully safe bot disable
* Feature: Add default #remoteterm channel (lol sorry I had to)
* Feature: Custom recency pruning in visualizer
* Bugfix: Be more cautious around null byte stripping
* Bugfix: Clear channel-add interface on not-add-another
* Bugfix: Add status/name/MQTT LWT
* Bugfix: Channel deletion propagates over WS
* Bugfix: Show map location for all nodes on link, not 7-day-limited
* Bugfix: Hide private key channel keys by default
* Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
* Misc: Doc, changelog, and test improvements
* Misc: Add, and remove, package lock (sorry Windows users)
* Misc: Don't show mark all as read if not necessary
* Misc: Fix stale closures and misc. frontend perf/correctness improvements
* Misc: Add Windows startup notes
* Misc: E2E expansion + improvement
* Misc: Move around visualizer settings
Feature: Far better accessibility across the app (with far to go)
Feature: Add community MQTT stats reporting, and improve over a few commits
Feature: Color schemes and misc. settings reorg
Feature: Add why-active to filtered nodes
Feature: Add channel and contact info box
Feature: Add contact blocking
Feature: Add potential repeater path map display
Feature: Add flood scoping/regions
Feature: Global message search
Feature: Fully safe bot disable
Feature: Add default #remoteterm channel (lol sorry I had to)
Feature: Custom recency pruning in visualizer
Bugfix: Be more cautious around null byte stripping
Bugfix: Clear channel-add interface on not-add-another
Bugfix: Add status/name/MQTT LWT
Bugfix: Channel deletion propagates over WS
Bugfix: Show map location for all nodes on link, not 7-day-limited
Bugfix: Hide private key channel keys by default
Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
Misc: Doc, changelog, and test improvements
Misc: Add, and remove, package lock (sorry Windows users)
Misc: Don't show mark all as read if not necessary
Misc: Fix stale closures and misc. frontend perf/correctness improvements
Misc: Add Windows startup notes
Misc: E2E expansion + improvement
Misc: Move around visualizer settings
## [2.4.0] - 2026-03-02
* Feature: Add community MQTT reporting (e.g. LetsMesh.net)
* Misc: Build scripts and library attribution
* Misc: Add sign of life to E2E tests
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
Misc: Build scripts and library attribution
Misc: Add sign of life to E2E tests
## [2.3.0] - 2026-03-01
* Feature: Click path description to reset to flood
* Feature: Add MQTT publishing
* Feature: Visualizer remembers settings
* Bugfix: Fix prefetch usage
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
* Misc: Drop py3.12 requirement
* Misc: Performance, documentation, test, and file structure optimizations
* Misc: Add arrows between route nodes on contact info
* Misc: Show repeater path/type in title bar
Feature: Click path description to reset to flood
Feature: Add MQTT publishing
Feature: Visualizer remembers settings
Bugfix: Fix prefetch usage
Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
Misc: Drop py3.12 requirement
Misc: Performance, documentation, test, and file structure optimizations
Misc: Add arrows between route nodes on contact info
Misc: Show repeater path/type in title bar
## [2.2.0] - 2026-02-28
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer
* Feature: Contact info pane
* Feature: Overhaul repeater interface
* Bugfix: Misc. frontend rendering + perf improvements
* Bugfix: Better behavior around radio locking and autofetch/polling
* Bugfix: Clear channel name field on new-channel modal tab change
* Bugfix: Repeater inforbox can scroll
* Bugfix: Better handling of historical DM encrypts
* Bugfix: Handle errors if returned in prefetch phase
* Misc: Radio event response failure is logged/surfaced better
* Misc: Improve test coverage and remove dead code
* Misc: Documentation and errata improvements
* Misc: Database storage optimization
Feature: Track advert paths and use to disambiguate repeater identity in visualizer
Feature: Contact info pane
Feature: Overhaul repeater interface
Bugfix: Misc. frontend rendering + perf improvements
Bugfix: Better behavior around radio locking and autofetch/polling
Bugfix: Clear channel name field on new-channel modal tab change
Bugfix: Repeater inforbox can scroll
Bugfix: Better handling of historical DM encrypts
Bugfix: Handle errors if returned in prefetch phase
Misc: Radio event response failure is logged/surfaced better
Misc: Improve test coverage and remove dead code
Misc: Documentation and errata improvements
Misc: Database storage optimization
## [2.1.0] - 2026-02-23
* Feature: Add ability to remember last-used channel on load
* Feature: Add `docker compose` support (thanks @suymur !)
* Feature: Better-aligned favicon (lol)
* Bugfix: Disable autocomplete on message field
* Bugfix: Legacy hash restoration on page load
* Bugfix: Align resend buttons in pathing modal
* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
* Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
* Bugfix: Improved repeater comms on busy meshes
* Bugfix: Drain before autofetch from radio
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
* Bugfix: Force server-side keystore update when radio key changes
* Bugfix: Reduce WS churn for incoming message handling
* Bugfix: Fix content type signalling for irrelevant endpoints
* Bugfix: Handle stuck post-connect failure state
* Misc: Documentation & version parsing improvements
* Misc: Hide char counter on mobile for short messages
* Misc: Typo fixes in docs and settings
* Misc: Add dynamic webmanifest for hosts that can support it
* Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
* Misc: Drop weird rounded bounding box for settings
* Misc: Move resend buttons to pathing modal
* Misc: Improved comments around database ownership on *nix systems
* Misc: Move to SSoT for message dedupe on frontend
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling
* Misc: Holistic testing overhaul
Feature: Add ability to remember last-used channel on load
Feature: Add `docker compose` support (thanks @suymur !)
Feature: Better-aligned favicon (lol)
Bugfix: Disable autocomplete on message field
Bugfix: Legacy hash restoration on page load
Bugfix: Align resend buttons in pathing modal
Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
Bugfix: Improved repeater comms on busy meshes
Bugfix: Drain before autofetch from radio
Bugfix: Fix, or document exceptions to, sub-second resolution message failure
Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
Bugfix: Force server-side keystore update when radio key changes
Bugfix: Reduce WS churn for incoming message handling
Bugfix: Fix content type signalling for irrelevant endpoints
Bugfix: Handle stuck post-connect failure state
Misc: Documentation & version parsing improvements
Misc: Hide char counter on mobile for short messages
Misc: Typo fixes in docs and settings
Misc: Add dynamic webmanifest for hosts that can support it
Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
Misc: Drop weird rounded bounding box for settings
Misc: Move resend buttons to pathing modal
Misc: Improved comments around database ownership on *nix systems
Misc: Move to SSoT for message dedupe on frontend
Misc: Move DM ack clearing to standard poll, and increase hold time between polling
Misc: Holistic testing overhaul
## [2.0.1] - 2026-02-16
* Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
## [2.0.0] - 2026-02-16
* Feature: Frontend UX + log overhaul
* Bugfix: Use contact object from DB for broadcast rather than handrolling
* Bugfix: Fix out of order path WS messages overwriting each other
* Bugfix: Make broadcast timestamp match fallback logic used in storage code
* Bugfix: Fix repeater command timestamp selection logic
* Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
* Bugfix: Add missing radio operation locks in a few spots
* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
* Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
* Misc: Visualizer layout refinement & option labels
Feature: Frontend UX + log overhaul
Bugfix: Use contact object from DB for broadcast rather than handrolling
Bugfix: Fix out of order path WS messages overwriting each other
Bugfix: Make broadcast timestamp match fallback logic used in storage code
Bugfix: Fix repeater command timestamp selection logic
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
Bugfix: Add missing radio operation locks in a few spots
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
Misc: Visualizer layout refinement & option labels
## [1.10.0] - 2026-02-16
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
* Feature: 3D mesh visualizer
* Feature: Statistics pane
* Feature: Support incoming/outgoing indication for bot invocations
* Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
* Bugfix: Fix top padding out outgoing message
* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
* Bugfix: Multiple-sent messages had path appearing delays until rerender
* Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
* Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
* Misc: s/stopped/idle/ for room finder
Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
Feature: 3D mesh visualizer
Feature: Statistics pane
Feature: Support incoming/outgoing indication for bot invocations
Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
Bugfix: Fix top padding out outgoing message
Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
Bugfix: Multiple-sent messages had path appearing delays until rerender
Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
Misc: s/stopped/idle/ for room finder
## [1.9.3] - 2026-02-12
* Feature: Upgrade the room finder to support two-word rooms
Feature: Upgrade the room finder to support two-word rooms
## [1.9.2] - 2026-02-12
* Feature: Options dialog sucks less
* Bugfix: Move tests to isolated memory DB
* Bugfix: Mention case sensitivity
* Bugfix: Stale header retention on settings page view
* Bugfix: Non-isolated path writing
* Bugfix: Nullable contact fields are now passed as real nulls
* Bugfix: Look at all fields on message reconcile, not just text
* Bugfix: Make mark-all-as-read atomic
* Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
* Misc: Massive test and AGENTS.md overhauls and additions
Feature: Options dialog sucks less
Bugfix: Move tests to isolated memory DB
Bugfix: Mention case sensitivity
Bugfix: Stale header retention on settings page view
Bugfix: Non-isolated path writing
Bugfix: Nullable contact fields are now passed as real nulls
Bugfix: Look at all fields on message reconcile, not just text
Bugfix: Make mark-all-as-read atomic
Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
Misc: Massive test and AGENTS.md overhauls and additions
## [1.9.1] - 2026-02-10
* Feature: Contacts and channels use keys, not names
* Bugfix: Fix falsy casting of 0 in lat lon and timing data
* Bugfix: Show message length in bytes, not chars
* Bugfix: Fix phantom unread badges on focused convos
* Misc: Bot invocation to async
* Misc: Use full key, not prefix, where we can
Feature: Contacts and channels use keys, not names
Bugfix: Fix falsy casting of 0 in lat lon and timing data
Bugfix: Show message length in bytes, not chars
Bugfix: Fix phantom unread badges on focused convos
Misc: Bot invocation to async
Misc: Use full key, not prefix, where we can
## [1.9.0] - 2026-02-10
* Feature: Favorited contacts are preferentially loaded onto the radio
* Feature: Add recent-message caching for fast switching
* Feature: Add echo paths modal when echo-heard checkbox is clicked
* Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
Feature: Favorited contacts are preferentially loaded onto the radio
Feature: Add recent-message caching for fast switching
Feature: Add echo paths modal when echo-heard checkbox is clicked
Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
Frontend: Better styling on echo + message path display
* Bugfix: Prevent frontend static file serving path traversal vuln
* Bugfix: Safer prefix-claiming for DMs we don't have the key for
* Bugfix: Prevent injection from mentions with special characters
* Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
* Bugfix: App can boot and test without a frontend dir
* Misc: Improve and consistent-ify (?) backend radio operation lock management
* Misc: Frontend performance and safety enhancements
* Misc: Move builds to non-bundled; usage requires building the Frontend
* Misc: Update tests and agent docs
Bugfix: Prevent frontend static file serving path traversal vuln
Bugfix: Safer prefix-claiming for DMs we don't have the key for
Bugfix: Prevent injection from mentions with special characters
Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
Bugfix: App can boot and test without a frontend dir
Misc: Improve and consistent-ify (?) backend radio operation lock management
Misc: Frontend performance and safety enhancements
Misc: Move builds to non-bundled; usage requires building the Frontend
Misc: Update tests and agent docs
## [1.8.0] - 2026-02-07
* Feature: Single hop ping
* Feature: PWA viewport fixes(thanks @rgregg)
Feature: Single hop ping
Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P
* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
* Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
* Bugfix: Better guarding around reconnection
* Bugfix: Duplicate websocket connection fixes
* Bugfix: Settings tab error cleanliness on tab swap
* Bugfix: Fix path traversal vuln
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
Bugfix: Better guarding around reconnection
Bugfix: Duplicate websocket connection fixes
Bugfix: Settings tab error cleanliness on tab swap
Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier)
* Misc: Perf and locking improvements
* Misc: Always flood advertisements
* Misc: Better packet dupe handling
* Misc: Dead code cleanup, test improvements
Misc: Perf and locking improvements
Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
## [1.7.1] - 2026-02-03
* Feature: Clickable hyperlinks
* Bugfix: More consistent public key normalization
* Bugfix: Use more reliable cursor paging
* Bugfix: Fix null timestamp dedupe failure
* Bugfix: More consistent prefix-based message claiming on key receipt
* Misc: Bot can respond to its own messages
* Misc: Additional tests
* Misc: Remove unneeded message dedupe logic
* Misc: Resync settings after radio settings mutation
Feature: Clickable hyperlinks
Bugfix: More consistent public key normalization
Bugfix: Use more reliable cursor paging
Bugfix: Fix null timestamp dedupe failure
Bugfix: More consistent prefix-based message claiming on key receipt
Misc: Bot can respond to its own messages
Misc: Additional tests
Misc: Remove unneeded message dedupe logic
Misc: Resync settings after radio settings mutation
## [1.7.0] - 2026-01-27
* Feature: Multi-bot functionality
* Bugfix: Adjust bot code editor display and add line numbers
* Bugfix: Fix clock filtering and contact lookup behavior bugs
* Bugfix: Fix repeater message duplication issue
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
Feature: Multi-bot functionality
Bugfix: Adjust bot code editor display and add line numbers
Bugfix: Fix clock filtering and contact lookup behavior bugs
Bugfix: Fix repeater message duplication issue
Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
UI: Move advertise button to identity tab
* Misc: Clarify fallback functionality for missing private key export in logs
Misc: Clarify fallback functionality for missing private key export in logs
## [1.6.0] - 2026-01-26
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
* Feature: Customizable advertising interval
* Feature: In-app bot setup
* Bugfix: Force contact onto radio before DM send
* Misc: Remove unused code
Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
Feature: Customizable advertising interval
Feature: In-app bot setup
Bugfix: Force contact onto radio before DM send
Misc: Remove unused code
## [1.5.0] - 2026-01-19
* Feature: Network visualizer
Feature: Network visualizer
## [1.4.1] - 2026-01-19
* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
* Feature: Server-side preference management for favorites, read status, etc.
Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
Feature: Server-side preference management for favorites, read status, etc.
UI: More compact hop labelling
* Bugfix: Misc. race conditions and websocket handling
* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
Bugfix: Misc. race conditions and websocket handling
Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
## [1.4.0] - 2026-01-18
UI: Improve button layout for room searcher
UI: Improve favicon coloring
UI: Improve status bar button layout on small screen
* Feature: Show multi-path hop display with distance estimates
* Feature: Search rooms and contacts by key, not just name
* Bugfix: Historical DM decryption now works as expected
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
Feature: Show multi-path hop display with distance estimates
Feature: Search rooms and contacts by key, not just name
Bugfix: Historical DM decryption now works as expected
Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
## [1.3.1] - 2026-01-17
UI: Rework restart handling
* Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
Bug: Improve error message rendering from server-side errors
UI: Remove octothorpe from channel listing
## [1.3.0] - 2026-01-17
* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
* Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
* Feature: Drop repeater login wait time; vestigial from debugging a different issue
Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
Feature: Drop repeater login wait time; vestigial from debugging a different issue
## [1.2.1] - 2026-01-17
@@ -481,27 +462,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
## [1.2.0] - 2026-01-16
* Feature: Add favorites
Feature: Add favorites
## [1.1.0] - 2026-01-14
* Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
* Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
## [1.0.3] - 2026-01-13
* Bugfix: Add missing test management packages
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
Bugfix: Add missing test management packages
Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
## [1.0.2] - 2026-01-13
* Improvement: Add delays between router ops to prevent traffic collisions
Improvement: Add delays between router ops to prevent traffic collisions
## [1.0.1] - 2026-01-13
* Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
## [1.0.0] - 2026-01-13
* Initial full release!
Initial full release!
+3 -13
View File
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
data BLOB NOT NULL,
message_id INTEGER,
payload_hash BLOB,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
FOREIGN KEY (message_id) REFERENCES messages(id)
);
CREATE TABLE IF NOT EXISTS contact_advert_paths (
@@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
CREATE TABLE IF NOT EXISTS contact_name_history (
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
@@ -132,12 +132,6 @@ class Database:
# migration 20 handles the one-time VACUUM to restructure the file.
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
# Foreign key enforcement: must be set per-connection (not persisted).
# Disabled during schema init and migrations to avoid issues with
# historical table-rebuild migrations that may temporarily violate
# constraints, then re-enabled for all subsequent application queries.
await self._connection.execute("PRAGMA foreign_keys = OFF")
await self._connection.executescript(SCHEMA)
await self._connection.commit()
logger.debug("Database schema initialized")
@@ -147,10 +141,6 @@ class Database:
await run_migrations(self._connection)
# Enable FK enforcement for all application queries from this point on.
await self._connection.execute("PRAGMA foreign_keys = ON")
logger.debug("Foreign key enforcement enabled")
async def disconnect(self) -> None:
if self._connection:
await self._connection.close()
+2 -192
View File
@@ -367,21 +367,6 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 47)
applied += 1
# Migration 48: Add discovery_blocked_types column to app_settings
if version < 48:
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
await _migrate_048_discovery_blocked_types(conn)
await set_version(conn, 48)
applied += 1
# Migration 49: Enable foreign key enforcement — rebuild tables with
# CASCADE / SET NULL and clean up any orphaned rows first.
if version < 49:
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
await _migrate_049_foreign_key_cascade(conn)
await set_version(conn, 49)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -844,7 +829,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0
@@ -856,7 +841,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
await conn.execute(
"""
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 1, 'recent', '{}', 0)
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
"""
)
@@ -2924,178 +2909,3 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
"""
)
await conn.commit()
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
"""Add discovery_blocked_types column to app_settings.
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
3=Room, 4=Sensor) whose advertisements should not create new contacts.
Empty list means all types are accepted.
"""
try:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
)
except Exception as e:
error_msg = str(e).lower()
if "duplicate column" in error_msg:
logger.debug("discovery_blocked_types column already exists, skipping")
elif "no such table" in error_msg:
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
else:
raise
await conn.commit()
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
Orphaned child rows are cleaned up before the rebuild to ensure the
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
"""
import shutil
from pathlib import Path
# Back up the database before table rebuilds (skip for in-memory DBs).
cursor = await conn.execute("PRAGMA database_list")
db_row = await cursor.fetchone()
db_path = db_row[2] if db_row else ""
if db_path and db_path != ":memory:" and Path(db_path).exists():
backup_path = db_path + ".pre-fk-migration.bak"
for suffix in ("", "-wal", "-shm"):
src = Path(db_path + suffix)
if src.exists():
shutil.copy2(str(src), backup_path + suffix)
logger.info("Database backed up to %s before FK migration", backup_path)
# --- Phase 1: clean orphans (guard each table's existence) ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_advert_paths "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_name_history "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "raw_packets" in existing_tables and "messages" in existing_tables:
# Guard: message_id column may not exist on very old schemas
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
raw_cols = {row[1] for row in await col_cursor.fetchall()}
if "message_id" in raw_cols:
await conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
"AND message_id NOT IN (SELECT id FROM messages)"
)
await conn.commit()
logger.debug("Cleaned orphaned child rows before FK rebuild")
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
raw_has_message_id = False
if "raw_packets" in existing_tables:
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
if raw_has_message_id:
# Dynamically build column list based on what the old table actually has,
# since very old schemas may lack payload_hash (added in migration 28).
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
old_cols = [row[1] for row in await col_cursor3.fetchall()]
new_col_defs = [
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"timestamp INTEGER NOT NULL",
"data BLOB NOT NULL",
"message_id INTEGER",
]
copy_cols = ["id", "timestamp", "data", "message_id"]
if "payload_hash" in old_cols:
new_col_defs.append("payload_hash BLOB")
copy_cols.append("payload_hash")
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
cols_sql = ", ".join(new_col_defs)
copy_sql = ", ".join(copy_cols)
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
await conn.execute(
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
)
await conn.execute("DROP TABLE raw_packets")
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
)
if "payload_hash" in old_cols:
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
)
await conn.commit()
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
if "contact_advert_paths" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_advert_paths_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
)
await conn.execute("DROP TABLE contact_advert_paths")
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
"ON contact_advert_paths(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
if "contact_name_history" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_name_history_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
name TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
)
await conn.execute("DROP TABLE contact_name_history")
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
"ON contact_name_history(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
+1 -8
View File
@@ -805,7 +805,7 @@ class AppSettings(BaseModel):
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=True,
default=False,
description="Whether to attempt historical DM decryption on new contact advertisement",
)
sidebar_sort_order: Literal["recent", "alpha"] = Field(
@@ -840,13 +840,6 @@ class AppSettings(BaseModel):
default_factory=list,
description="Display names whose messages are hidden from the UI",
)
discovery_blocked_types: list[int] = Field(
default_factory=list,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts; existing contacts are still updated"
),
)
class FanoutConfig(BaseModel):
+8 -24
View File
@@ -462,19 +462,14 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
# Check discovery_blocked_types: skip new contacts whose type is blocked.
# Existing contacts are always updated (location, name, last_seen, etc.).
if existing is None and contact_type > 0:
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
if contact_type in settings.discovery_blocked_types:
logger.debug(
"Skipping new contact %s: type %d is in discovery_blocked_types",
advert.public_key[:12],
contact_type,
)
return
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
contact_upsert = ContactUpsert(
public_key=advert.public_key.lower(),
@@ -487,18 +482,7 @@ async def _process_advertisement(
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
# Upsert the contact BEFORE recording advert paths so the parent row
# exists when foreign key enforcement is enabled.
await ContactRepository.upsert(contact_upsert)
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key,
log=logger,
+4 -35
View File
@@ -29,10 +29,7 @@ from app.repository import (
ChannelRepository,
ContactRepository,
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.contact_reconciliation import reconcile_contact_messages
from app.services.messages import create_fallback_channel_message
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error, broadcast_event
@@ -66,25 +63,13 @@ async def _reconcile_contact_messages_background(
public_key: str,
contact_name: str | None,
) -> None:
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
"""Run contact/message reconciliation outside the radio critical path."""
try:
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
await reconcile_contact_messages(
public_key=public_key,
contact_name=contact_name,
log=logger,
)
if promoted_keys:
contact = await ContactRepository.get_by_key(public_key.lower())
if contact is not None:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": contact.model_dump()},
)
except Exception as exc:
logger.warning(
"Background contact reconciliation failed for %s: %s",
@@ -194,22 +179,6 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
def _effective_radio_capacity(configured: int) -> int:
"""Return the effective radio contact capacity.
Uses the lower of the user-configured ``max_radio_contacts`` and the
hardware limit reported by the radio at connect time. The existing
80% refill ratio already reserves headroom for the radio to
organically add contacts it hears via adverts, so no additional
reduction is applied here.
"""
capacity = max(1, configured)
hw_limit = radio_manager.max_contacts
if hw_limit is not None:
capacity = min(capacity, hw_limit)
return max(1, capacity)
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
capacity = max(1, max_contacts)
@@ -224,7 +193,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
"""Check current radio occupancy and decide whether to offload/reload."""
app_settings = await AppSettingsRepository.get()
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
capacity = app_settings.max_radio_contacts
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts()
@@ -1332,7 +1301,7 @@ async def stop_background_contact_reconciliation() -> None:
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
"""Return the contacts that would be loaded onto the radio right now."""
app_settings = await AppSettingsRepository.get()
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
max_contacts = app_settings.max_radio_contacts
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
+51 -63
View File
@@ -1,4 +1,3 @@
import logging
import time
from collections.abc import Mapping
from typing import Any
@@ -13,8 +12,6 @@ from app.models import (
)
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
logger = logging.getLogger(__name__)
class AmbiguousPublicKeyPrefixError(ValueError):
"""Raised when a public key prefix matches multiple contacts."""
@@ -487,6 +484,7 @@ class ContactRepository:
return []
promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows:
old_key = row["public_key"]
@@ -503,70 +501,60 @@ class ContactRepository:
(old_key,),
)
match_row = await match_cursor.fetchone()
match_count = match_row["match_count"] if match_row is not None else 0
if match_count != 1:
logger.warning(
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
old_key,
match_count,
)
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
await migrate_child_rows(old_key, normalized_full_key)
# Merge timestamp metadata from the old prefix contact into the
# full-key contact (which all callers guarantee already exists),
# then delete the prefix placeholder.
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = CASE
WHEN contacts.last_read_at IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_read_at
WHEN ? > contacts.last_read_at THEN ?
ELSE contacts.last_read_at
END
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
if full_exists:
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = COALESCE(contacts.last_read_at, ?)
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
else:
await db.conn.execute(
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
(normalized_full_key, old_key),
)
full_exists = True
promoted_keys.append(old_key)
+2 -9
View File
@@ -158,11 +158,7 @@ class MessageRepository:
"""
lower_key = full_key.lower()
cursor = await db.conn.execute(
"""UPDATE messages SET conversation_key = ?,
sender_key = CASE
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
AND ? LIKE sender_key || '%'
THEN ? ELSE sender_key END
"""UPDATE messages SET conversation_key = ?
WHERE type = 'PRIV' AND length(conversation_key) < 64
AND ? LIKE conversation_key || '%'
AND (
@@ -170,7 +166,7 @@ class MessageRepository:
WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%'
) = 1""",
(lower_key, lower_key, lower_key, lower_key),
(lower_key, lower_key),
)
await db.conn.commit()
return cursor.rowcount
@@ -576,9 +572,6 @@ class MessageRepository:
@staticmethod
async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID."""
await db.conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
)
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit()
+1 -15
View File
@@ -29,7 +29,7 @@ class AppSettingsRepository:
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types
blocked_keys, blocked_names
FROM app_settings WHERE id = 1
"""
)
@@ -81,14 +81,6 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
blocked_names = []
# Parse discovery_blocked_types JSON
discovery_blocked_types: list[int] = []
if row["discovery_blocked_types"]:
try:
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
except (json.JSONDecodeError, TypeError):
discovery_blocked_types = []
# Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"):
@@ -106,7 +98,6 @@ class AppSettingsRepository:
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
)
@staticmethod
@@ -122,7 +113,6 @@ class AppSettingsRepository:
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -173,10 +163,6 @@ class AppSettingsRepository:
updates.append("blocked_names = ?")
params.append(json.dumps(blocked_names))
if discovery_blocked_types is not None:
updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types))
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
+3 -50
View File
@@ -1,12 +1,10 @@
import asyncio
import logging
import random
import time
from contextlib import suppress
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
@@ -33,7 +31,7 @@ from app.repository import (
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
reconcile_contact_messages,
)
from app.services.radio_runtime import radio_runtime as radio_manager
@@ -279,18 +277,12 @@ async def create_contact(
# Check if contact already exists
existing = await ContactRepository.get_by_key(request.public_key)
if existing:
# Update name if provided and record name history
# Update name if provided
if request.name:
await ContactRepository.upsert(existing.to_upsert(name=request.name))
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
await record_contact_name_and_reconcile(
public_key=request.public_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key,
@@ -325,10 +317,9 @@ async def create_contact(
log=logger,
)
await record_contact_name_and_reconcile(
await reconcile_contact_messages(
public_key=lower_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
@@ -356,44 +347,6 @@ async def mark_contact_read(public_key: str) -> dict:
return {"status": "ok", "public_key": contact.public_key}
class BulkDeleteRequest(BaseModel):
public_keys: list[str] = Field(description="Public keys to delete")
@router.post("/bulk-delete")
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
"""Delete multiple contacts from the database (and radio if present)."""
from app.websocket import broadcast_event
# Resolve all contacts first
contacts_to_delete: list[Contact] = []
for key in request.public_keys:
contact = await ContactRepository.get_by_key(key.lower())
if contact:
contacts_to_delete.append(contact)
# Remove from radio in a single locked operation (blocks until radio is free)
if radio_manager.is_connected and contacts_to_delete:
try:
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
for contact in contacts_to_delete:
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
await mc.commands.remove_contact(radio_contact)
except Exception as e:
logger.warning("Radio removal during bulk delete failed: %s", e)
# Delete from database and broadcast events
deleted = 0
for contact in contacts_to_delete:
await ContactRepository.delete(contact.public_key)
broadcast_event("contact_deleted", {"public_key": contact.public_key})
deleted += 1
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
return {"deleted": deleted}
@router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present)."""
+19 -138
View File
@@ -1,21 +1,17 @@
import hashlib
import logging
import os
import platform
import struct
import sys
from datetime import datetime, timezone
from typing import Any, Literal
from typing import Any
from fastapi import APIRouter
from meshcore import EventType
from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings
from app.models import AppSettings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
from app.routers.health import FanoutStatusResponse, build_health_data
from app.repository import MessageRepository, StatisticsRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
@@ -38,13 +34,6 @@ LOG_COPY_BOUNDARY_PREFIX = [
]
class DebugSystemInfo(BaseModel):
os: str
arch: str
arch_bits: int
total_ram_mb: int
class DebugApplicationInfo(BaseModel):
version: str
version_source: str
@@ -61,6 +50,8 @@ class DebugRuntimeInfo(BaseModel):
setup_in_progress: bool
setup_complete: bool
channels_with_incoming_messages: int
max_channels: int
path_hash_mode: int
path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int
@@ -87,6 +78,7 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel):
performed: bool
errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None
@@ -101,53 +93,16 @@ class DebugDatabaseInfo(BaseModel):
total_outgoing: int
class DebugHealthSummary(BaseModel):
radio_state: str
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
bots_disabled_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
advert_interval: int
flood_scope: str
blocked_keys_count: int
blocked_names_count: int
class DebugSnapshotResponse(BaseModel):
captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo
health: DebugHealthSummary
settings: DebugAppSettings
health: HealthResponse
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
radio_probe: DebugRadioProbe
logs: list[str]
def _build_system_info() -> DebugSystemInfo:
try:
# os.sysconf is available on Linux/macOS
page_size = os.sysconf("SC_PAGE_SIZE")
page_count = os.sysconf("SC_PHYS_PAGES")
total_ram_mb = (page_size * page_count) // (1024 * 1024)
except (AttributeError, ValueError, OSError):
total_ram_mb = 0
return DebugSystemInfo(
os=f"{platform.system()} {platform.release()}",
arch=platform.machine(),
arch_bits=struct.calcsize("P") * 8,
total_ram_mb=total_ram_mb,
)
def _build_application_info() -> DebugApplicationInfo:
build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain")
@@ -203,68 +158,6 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
return None
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
return DebugAppSettings(
max_radio_contacts=app_settings.max_radio_contacts,
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
advert_interval=app_settings.advert_interval,
flood_scope=app_settings.flood_scope,
blocked_keys_count=len(app_settings.blocked_keys),
blocked_names_count=len(app_settings.blocked_names),
)
def _derive_debug_radio_state(
*,
radio_connected: bool,
connection_desired: bool,
setup_in_progress: bool,
setup_complete: bool,
is_reconnecting: bool,
) -> str:
if not connection_desired:
return "paused"
if radio_connected and (setup_in_progress or not setup_complete):
return "initializing"
if radio_connected:
return "connected"
if is_reconnecting:
return "connecting"
return "disconnected"
def _build_debug_health_summary(
health_data: dict[str, Any], *, radio_state: str
) -> DebugHealthSummary:
def _fanout_last_error(status: Any) -> str | None:
if isinstance(status, dict):
value = status.get("last_error")
else:
value = getattr(status, "last_error", None)
return value if isinstance(value, str) and value else None
fanouts_with_errors = {
config_id: status
for config_id, status in health_data["fanout_statuses"].items()
if _fanout_last_error(status)
}
return DebugHealthSummary(
radio_state=radio_state,
database_size_mb=health_data["database_size_mb"],
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
fanouts_with_errors=fanouts_with_errors,
bots_disabled_source=health_data["bots_disabled_source"],
basic_auth_enabled=health_data["basic_auth_enabled"],
)
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
sanitized = dict(self_info or {})
sanitized.pop("adv_lat", None)
sanitized.pop("adv_lon", None)
return sanitized
async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit:
@@ -349,7 +242,10 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe(
performed=True,
errors=errors,
self_info=_sanitize_radio_probe_self_info(mc.self_info),
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info,
stats_core=stats_core,
stats_radio=stats_radio,
@@ -368,39 +264,24 @@ async def _probe_radio() -> DebugRadioProbe:
@router.get("/debug", response_model=DebugSnapshotResponse)
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
connection_info = radio_runtime.connection_info
connection_desired = radio_runtime.connection_desired
setup_in_progress = radio_runtime.is_setup_in_progress
setup_complete = radio_runtime.is_setup_complete
radio_connected = radio_runtime.is_connected
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
health_data = await build_health_data(radio_connected, connection_info)
app_settings = await AppSettingsRepository.get()
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
)
radio_state = _derive_debug_radio_state(
radio_connected=radio_connected,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
is_reconnecting=is_reconnecting,
)
return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
health=_build_debug_health_summary(health_data, radio_state=radio_state),
settings=_build_debug_app_settings(app_settings),
health=HealthResponse(**health_data),
runtime=DebugRuntimeInfo(
connection_info=connection_info,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
connection_info=radio_runtime.connection_info,
connection_desired=radio_runtime.connection_desired,
setup_in_progress=radio_runtime.is_setup_in_progress,
setup_complete=radio_runtime.is_setup_complete,
channels_with_incoming_messages=channels_with_incoming_messages,
max_channels=radio_runtime.max_channels,
path_hash_mode=radio_runtime.path_hash_mode,
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
+3 -14
View File
@@ -24,10 +24,7 @@ from app.models import (
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
from app.repository import ContactRepository
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
from app.services.radio_commands import (
KeystoreRefreshError,
PathHashModeUnsupportedError,
@@ -217,19 +214,11 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
public_key=result.public_key,
log=logger,
)
await reconcile_contact_messages(
public_key=result.public_key,
contact_name=result.name,
log=logger,
)
created = await ContactRepository.get_by_key(result.public_key)
if created is not None:
broadcast_event("contact", created.model_dump())
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": created.model_dump()},
)
for old_key in promoted_keys:
broadcast_event("contact_deleted", {"public_key": old_key})
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
-13
View File
@@ -48,13 +48,6 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Display names whose messages are hidden from the UI",
)
discovery_blocked_types: list[int] | None = Field(
default=None,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts"
),
)
class BlockKeyRequest(BaseModel):
@@ -129,12 +122,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names
# Discovery blocked types
if update.discovery_blocked_types is not None:
# Only allow valid contact type codes (1-4)
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
kwargs["discovery_blocked_types"] = sorted(set(valid))
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.6.7",
"version": "3.6.3",
"type": "module",
"scripts": {
"dev": "vite",
-7
View File
@@ -471,8 +471,6 @@ export function App() {
favorites,
legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
};
const conversationPaneProps = {
activeConversation,
@@ -557,11 +555,6 @@ export function App() {
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
contacts,
onBulkDeleteContacts: (deletedKeys: string[]) => {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
},
};
const crackerProps = {
packets: rawPackets,
-6
View File
@@ -149,12 +149,6 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
bulkDeleteContacts: (publicKeys: string[]) =>
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public_keys: publicKeys }),
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', {
method: 'POST',
+32 -112
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import {
LineChart,
@@ -35,7 +35,6 @@ import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { CONTACT_TYPE_REPEATER } from '../types';
import type {
Contact,
ContactActiveRoom,
@@ -159,7 +158,6 @@ export function ContactInfoPane({
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -442,7 +440,7 @@ export function ContactInfoPane({
</div>
)}
{!isRepeater && onSearchMessagesByKey && (
{onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
@@ -455,60 +453,40 @@ export function ContactInfoPane({
</div>
)}
{/* Nearest Repeaters (Hops) — last 7 days only */}
{analytics &&
(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const recent = analytics.nearest_repeaters.filter(
(r) => r.last_seen >= sevenDaysAgo
);
if (recent.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Hops (last 7 days)</SectionLabel>
<div className="space-y-1">
{recent.map((r) => (
<div
key={r.public_key}
className="flex justify-between items-center text-sm"
>
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
))}
{/* Nearest Repeaters */}
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{analytics.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
</div>
);
})()}
{/* Geographically nearest repeaters (repeaters only) */}
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
<NearbyRepeatersSection
contact={contact}
contacts={contacts}
distanceUnit={distanceUnit}
/>
))}
</div>
</div>
)}
{/* Advert Paths */}
{analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1.5">
<div className="space-y-1">
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-start gap-2 text-sm"
className="flex justify-between items-center text-sm"
>
<span className="font-mono text-xs break-all">
<span className="font-mono text-xs truncate">
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)}
</span>
</div>
@@ -540,21 +518,17 @@ export function ContactInfoPane({
</div>
)}
{!isRepeater && (
<>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<ActivityChartsSection analytics={analytics} />
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</>
)}
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -852,60 +826,6 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
);
}
function NearbyRepeatersSection({
contact,
contacts,
distanceUnit,
}: {
contact: Contact;
contacts: Contact[];
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
}) {
const nearby = useMemo(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
for (const other of contacts) {
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
if (
other.public_key === contact.public_key ||
other.type !== CONTACT_TYPE_REPEATER ||
!isValidLocation(other.lat, other.lon) ||
heardAt < sevenDaysAgo
) {
continue;
}
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
if (dist !== null) {
results.push({
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
publicKey: other.public_key,
distance: dist,
});
}
}
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5);
}, [contact.public_key, contact.lat, contact.lon, contacts]);
if (nearby.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Geo (last 7 days)</SectionLabel>
<div className="space-y-1">
{nearby.map((r) => (
<div key={r.publicKey} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistance(r.distance, distanceUnit)}
</span>
</div>
))}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>
@@ -233,7 +233,6 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
/>
</Suspense>
);
+21 -35
View File
@@ -406,12 +406,9 @@ interface HopNodeProps {
distanceUnit: DistanceUnit;
}
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
const [expanded, setExpanded] = useState(false);
// Calculate distance from previous location for a contact
// Returns null if prev location unknown/ambiguous or contact has no valid location
@@ -450,38 +447,27 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
<div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div>
) : isAmbiguous ? (
<div>
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
(contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
}
)}
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
<button
type="button"
className="text-xs text-primary hover:underline cursor-pointer"
onClick={() => setExpanded(true)}
>
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
</button>
)}
{hop.matches.map((contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
})}
</div>
) : (
<div className="font-medium truncate">
+4 -21
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
@@ -45,7 +45,6 @@ interface RepeaterDashboardProps {
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
}
export function RepeaterDashboard({
@@ -63,7 +62,6 @@ export function RepeaterDashboard({
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
onOpenContactInfo,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -117,24 +115,9 @@ export function RepeaterDashboard({
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<h2 className="min-w-0 flex-shrink font-semibold text-base">
{onOpenContactInfo ? (
<button
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={() => onOpenContactInfo(conversation.id)}
>
<span className="truncate">{conversation.name}</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">{conversation.name}</span>
)}
</h2>
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
{conversation.name}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
@@ -2,7 +2,6 @@ import { useState, useEffect, type ReactNode } from 'react';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
RadioAdvertMode,
RadioConfig,
@@ -48,8 +47,6 @@ interface SettingsModalBaseProps {
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -83,8 +80,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames,
onToggleBlockedKey,
onToggleBlockedName,
contacts,
onBulkDeleteContacts,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -244,8 +239,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
className={sectionContentClass}
/>
) : (
+31 -36
View File
@@ -110,8 +110,6 @@ interface SidebarProps {
/** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
}
type InitialSectionSortState = {
@@ -155,16 +153,7 @@ export function Sidebar({
favorites,
legacySortOrder,
isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
}: SidebarProps) {
const isContactBlocked = useCallback(
(c: Contact) =>
blockedKeys.includes(c.public_key.toLowerCase()) ||
(c.name != null && blockedNames.includes(c.name)),
[blockedKeys, blockedNames]
);
const [searchQuery, setSearchQuery] = useState('');
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
@@ -409,32 +398,38 @@ export function Sidebar({
[sortedChannels, query]
);
const filteredNonRepeaterContacts = useMemo(() => {
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
const filteredNonRepeaterContacts = useMemo(
() =>
query
? sortedNonRepeaterContacts.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedNonRepeaterContacts,
[sortedNonRepeaterContacts, query]
);
const filteredRooms = useMemo(() => {
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRooms, query, isContactBlocked]);
const filteredRooms = useMemo(
() =>
query
? sortedRooms.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRooms,
[sortedRooms, query]
);
const filteredRepeaters = useMemo(() => {
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRepeaters, query, isContactBlocked]);
const filteredRepeaters = useMemo(
() =>
query
? sortedRepeaters.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRepeaters,
[sortedRepeaters, query]
);
// Expand sections while searching; restore prior collapse state when search ends.
useEffect(() => {
@@ -865,7 +860,7 @@ export function Sidebar({
onClick={onNewMessage}
title="Add channel or contact"
aria-label="Add channel or contact"
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary"
className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
>
<SquarePen className="h-4 w-4" />
<span>Add Channel/Contact</span>
@@ -1,352 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { api } from '../../api';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { toast } from '../ui/sonner';
import type { Contact } from '../../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
1: 'Client',
2: 'Repeater',
3: 'Room',
4: 'Sensor',
};
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateISO(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function datetimeToUnix(datetimeStr: string): number {
const d = new Date(datetimeStr);
return Math.floor(d.getTime() / 1000);
}
interface BulkDeleteContactsModalProps {
open: boolean;
onClose: () => void;
contacts: Contact[];
onDeleted: (deletedKeys: string[]) => void;
}
export function BulkDeleteContactsModal({
open,
onClose,
contacts,
onDeleted,
}: BulkDeleteContactsModalProps) {
const [step, setStep] = useState<'select' | 'confirm'>('select');
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
const [deleting, setDeleting] = useState(false);
const lastClickedKeyRef = useRef<string | null>(null);
const resetAndClose = useCallback(() => {
setStep('select');
setSelectedKeys(new Set());
setStartDate('');
setEndDate('');
setTypeFilter('all');
lastClickedKeyRef.current = null;
onClose();
}, [onClose]);
const filteredContacts = useMemo(() => {
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
if (typeFilter !== 'all') {
list = list.filter((c) => c.type === typeFilter);
}
if (startDate) {
const start = datetimeToUnix(startDate);
list = list.filter((c) => (c.first_seen ?? 0) >= start);
}
if (endDate) {
const end = datetimeToUnix(endDate);
list = list.filter((c) => (c.first_seen ?? 0) <= end);
}
return list;
}, [contacts, typeFilter, startDate, endDate]);
const handleToggle = (key: string, shiftKey: boolean) => {
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
const keys = filteredContacts.map((c) => c.public_key);
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
const curIdx = keys.indexOf(key);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
const rangeKeys = keys.slice(from, to + 1);
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const k of rangeKeys) next.add(k);
return next;
});
lastClickedKeyRef.current = key;
return;
}
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
lastClickedKeyRef.current = key;
};
const handleSelectAll = () => {
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
};
const handleSelectNone = () => {
setSelectedKeys(new Set());
};
const selectedContacts = useMemo(
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
[contacts, selectedKeys]
);
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
const minDate =
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
const maxDate =
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
const handleDelete = async () => {
setDeleting(true);
try {
const keysToDelete = [...selectedKeys];
const result = await api.bulkDeleteContacts(keysToDelete);
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
onDeleted(keysToDelete);
resetAndClose();
} catch (err) {
console.error('Bulk delete failed:', err);
toast.error('Bulk delete failed', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
</DialogTitle>
<DialogDescription>
{step === 'select'
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
: 'Review the contacts that will be permanently deleted.'}
</DialogDescription>
</DialogHeader>
{step === 'select' && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
Select all
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
Select none
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
{(startDate || endDate) && ' (filtered)'}
{' · '}
{selectedKeys.size} selected
</div>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
{filteredContacts.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No contacts match the selected date range.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5 w-8" />
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{filteredContacts.map((c) => (
<tr
key={c.public_key}
className="border-t border-border hover:bg-accent/50 cursor-pointer"
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
>
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={selectedKeys.has(c.public_key)}
onChange={(e) =>
handleToggle(
c.public_key,
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
)
}
onClick={(e) => e.stopPropagation()}
className="rounded border-input"
/>
</td>
<td className="px-3 py-1.5 truncate max-w-[10rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={resetAndClose}>
Cancel
</Button>
<Button
variant="outline"
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
disabled={selectedKeys.size === 0}
onClick={() => setStep('confirm')}
>
Proceed to confirmation ({selectedKeys.size})
</Button>
</div>
</>
)}
{step === 'confirm' && (
<>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{selectedContacts.map((c) => (
<tr key={c.public_key} className="border-t border-border">
<td className="px-3 py-1.5 truncate max-w-[12rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
variant="destructive"
className="w-full h-auto py-3 text-wrap"
disabled={deleting}
onClick={handleDelete}
>
{deleting
? 'Deleting...'
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
repeaterCount > 0 &&
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
]
.filter(Boolean)
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
</Button>
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
Back
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -6,8 +6,7 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsDatabaseSection({
appSettings,
@@ -18,8 +17,6 @@ export function SettingsDatabaseSection({
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
className,
}: {
appSettings: AppSettings;
@@ -30,23 +27,18 @@ export function SettingsDatabaseSection({
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
className?: string;
}) {
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]);
const handleCleanup = async () => {
@@ -100,15 +92,7 @@ export function SettingsDatabaseSection({
setError(null);
try {
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
const currentBlocked = appSettings.discovery_blocked_types ?? [];
if (
discoveryBlockedTypes.length !== currentBlocked.length ||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
) {
update.discovery_blocked_types = discoveryBlockedTypes;
}
await onSaveAppSettings(update);
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
toast.success('Database settings saved');
} catch (err) {
console.error('Failed to save database settings:', err);
@@ -121,93 +105,93 @@ export function SettingsDatabaseSection({
return (
<div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3">
<Label className="text-base">Database Overview</Label>
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">Database size</span>
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Oldest undecrypted packet</span>
{health?.oldest_undecrypted_timestamp ? (
<span className="text-sm font-semibold">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="font-normal text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days)
</span>
</span>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div>
<Separator />
{/* ── Storage Cleanup ── */}
<div className="space-y-4">
<Label className="text-base">Storage Cleanup</Label>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets that have not yet been decrypted. These are
retained in case you later obtain the correct key once deleted, these messages can
never be recovered.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{cleaning ? 'Deleting...' : 'Delete'}
</Button>
<div className="space-y-3">
<Label>Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets containing DMs and channel messages that have not
yet been decrypted. These packets are retained in case you later obtain the correct key
once deleted, these messages can never be recovered or decrypted.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes the raw packet bytes behind messages that are already decrypted and visible in
chat. This frees space but removes packet-analysis availability for those messages. It
does not affect displayed messages or future decryption.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
{cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button>
</div>
</div>
<Separator />
{/* ── DM Decryption ── */}
<div className="space-y-3">
<Label className="text-base">DM Decryption</Label>
<Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
@@ -223,87 +207,17 @@ export function SettingsDatabaseSection({
</p>
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-2">
<Label className="text-base">Contact Management</Label>
</div>
{/* Block discovery of new node types */}
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() =>
setDiscoveryBlockedTypes((prev) =>
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
)
}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
affected. Messages are still stored and will reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
@@ -354,25 +268,15 @@ export function SettingsDatabaseSection({
)}
</div>
<Separator />
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
{/* Bulk delete */}
<div className="space-y-3">
<Label>Bulk Delete Contacts</Label>
<p className="text-xs text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
</div>
);
}
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
return (
<div className={className}>
{/* ── Connection ── */}
{/* Connection display */}
<div className="space-y-3">
<Label className="text-base">Connection</Label>
<Label>Connection</Label>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
@@ -428,58 +428,15 @@ export function SettingsRadioSection({
</p>
</div>
<Separator />
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
</div>
{/* Radio Name */}
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* ── Radio Parameters ── */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
</div>
{/* Radio Config */}
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
@@ -561,36 +518,11 @@ export function SettingsRadioSection({
</div>
</div>
{config.path_hash_mode_supported && (
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
)}
<Separator />
{/* ── Location ── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base">Location</Label>
<Label>Location</Label>
<Button
type="button"
variant="outline"
@@ -653,8 +585,53 @@ export function SettingsRadioSection({
library.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal
ACK for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
</div>
</div>
{config.path_hash_mode_supported && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
</>
)}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
@@ -680,28 +657,64 @@ export function SettingsRadioSection({
<Separator />
{/* ── Messaging ── */}
{/* Keys */}
<div className="space-y-2">
<Label className="text-base">Messaging</Label>
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
@@ -733,13 +746,6 @@ export function SettingsRadioSection({
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
{health?.radio_device_info?.max_contacts != null &&
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
<p className="text-xs text-warning">
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
contacts. The effective cap will be limited to what the radio supports.
</p>
)}
</div>
{floodError && (
@@ -754,28 +760,8 @@ export function SettingsRadioSection({
<Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2">
<Label className="text-base">Advertising &amp; Discovery</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
<Label className="text-base">Hear &amp; Be Heard</Label>
</div>
<div className="space-y-2">
+2 -3
View File
@@ -69,7 +69,6 @@ const baseSettings: AppSettings = {
flood_scope: '',
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
};
function renderModal(overrides?: {
@@ -616,10 +615,10 @@ describe('SettingsModal', () => {
openDatabaseSection();
expect(
screen.getByText(/removes packet-analysis availability for those messages/i)
screen.getByText(/remove packet-analysis availability for those historical messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
await waitFor(() => {
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
-2
View File
@@ -332,7 +332,6 @@ export interface AppSettings {
flood_scope: string;
blocked_keys: string[];
blocked_names: string[];
discovery_blocked_types: number[];
}
export interface AppSettingsUpdate {
@@ -343,7 +342,6 @@ export interface AppSettingsUpdate {
flood_scope?: string;
blocked_keys?: string[];
blocked_names?: string[];
discovery_blocked_types?: number[];
}
export interface MigratePreferencesRequest {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.6.7"
version = "3.6.3"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/create_github_release.sh --version X.Y.Z --asset PATH [options]
Options:
--version VERSION Release version / tag (required)
--asset PATH Asset to attach; may be specified multiple times
--notes-file PATH Markdown release notes file; defaults to CHANGELOG section
--full-git-hash HASH Commit to tag if the tag does not already exist locally
--title TITLE Release title (default: version)
--help Show this message
EOF
}
VERSION=""
TITLE=""
NOTES_FILE=""
FULL_GIT_HASH=""
ASSETS=()
TEMP_NOTES_FILE=""
cleanup() {
if [ -n "$TEMP_NOTES_FILE" ] && [ -f "$TEMP_NOTES_FILE" ]; then
rm -f "$TEMP_NOTES_FILE"
fi
}
trap cleanup EXIT
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--asset)
ASSETS+=("${2:-}")
shift 2
;;
--notes-file)
NOTES_FILE="${2:-}"
shift 2
;;
--full-git-hash)
FULL_GIT_HASH="${2:-}"
shift 2
;;
--title)
TITLE="${2:-}"
shift 2
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
[ "${#ASSETS[@]}" -gt 0 ] || release_die "At least one --asset is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
TITLE="${TITLE:-$VERSION}"
FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}"
for asset in "${ASSETS[@]}"; do
[ -f "$asset" ] || release_die "Asset not found: $asset"
done
if [ -z "$NOTES_FILE" ]; then
TEMP_NOTES_FILE="$(mktemp)"
release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$TEMP_NOTES_FILE"
NOTES_FILE="$TEMP_NOTES_FILE"
fi
[ -f "$NOTES_FILE" ] || release_die "Notes file not found: $NOTES_FILE"
if ! git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "[create_github_release] Creating local tag $VERSION at $FULL_GIT_HASH..." >&2
git -C "$REPO_ROOT" tag -a "$VERSION" "$FULL_GIT_HASH" -F "$NOTES_FILE"
fi
if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
echo "[create_github_release] Pushing tag $VERSION to origin..." >&2
git -C "$REPO_ROOT" push origin "$VERSION"
fi
if gh release view "$VERSION" >/dev/null 2>&1; then
echo "[create_github_release] Updating existing GitHub release $VERSION..." >&2
gh release upload "$VERSION" "${ASSETS[@]}" --clobber
gh release edit "$VERSION" --title "$TITLE" --notes-file "$NOTES_FILE"
else
echo "[create_github_release] Creating GitHub release $VERSION..." >&2
gh release create "$VERSION" "${ASSETS[@]}" --title "$TITLE" --notes-file "$NOTES_FILE" --verify-tag
fi
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/extract_release_notes.sh --version X.Y.Z --output PATH
Options:
--version VERSION Release version to extract from CHANGELOG.md
--output PATH Output markdown file path
--changelog PATH Override changelog path
--help Show this message
EOF
}
VERSION=""
OUTPUT_FILE=""
CHANGELOG_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--output)
OUTPUT_FILE="${2:-}"
shift 2
;;
--changelog)
CHANGELOG_PATH="${2:-}"
shift 2
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
[ -n "$OUTPUT_FILE" ] || release_die "--output is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$OUTPUT_FILE" "${CHANGELOG_PATH:-$REPO_ROOT/CHANGELOG.md}"
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/package_release_artifact.sh --version X.Y.Z [options]
Options:
--version VERSION Release version (required)
--git-hash HASH Short git hash to embed in artifact naming
--full-git-hash HASH Full git hash to archive
--output PATH Output zip path
--bundle-name NAME Bundle folder name inside the zip
--skip-prebuilt-build Reuse existing frontend/prebuilt instead of rebuilding it
--help Show this message
EOF
}
VERSION=""
GIT_HASH=""
FULL_GIT_HASH=""
OUTPUT_PATH=""
BUNDLE_NAME="Remote-Terminal-for-MeshCore"
SKIP_PREBUILT_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--git-hash)
GIT_HASH="${2:-}"
shift 2
;;
--full-git-hash)
FULL_GIT_HASH="${2:-}"
shift 2
;;
--output)
OUTPUT_PATH="${2:-}"
shift 2
;;
--bundle-name)
BUNDLE_NAME="${2:-}"
shift 2
;;
--skip-prebuilt-build)
SKIP_PREBUILT_BUILD=1
shift
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}"
GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT" "$FULL_GIT_HASH")}"
OUTPUT_PATH="${OUTPUT_PATH:-$REPO_ROOT/remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip}"
WORK_DIR="$(mktemp -d)"
BUNDLE_DIR="$WORK_DIR/$BUNDLE_NAME"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
if [ "$SKIP_PREBUILT_BUILD" -eq 0 ]; then
echo "[package_release_artifact] Building frontend prebuilt bundle..." >&2
(
cd "$REPO_ROOT/frontend"
npm run packaged-build
)
fi
[ -d "$REPO_ROOT/frontend/prebuilt" ] || release_die "frontend/prebuilt is missing; run with frontend built or omit --skip-prebuilt-build"
mkdir -p "$BUNDLE_DIR/frontend"
git -C "$REPO_ROOT" archive "$FULL_GIT_HASH" | tar -x -C "$BUNDLE_DIR"
cp -R "$REPO_ROOT/frontend/prebuilt" "$BUNDLE_DIR/frontend/prebuilt"
cat > "$BUNDLE_DIR/build_info.json" <<EOF
{
"version": "$VERSION",
"commit_hash": "$GIT_HASH",
"build_source": "prebuilt-release"
}
EOF
rm -f "$OUTPUT_PATH"
(
cd "$WORK_DIR"
zip -qr "$OUTPUT_PATH" "$BUNDLE_NAME"
)
echo "$OUTPUT_PATH"
+155 -93
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
set -e
# Colors for output
RED='\033[0;31m'
@@ -10,63 +10,95 @@ NC='\033[0m' # No Color
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
DOCKER_IMAGE="docker.io/jkingsman/remoteterm-meshcore"
RELEASE_WORK_DIR=""
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
RELEASE_ASSET=""
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
VERSION=""
NOTES_FILE=""
SKIP_QUALITY=0
RELEASE_ASSET_PATH=""
usage() {
cat <<'EOF'
Usage: scripts/build/publish.sh [options]
Options:
--version VERSION Release version; prompts if omitted
--notes-file PATH File containing changelog entry lines; prompts if omitted
--skip-quality Skip ./scripts/quality/all_quality.sh
--help Show this message
EOF
cleanup_release_build_artifacts() {
if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then
rm -rf "$REPO_ROOT/frontend/prebuilt"
fi
if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
rm -rf "$RELEASE_WORK_DIR"
fi
if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then
rm -f "$REPO_ROOT/$RELEASE_ASSET"
fi
}
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--notes-file)
NOTES_FILE="${2:-}"
shift 2
;;
--skip-quality)
SKIP_QUALITY=1
shift
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
trap cleanup_release_build_artifacts EXIT
ensure_buildx_builder() {
if ! docker buildx version >/dev/null 2>&1; then
echo -e "${RED}Error: docker buildx is required for multi-arch Docker builds.${NC}"
exit 1
fi
local current_builder
current_builder="$(docker buildx inspect --format '{{ .Name }}' 2>/dev/null || true)"
if [ -n "$current_builder" ]; then
docker buildx inspect --bootstrap >/dev/null
return
fi
if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then
docker buildx use remoteterm-multiarch >/dev/null
else
docker buildx create --name remoteterm-multiarch --use >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
}
echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}"
echo
if [ "$SKIP_QUALITY" -eq 0 ]; then
echo -e "${YELLOW}Running repo quality gate...${NC}"
./scripts/quality/all_quality.sh
echo -e "${GREEN}Quality gate passed!${NC}"
echo
fi
# Run backend linting and type checking
echo -e "${YELLOW}Running backend lint (Ruff)...${NC}"
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
# validate
uv run ruff check app/ tests/
uv run ruff format --check app/ tests/
echo -e "${GREEN}Backend lint passed!${NC}"
echo
echo -e "${YELLOW}Running backend type check (Pyright)...${NC}"
uv run pyright app/
echo -e "${GREEN}Backend type check passed!${NC}"
echo
# Run backend tests
echo -e "${YELLOW}Running backend tests...${NC}"
PYTHONPATH=. uv run pytest tests/ -v
echo -e "${GREEN}Backend tests passed!${NC}"
echo
# Run frontend linting and formatting check
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
cd "$REPO_ROOT/frontend"
npm run lint
echo -e "${GREEN}Frontend lint passed!${NC}"
echo
echo -e "${YELLOW}Checking frontend formatting (Prettier)...${NC}"
npm run format:check
echo -e "${GREEN}Frontend formatting OK!${NC}"
echo
# Run frontend tests and build
echo -e "${YELLOW}Running frontend tests...${NC}"
npm run test:run
echo -e "${GREEN}Frontend tests passed!${NC}"
echo
echo -e "${YELLOW}Building frontend...${NC}"
npm run build
echo -e "${GREEN}Frontend build complete!${NC}"
cd "$REPO_ROOT"
echo
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
bash scripts/build/collect_licenses.sh LICENSES.md
@@ -81,11 +113,13 @@ echo -n " package.json: "
grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
echo
if [ -z "$VERSION" ]; then
read -r -p "Enter new version (e.g., 1.2.3): " VERSION
read -r -p "Enter new version (e.g., 1.2.3): " VERSION
VERSION="$(printf '%s' "$VERSION" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Error: Version must be in format X.Y.Z${NC}"
exit 1
fi
VERSION="$(release_trim "$VERSION")"
release_validate_version "$VERSION"
# Update pyproject.toml
echo -e "${YELLOW}Updating pyproject.toml...${NC}"
@@ -103,28 +137,11 @@ echo -e "${GREEN}Version updated to $VERSION${NC}"
echo
# Prompt for changelog entry
RAW_CHANGELOG_INPUT_FILE="$(mktemp)"
FORMATTED_CHANGELOG_INPUT_FILE="$(mktemp)"
cleanup() {
rm -f "$RAW_CHANGELOG_INPUT_FILE" "$FORMATTED_CHANGELOG_INPUT_FILE"
rm -rf "${REPO_ROOT:?}/frontend/prebuilt"
if [ -n "$RELEASE_ASSET_PATH" ] && [ -f "$RELEASE_ASSET_PATH" ]; then
rm -f "$RELEASE_ASSET_PATH"
fi
}
trap cleanup EXIT
echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}"
echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}"
echo
if [ -n "$NOTES_FILE" ]; then
cp "$NOTES_FILE" "$RAW_CHANGELOG_INPUT_FILE"
else
echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}"
echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}"
echo
cat > "$RAW_CHANGELOG_INPUT_FILE"
fi
release_format_markdown_list "$RAW_CHANGELOG_INPUT_FILE" "$FORMATTED_CHANGELOG_INPUT_FILE"
[ -s "$FORMATTED_CHANGELOG_INPUT_FILE" ] || release_die "Changelog entry cannot be empty"
CHANGELOG_ENTRY=$(cat)
# Create changelog entry with date
DATE=$(date +%Y-%m-%d)
@@ -140,7 +157,7 @@ if [ -f CHANGELOG.md ]; then
echo
echo "$CHANGELOG_HEADER"
echo
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
echo "$CHANGELOG_ENTRY"
echo
tail -n +2 CHANGELOG.md
} > CHANGELOG.md.tmp
@@ -150,7 +167,7 @@ if [ -f CHANGELOG.md ]; then
{
echo "$CHANGELOG_HEADER"
echo
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
echo "$CHANGELOG_ENTRY"
echo
cat CHANGELOG.md
} > CHANGELOG.md.tmp
@@ -163,7 +180,7 @@ else
echo
echo "$CHANGELOG_HEADER"
echo
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
echo "$CHANGELOG_ENTRY"
} > CHANGELOG.md
fi
@@ -183,33 +200,78 @@ echo
GIT_HASH=$(git rev-parse --short HEAD)
FULL_GIT_HASH=$(git rev-parse HEAD)
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
RELEASE_ASSET_PATH="$REPO_ROOT/$RELEASE_ASSET"
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
scripts/build/package_release_artifact.sh \
--version "$VERSION" \
--git-hash "$GIT_HASH" \
--full-git-hash "$FULL_GIT_HASH" \
--output "$RELEASE_ASSET_PATH"
cd "$REPO_ROOT/frontend"
npm run packaged-build
cd "$REPO_ROOT"
RELEASE_WORK_DIR=$(mktemp -d)
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
mkdir -p "$RELEASE_BUNDLE_DIR"
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
{
"version": "$VERSION",
"commit_hash": "$GIT_HASH",
"build_source": "prebuilt-release"
}
EOF
rm -f "$REPO_ROOT/$RELEASE_ASSET"
(
cd "$RELEASE_WORK_DIR"
zip -qr "$REPO_ROOT/$RELEASE_ASSET" "$(basename "$RELEASE_BUNDLE_DIR")"
)
echo -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}"
echo
# Build and push multi-arch docker image
echo -e "${YELLOW}Building and pushing multi-arch Docker image...${NC}"
scripts/build/push_docker_multiarch.sh \
--version "$VERSION" \
--git-hash "$GIT_HASH" \
--image "$DOCKER_IMAGE" \
--platforms "$DOCKER_PLATFORMS"
ensure_buildx_builder
docker buildx build \
--platform "$DOCKER_PLATFORMS" \
--build-arg COMMIT_HASH="$GIT_HASH" \
-t "$DOCKER_IMAGE:latest" \
-t "$DOCKER_IMAGE:$VERSION" \
-t "$DOCKER_IMAGE:$GIT_HASH" \
--push \
.
echo -e "${GREEN}Multi-arch Docker build + push complete!${NC}"
echo
# Create GitHub release using the changelog notes for this version.
echo -e "${YELLOW}Creating GitHub release...${NC}"
scripts/build/create_github_release.sh \
--version "$VERSION" \
--full-git-hash "$FULL_GIT_HASH" \
--asset "$RELEASE_ASSET_PATH"
RELEASE_NOTES_FILE=$(mktemp)
{
echo "$CHANGELOG_HEADER"
echo
echo "$CHANGELOG_ENTRY"
} > "$RELEASE_NOTES_FILE"
# Create and push the release tag first so GitHub release creation does not
# depend on resolving a symbolic ref like HEAD on the remote side. Use the same
# changelog-derived notes for the annotated tag message.
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo -e "${YELLOW}Tag $VERSION already exists locally; reusing it.${NC}"
else
git tag -a "$VERSION" "$FULL_GIT_HASH" -F "$RELEASE_NOTES_FILE"
fi
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
echo -e "${YELLOW}Tag $VERSION already exists on origin; not pushing it again.${NC}"
else
git push origin "$VERSION"
fi
gh release create "$VERSION" \
"$RELEASE_ASSET" \
--title "$VERSION" \
--notes-file "$RELEASE_NOTES_FILE" \
--verify-tag
rm -f "$RELEASE_NOTES_FILE"
echo -e "${GREEN}GitHub release created!${NC}"
echo
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/push_docker_multiarch.sh --version X.Y.Z [options]
Options:
--version VERSION Release version (required)
--git-hash HASH Short git hash to tag alongside the version
--image IMAGE Docker image name (default: docker.io/jkingsman/remoteterm-meshcore)
--platforms CSV Buildx platforms CSV (default: linux/amd64,linux/arm64)
--help Show this message
EOF
}
VERSION=""
GIT_HASH=""
IMAGE="docker.io/jkingsman/remoteterm-meshcore"
PLATFORMS="linux/amd64,linux/arm64"
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--git-hash)
GIT_HASH="${2:-}"
shift 2
;;
--image)
IMAGE="${2:-}"
shift 2
;;
--platforms)
PLATFORMS="${2:-}"
shift 2
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT")}"
echo "[push_docker_multiarch] Ensuring docker buildx builder..." >&2
release_ensure_buildx_builder
docker_buildx_args=(
build
--platform "$PLATFORMS"
--build-arg "COMMIT_HASH=$GIT_HASH"
-t "$IMAGE:latest"
-t "$IMAGE:$VERSION"
-t "$IMAGE:$GIT_HASH"
--push
.
)
echo "[push_docker_multiarch] Building and pushing $IMAGE for $PLATFORMS..." >&2
(
cd "$REPO_ROOT"
docker buildx "${docker_buildx_args[@]}"
)
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env bash
release_repo_root() {
(
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
)
}
release_die() {
echo "Error: $*" >&2
exit 1
}
release_trim() {
printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
release_validate_version() {
local version="$1"
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || release_die "Version must be in format X.Y.Z"
}
release_resolve_full_hash() {
local repo_root="$1"
local ref="${2:-HEAD}"
git -C "$repo_root" rev-parse "$ref"
}
release_resolve_short_hash() {
local repo_root="$1"
local ref="${2:-HEAD}"
git -C "$repo_root" rev-parse --short "$ref"
}
release_format_markdown_list() {
local input_file="$1"
local output_file="$2"
awk '
/^[[:space:]]*$/ { next }
{
sub(/^[[:space:]]+/, "", $0)
if ($0 ~ /^\* /) {
print
} else if ($0 ~ /^- /) {
sub(/^- /, "* ", $0)
print
} else {
print "* " $0
}
}
' "$input_file" > "$output_file"
}
release_extract_changelog_section() {
local repo_root="$1"
local version="$2"
local output_file="$3"
local changelog_path="${4:-$repo_root/CHANGELOG.md}"
# Use index() for literal matching so dots in version strings are not
# treated as regex wildcards (e.g. 3.6.5 won't match 31615).
awk -v ver="$version" '
BEGIN { header = "## [" ver "]" }
index($0, header) == 1 { capture = 1; print; next }
capture && /^## \[/ { exit }
capture { print }
' "$changelog_path" > "$output_file"
[ -s "$output_file" ] || release_die "Could not find CHANGELOG entry for version $version"
}
release_ensure_buildx_builder() {
if ! docker buildx version >/dev/null 2>&1; then
release_die "docker buildx is required for multi-arch Docker builds"
fi
# Multi-platform builds require the docker-container driver. The default
# builder uses the "docker" driver which only supports the host platform.
# Check the current builder's driver first; only create a new one if needed.
local current_driver
current_driver="$(docker buildx inspect --format '{{ .Driver }}' 2>/dev/null || true)"
if [ "$current_driver" = "docker-container" ]; then
docker buildx inspect --bootstrap >/dev/null
return
fi
if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then
docker buildx use remoteterm-multiarch >/dev/null
else
docker buildx create --name remoteterm-multiarch --use >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
}
+1 -1
View File
@@ -26,7 +26,7 @@ echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
echo -ne "${BLUE}[backend lint]${NC} "
cd "$REPO_ROOT"
uv run ruff check app/ tests/ --fix --quiet
uv run ruff format app/ tests/ --quiet
uv run ruff format app/ tests/ --check --quiet
echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[frontend lint]${NC} "
+76 -22
View File
@@ -22,12 +22,15 @@ REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
COMPOSE_FILE="$REPO_DIR/docker-compose.yml"
EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml"
SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs"
NGINX_CONFIG_DIR="$REPO_DIR/.docker-nginx"
NGINX_CONFIG_BASENAME="remoteterm.conf"
NGINX_CONFIG_HOST_PATH="$NGINX_CONFIG_DIR/$NGINX_CONFIG_BASENAME"
SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt"
SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key"
SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME"
SNAKEOIL_CERT_CONTAINER_PATH="/app/certs/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_CONTAINER_PATH="/app/certs/$SNAKEOIL_KEY_BASENAME"
SNAKEOIL_CERT_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_KEY_BASENAME"
IMAGE_MODE="image"
TRANSPORT_MODE="serial"
@@ -211,6 +214,49 @@ EOF
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
}
generate_nginx_tls_config() {
mkdir -p "$NGINX_CONFIG_DIR"
cat >"$NGINX_CONFIG_HOST_PATH" <<EOF
server {
listen 80;
server_name _;
return 308 https://\$host:8000\$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate $SNAKEOIL_CERT_CONTAINER_PATH;
ssl_certificate_key $SNAKEOIL_KEY_CONTAINER_PATH;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location /api/ws {
proxy_pass http://remoteterm:8000/api/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
}
location / {
proxy_pass http://remoteterm:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
proxy_read_timeout 300s;
}
}
EOF
}
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
echo
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
@@ -400,7 +446,9 @@ LOCAL_ACCESS_IP="$(detect_primary_local_ip)"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
ensure_snakeoil_requirements
generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
generate_nginx_tls_config
echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}"
echo -e "${GREEN}Generated nginx TLS proxy config in ${NGINX_CONFIG_DIR}.${NC}"
echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}"
else
echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}"
@@ -441,32 +489,19 @@ mkdir -p "$REPO_DIR/data"
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
echo " user: \"$(id -u):$(id -g)\""
fi
echo " ports:"
echo " - \"8000:8000\""
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " expose:"
echo " - \"8000\""
else
echo " ports:"
echo " - \"8000:8000\""
fi
echo " volumes:"
echo " - ./data:/app/data"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " - ./.docker-certs:/app/certs:ro"
fi
if [ "$TRANSPORT_MODE" = "serial" ]; then
echo " devices:"
echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
fi
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " command:"
echo " - uv"
echo " - run"
echo " - uvicorn"
echo " - app.main:app"
echo " - --host"
echo " - 0.0.0.0"
echo " - --port"
echo " - \"8000\""
echo " - --ssl-keyfile"
echo " - $SNAKEOIL_KEY_CONTAINER_PATH"
echo " - --ssl-certfile"
echo " - $SNAKEOIL_CERT_CONTAINER_PATH"
fi
echo " environment:"
echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")"
if [ "$TRANSPORT_MODE" = "serial" ]; then
@@ -486,6 +521,19 @@ mkdir -p "$REPO_DIR/data"
echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")"
fi
echo " restart: unless-stopped"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " nginx:"
echo " image: nginx:alpine"
echo " depends_on:"
echo " - remoteterm"
echo " ports:"
echo " - \"80:80\""
echo " - \"8000:443\""
echo " volumes:"
echo " - ./.docker-certs:/etc/nginx/certs:ro"
echo " - ./.docker-nginx/$NGINX_CONFIG_BASENAME:/etc/nginx/conf.d/default.conf:ro"
echo " restart: unless-stopped"
fi
} >"$COMPOSE_FILE"
echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
@@ -504,6 +552,11 @@ echo " sudo docker compose pull && sudo docker compose up -d # upgrade to the
echo
echo -e "${YELLOW}Note:${NC} serial passthrough generally needs ${BOLD}rootful Docker${NC}."
echo "If Docker is running rootless on this host, serial-device mappings may fail even with a valid compose file."
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo
echo -e "${GREEN}HTTPS will be handled by an nginx sidecar.${NC}"
echo "Host port 80 will redirect to HTTPS on port 8000."
fi
if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
echo
echo -e "${RED}BLE requires more than the generated env vars.${NC}"
@@ -519,6 +572,7 @@ echo -e "${PURPLE}└───────────────────
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo
echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
echo -e "Plain HTTP on ${CYAN}http://${LOCAL_ACCESS_IP}${NC} will redirect there automatically."
echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
else
echo
+1 -1
View File
@@ -19,7 +19,7 @@ test.describe('Create contact flow', () => {
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open new message modal
await page.getByRole('button', { name: /add channel or contact/i }).click();
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
+2 -2
View File
@@ -26,7 +26,7 @@ test.describe('Create hashtag channel flow', () => {
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open new message modal
await page.getByRole('button', { name: /add channel or contact/i }).click();
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
@@ -49,7 +49,7 @@ test.describe('Create hashtag channel flow', () => {
await page.goto('/');
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
await page.getByRole('button', { name: /add channel or contact/i }).click();
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
@@ -37,7 +37,7 @@ test.describe('Historical packet decryption', () => {
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open new message modal → Hashtag tab
await page.getByRole('button', { name: /add channel or contact/i }).click();
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: /Hashtag/i }).click();
+5 -150
View File
@@ -131,62 +131,6 @@ class TestHealthEndpoint:
class TestDebugEndpoint:
"""Test the debug support snapshot endpoint."""
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
"""Debug radio probe should redact advertised lat/lon from self_info."""
from app.routers.debug import _sanitize_radio_probe_self_info
sanitized = _sanitize_radio_probe_self_info(
{
"name": "FlightlessTestNode",
"adv_lat": 47.786445,
"adv_lon": -122.344011,
"radio_freq": 910.525,
}
)
assert sanitized == {
"name": "FlightlessTestNode",
"radio_freq": 910.525,
}
def test_support_snapshot_only_keeps_erroring_fanouts_in_health_summary(self):
"""Debug health summary should only include fanouts with non-empty last_error."""
from app.routers.debug import _build_debug_health_summary
from app.routers.health import FanoutStatusResponse
summary = _build_debug_health_summary(
{
"database_size_mb": 1.23,
"oldest_undecrypted_timestamp": 123,
"fanout_statuses": {
"ok-id": {
"name": "OK Fanout",
"type": "bot",
"status": "connected",
"last_error": None,
},
"err-id": {
"name": "Broken Fanout",
"type": "mqtt_private",
"status": "error",
"last_error": "broker down",
},
},
"bots_disabled_source": None,
"basic_auth_enabled": False,
},
radio_state="connected",
)
assert summary.fanouts_with_errors == {
"err-id": FanoutStatusResponse(
name="Broken Fanout",
type="mqtt_private",
status="error",
last_error="broker down",
)
}
@pytest.mark.asyncio
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
"""Debug snapshot should still return logs and runtime state when radio is disconnected."""
@@ -213,21 +157,8 @@ class TestDebugEndpoint:
assert response.status_code == 200
payload = response.json()
assert "app_info" not in payload["health"]
assert "bots_disabled" not in payload["health"]
assert "connection_info" not in payload["health"]
assert "fanout_statuses" not in payload["health"]
assert "radio_connected" not in payload["health"]
assert "radio_device_info" not in payload["health"]
assert "radio_initializing" not in payload["health"]
assert "status" not in payload["health"]
assert payload["health"]["fanouts_with_errors"] == {}
assert payload["health"]["radio_state"] == "disconnected"
assert payload["radio_probe"]["performed"] is False
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
assert "multi_acks_enabled" not in payload["radio_probe"]
assert "max_channels" not in payload["runtime"]
assert "path_hash_mode" not in payload["runtime"]
assert payload["runtime"]["channels_with_incoming_messages"] == 0
assert payload["database"]["total_dms"] == 0
assert payload["database"]["total_channel_messages"] == 0
@@ -282,47 +213,6 @@ class TestDebugEndpoint:
assert response.status_code == 200
@pytest.mark.asyncio
async def test_support_snapshot_includes_persisted_app_settings(self, test_db, client):
"""Debug snapshot should expose the stored app settings row."""
pub_key = "ab" * 32
await _insert_contact(pub_key, "Alice")
response = await client.patch(
"/api/settings",
json={
"max_radio_contacts": 321,
"auto_decrypt_dm_on_advert": True,
"sidebar_sort_order": "alpha",
"advert_interval": 7200,
"flood_scope": "US-CA",
"blocked_keys": [pub_key],
"blocked_names": ["Mallory"],
},
)
assert response.status_code == 200
response = await client.post(
"/api/settings/favorites/toggle",
json={"type": "contact", "id": pub_key},
)
assert response.status_code == 200
response = await client.get("/api/debug")
assert response.status_code == 200
payload = response.json()
assert payload["settings"]["max_radio_contacts"] == 321
assert payload["settings"]["auto_decrypt_dm_on_advert"] is True
assert payload["settings"]["advert_interval"] == 7200
assert payload["settings"]["flood_scope"] == "#US-CA"
assert payload["settings"]["blocked_keys_count"] == 1
assert payload["settings"]["blocked_names_count"] == 1
assert "favorites" not in payload["settings"]
assert "blocked_keys" not in payload["settings"]
assert "blocked_names" not in payload["settings"]
assert "sidebar_sort_order" not in payload["settings"]
class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503."""
@@ -1167,14 +1057,7 @@ class TestRawPacketRepository:
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
# Insert old but decrypted packet (should NOT be deleted)
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
msg_id = await MessageRepository.create(
msg_type="PRIV",
conversation_key="test_key",
text="test",
sender_timestamp=old_timestamp,
received_at=old_timestamp,
)
await RawPacketRepository.mark_decrypted(old_id, msg_id)
await RawPacketRepository.mark_decrypted(old_id, 1)
# Prune packets older than 10 days
deleted = await RawPacketRepository.prune_old_undecrypted(10)
@@ -1198,24 +1081,10 @@ class TestRawPacketRepository:
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
"""Purge linked raw packets removes only rows with a message_id."""
ts = int(time.time())
msg_id_1 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k1",
text="t1",
sender_timestamp=ts,
received_at=ts,
)
msg_id_2 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k2",
text="t2",
sender_timestamp=ts,
received_at=ts,
)
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
await RawPacketRepository.mark_decrypted(linked_1, 101)
await RawPacketRepository.mark_decrypted(linked_2, 102)
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
@@ -1253,24 +1122,10 @@ class TestMaintenanceEndpoint:
from app.routers.packets import MaintenanceRequest, run_maintenance
ts = int(time.time())
msg_id_1 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k1",
text="t1",
sender_timestamp=ts,
received_at=ts,
)
msg_id_2 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k2",
text="t2",
sender_timestamp=ts,
received_at=ts,
)
linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
await RawPacketRepository.mark_decrypted(linked_1, 201)
await RawPacketRepository.mark_decrypted(linked_2, 202)
request = MaintenanceRequest(purge_linked_raw_packets=True)
result = await run_maintenance(request)
+17 -19
View File
@@ -513,9 +513,7 @@ class TestMigration018:
from hashlib import sha256
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
# message_id=42 was orphaned (no matching messages row), so
# migration 49's orphan cleanup NULLs it out.
assert rows[1]["message_id"] is None
assert rows[1]["message_id"] == 42
# Verify payload_hash unique index still works
cursor = await conn.execute(
@@ -1249,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 49
assert applied == 9
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1321,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 49
assert applied == 9
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1388,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 49
assert applied == 3
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1441,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 49
assert applied == 8
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1503,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 49
assert applied == 7
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1556,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 49
assert applied == 6
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1696,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 49
assert applied == 2
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1790,8 +1788,8 @@ class TestMigration047:
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 49
assert applied == 1
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
-15
View File
@@ -381,11 +381,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock,
return_value=[],
),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -459,11 +454,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock,
return_value=[],
) as mock_promote,
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -789,11 +779,6 @@ class TestTracePath:
new_callable=AsyncMock,
return_value=[],
),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
-5
View File
@@ -884,11 +884,6 @@ class TestSyncAndOffloadContacts:
return task
with (
patch(
"app.radio_sync.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
),
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
):
-1
View File
@@ -630,7 +630,6 @@ class TestAppSettingsRepository:
"flood_scope": "",
"blocked_keys": "[]",
"blocked_names": "[]",
"discovery_blocked_types": "[]",
}
)
mock_conn.execute = AsyncMock(return_value=mock_cursor)
Generated
+1 -1
View File
@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.6.7"
version = "3.6.3"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },