mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 12:00:28 +02:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c333eb25e3 | |||
| 580aa1cefd | |||
| 30de09f71b | |||
| 93d31adecd | |||
| 5f969017f7 | |||
| 967dd05fad | |||
| c808f0930b | |||
| 87df4b4aa1 | |||
| 0511d6f69b | |||
| 78b5598f67 | |||
| 5e1bdb2cc1 | |||
| 4420d44838 | |||
| ead1774cd3 | |||
| 0d45cbd849 | |||
| 456f739f51 | |||
| 80c6cc44e5 | |||
| 35265d8ae8 | |||
| 4a2d7ed100 | |||
| 47c4f038fe | |||
| 630ba67ef0 | |||
| fd1188abcd | |||
| 94513d7177 | |||
| fbff9821be | |||
| 1fd281121b | |||
| 5653a43941 | |||
| 7f07aedb8a | |||
| e437ce74c6 | |||
| 4ff6d2018a | |||
| 1c634da687 | |||
| 738c21dd66 | |||
| 7d72448ebf | |||
| b4f3d1f14c | |||
| 416166b07c | |||
| 480798e117 | |||
| 704a3d8a87 | |||
| 96e108037c | |||
| 97aade3632 | |||
| e43584912b | |||
| fccde36ecb | |||
| e631f9b0cc | |||
| b52431616e | |||
| 8446d99df1 | |||
| 8e1e913fcd | |||
| b74137dc72 | |||
| c83f9b0005 | |||
| 9f4737d350 | |||
| 29e9a5f701 | |||
| f0f06671cc | |||
| b1595e479c | |||
| 25df69bfbc |
@@ -29,3 +29,4 @@ references/
|
||||
# local Docker compose files
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
.docker-certs/
|
||||
|
||||
@@ -463,7 +463,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
||||
|
||||
+369
-334
@@ -1,172 +1,207 @@
|
||||
## [3.7.0] - 2026-04-02
|
||||
|
||||
* Feature: Repeater battery tracking
|
||||
* Feature: Repeater info pane just like contacts
|
||||
* Feature: Make repeaters blockable
|
||||
* Feature: Add new-node advert blocking
|
||||
* Feature: Add bulk deletion interface
|
||||
* Feature: Bulk room add on alt+click of new channel button
|
||||
* Feature: More info in debug endpoint
|
||||
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
|
||||
* Misc: Default auto-DM decrypt to true
|
||||
* Misc: Reorganize some settings panes
|
||||
* Misc: Enable FK pragma
|
||||
* Misc: Various performance and correctness fixes
|
||||
* Misc: Correct TCP default port
|
||||
|
||||
## [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
|
||||
|
||||
@@ -174,287 +209,287 @@ Bugfix: Don't obscure new integration dropdown on session boundary
|
||||
|
||||
## [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
|
||||
|
||||
@@ -462,27 +497,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!
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform.
|
||||
|
||||
For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct.
|
||||
|
||||
Create a local `docker-compose.yml` in one of two ways:
|
||||
|
||||
1. Copy the example file and edit it by hand:
|
||||
@@ -128,7 +130,7 @@ The guided Docker flow can collect BLE settings, but BLE access from Docker stil
|
||||
Then customize the local compose file for your transport and launch:
|
||||
|
||||
```bash
|
||||
docker compose up # -d for background once you validate it's working
|
||||
sudo docker compose up # add -d for background once you validate it's working
|
||||
```
|
||||
|
||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
|
||||
@@ -136,14 +138,14 @@ The database is stored in `./data/` (bind-mounted), so the container shares the
|
||||
To rebuild after pulling updates:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sudo docker compose pull
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
|
||||
|
||||
```yaml
|
||||
image: jkingsman/remoteterm-meshcore:latest
|
||||
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||
```
|
||||
|
||||
with:
|
||||
@@ -155,7 +157,7 @@ build: .
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user.
|
||||
@@ -163,7 +165,7 @@ The container runs as root by default for maximum serial passthrough compatibili
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
sudo docker compose down
|
||||
```
|
||||
|
||||
## Standard Environment Variables
|
||||
@@ -175,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
|
||||
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
||||
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
|
||||
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
|
||||
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
@@ -191,7 +193,7 @@ Common launch patterns:
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# TCP
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# BLE
|
||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
|
||||
serial_port: str = "" # Empty string triggers auto-detection
|
||||
serial_baudrate: int = 115200
|
||||
tcp_host: str = ""
|
||||
tcp_port: int = 4000
|
||||
tcp_port: int = 5000
|
||||
ble_address: str = ""
|
||||
ble_pin: str = ""
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
default=False,
|
||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||
)
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
|
||||
+13
-3
@@ -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)
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
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)
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
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)
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||
@@ -132,6 +132,12 @@ 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")
|
||||
@@ -141,6 +147,10 @@ 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()
|
||||
|
||||
+221
-2
@@ -367,6 +367,28 @@ 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
|
||||
|
||||
# Migration 50: Repeater telemetry history table + tracking opt-in column
|
||||
if version < 50:
|
||||
logger.info("Applying migration 50: repeater telemetry history")
|
||||
await _migrate_050_repeater_telemetry_history(conn)
|
||||
await set_version(conn, 50)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -829,7 +851,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 0,
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0
|
||||
@@ -841,7 +863,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, '[]', 0, 'recent', '{}', 0)
|
||||
VALUES (1, 200, '[]', 1, 'recent', '{}', 0)
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -2909,3 +2931,200 @@ 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")
|
||||
|
||||
|
||||
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
|
||||
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||
ON repeater_telemetry_history (public_key, timestamp)
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
+16
-1
@@ -530,6 +530,9 @@ class RepeaterStatusResponse(BaseModel):
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterNodeInfoResponse(BaseModel):
|
||||
@@ -805,7 +808,7 @@ class AppSettings(BaseModel):
|
||||
default_factory=list, description="List of favorited conversations"
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool = Field(
|
||||
default=False,
|
||||
default=True,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
)
|
||||
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
||||
@@ -840,6 +843,13 @@ 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):
|
||||
@@ -914,3 +924,8 @@ class StatisticsResponse(BaseModel):
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
noise_floor_24h: NoiseFloorHistoryStats
|
||||
|
||||
|
||||
class TelemetryHistoryEntry(BaseModel):
|
||||
timestamp: int
|
||||
data: dict
|
||||
|
||||
+24
-8
@@ -462,14 +462,19 @@ async def _process_advertisement(
|
||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
# 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
|
||||
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=advert.public_key.lower(),
|
||||
@@ -482,7 +487,18 @@ 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,
|
||||
|
||||
+35
-4
@@ -29,7 +29,10 @@ from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
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
|
||||
@@ -63,13 +66,25 @@ async def _reconcile_contact_messages_background(
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
) -> None:
|
||||
"""Run contact/message reconciliation outside the radio critical path."""
|
||||
"""Run prefix promotion and 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",
|
||||
@@ -179,6 +194,22 @@ 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)
|
||||
@@ -193,7 +224,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 = app_settings.max_radio_contacts
|
||||
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
@@ -1301,7 +1332,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 = app_settings.max_radio_contacts
|
||||
max_contacts = _effective_radio_capacity(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()
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.repository.contacts import (
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.repository.messages import MessageRepository
|
||||
from app.repository.raw_packets import RawPacketRepository
|
||||
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||
|
||||
__all__ = [
|
||||
@@ -20,5 +21,6 @@ __all__ = [
|
||||
"FanoutConfigRepository",
|
||||
"MessageRepository",
|
||||
"RawPacketRepository",
|
||||
"RepeaterTelemetryRepository",
|
||||
"StatisticsRepository",
|
||||
]
|
||||
|
||||
+63
-51
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
@@ -12,6 +13,8 @@ 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."""
|
||||
@@ -484,7 +487,6 @@ 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"]
|
||||
@@ -501,60 +503,70 @@ class ContactRepository:
|
||||
(old_key,),
|
||||
)
|
||||
match_row = await match_cursor.fetchone()
|
||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
||||
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,
|
||||
)
|
||||
continue
|
||||
|
||||
await migrate_child_rows(old_key, normalized_full_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
|
||||
# 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,))
|
||||
|
||||
promoted_keys.append(old_key)
|
||||
|
||||
|
||||
@@ -158,7 +158,11 @@ class MessageRepository:
|
||||
"""
|
||||
lower_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET conversation_key = ?
|
||||
"""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
|
||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
@@ -166,7 +170,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
|
||||
@@ -572,6 +576,9 @@ 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()
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum age for telemetry history entries (30 days)
|
||||
_MAX_AGE_SECONDS = 30 * 86400
|
||||
|
||||
# Maximum entries to keep per repeater (sanity cap)
|
||||
_MAX_ENTRIES_PER_REPEATER = 1000
|
||||
|
||||
|
||||
class RepeaterTelemetryRepository:
|
||||
@staticmethod
|
||||
async def record(
|
||||
public_key: str,
|
||||
timestamp: int,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Insert a telemetry history row and prune stale entries."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO repeater_telemetry_history
|
||||
(public_key, timestamp, data)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(public_key, timestamp, json.dumps(data)),
|
||||
)
|
||||
|
||||
# Prune entries older than 30 days
|
||||
cutoff = int(time.time()) - _MAX_AGE_SECONDS
|
||||
await db.conn.execute(
|
||||
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
|
||||
(public_key, cutoff),
|
||||
)
|
||||
|
||||
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
DELETE FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND id NOT IN (
|
||||
SELECT id FROM repeater_telemetry_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
|
||||
)
|
||||
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
|
||||
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT timestamp, data
|
||||
FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
""",
|
||||
(public_key, since_timestamp),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"timestamp": row["timestamp"],
|
||||
"data": json.loads(row["data"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -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
|
||||
blocked_keys, blocked_names, discovery_blocked_types
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -81,6 +81,14 @@ 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"):
|
||||
@@ -98,6 +106,7 @@ class AppSettingsRepository:
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -113,6 +122,7 @@ 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 = []
|
||||
@@ -163,6 +173,10 @@ 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)
|
||||
|
||||
+224
-47
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.channel_constants import (
|
||||
@@ -10,10 +11,12 @@ from app.channel_constants import (
|
||||
is_public_channel_key,
|
||||
is_public_channel_name,
|
||||
)
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_event, broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
@@ -31,12 +34,154 @@ class CreateChannelRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsRequest(BaseModel):
|
||||
channel_names: list[str] = Field(
|
||||
min_length=1,
|
||||
description="List of hashtag room names. Leading # is optional per entry.",
|
||||
)
|
||||
try_historical: bool = Field(
|
||||
default=False,
|
||||
description="Attempt one background historical decrypt sweep for the newly added rooms.",
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsResponse(BaseModel):
|
||||
created_channels: list[Channel]
|
||||
existing_count: int
|
||||
invalid_names: list[str]
|
||||
decrypt_started: bool = False
|
||||
decrypt_total_packets: int = 0
|
||||
message: str
|
||||
|
||||
|
||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
flood_scope_override: str = Field(
|
||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||
)
|
||||
|
||||
|
||||
def _derive_channel_identity(
|
||||
requested_name: str,
|
||||
request_key: str | None = None,
|
||||
) -> tuple[str, str, bool]:
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
if is_public_channel_name(requested_name):
|
||||
if request_key:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||
)
|
||||
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
|
||||
|
||||
if request_key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
key_hex = key_bytes.hex().upper()
|
||||
if is_public_channel_key(key_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||
)
|
||||
return key_hex, requested_name, False
|
||||
|
||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||
return key_bytes.hex().upper(), requested_name, is_hashtag
|
||||
|
||||
|
||||
def _normalize_bulk_hashtag_name(name: str) -> str | None:
|
||||
trimmed = name.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
normalized = trimmed.lstrip("#").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) > 31:
|
||||
return None
|
||||
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
|
||||
return None
|
||||
return f"#{normalized}"
|
||||
|
||||
|
||||
async def _run_historical_channel_decryption_for_channels(
|
||||
channels: list[tuple[bytes, str, str]],
|
||||
) -> None:
|
||||
packets = await RawPacketRepository.get_all_undecrypted()
|
||||
total = len(packets)
|
||||
decrypted_count = 0
|
||||
matched_channel_names: set[str] = set()
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted packets to process for bulk channel decrypt")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Starting bulk historical channel decryption of %d packets across %d channels",
|
||||
total,
|
||||
len(channels),
|
||||
)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
packet_info = parse_packet(packet_data)
|
||||
path_hex = packet_info.path.hex() if packet_info else None
|
||||
path_len = packet_info.path_length if packet_info else None
|
||||
|
||||
for channel_key_bytes, channel_key_hex, channel_name in channels:
|
||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key_hex,
|
||||
channel_name=channel_name,
|
||||
sender=result.sender,
|
||||
message_text=result.message,
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=path_len,
|
||||
realtime=False,
|
||||
)
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
matched_channel_names.add(channel_name)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
|
||||
decrypted_count,
|
||||
total,
|
||||
len(matched_channel_names),
|
||||
)
|
||||
|
||||
if decrypted_count > 0:
|
||||
broadcast_success(
|
||||
"Bulk historical decrypt complete",
|
||||
(
|
||||
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
|
||||
f"across {len(matched_channel_names)} room"
|
||||
f"{'s' if len(matched_channel_names) != 1 else ''}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
@@ -69,50 +214,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
automatically when sending a message (see messages.py send_channel_message).
|
||||
"""
|
||||
requested_name = request.name
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
# Reserve the canonical Public channel so it cannot drift to another key,
|
||||
# and the well-known Public key cannot be renamed to something else.
|
||||
if is_public_channel_name(requested_name):
|
||||
if request.key:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||
)
|
||||
key_hex = PUBLIC_CHANNEL_KEY
|
||||
channel_name = PUBLIC_CHANNEL_NAME
|
||||
is_hashtag = False
|
||||
elif request.key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
key_hex = key_bytes.hex().upper()
|
||||
if is_public_channel_key(key_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||
)
|
||||
channel_name = requested_name
|
||||
else:
|
||||
# Derive key from name hash (same as meshcore library does)
|
||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||
key_hex = key_bytes.hex().upper()
|
||||
channel_name = requested_name
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
||||
|
||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
||||
|
||||
@@ -132,6 +234,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
|
||||
async def bulk_create_hashtag_channels(
|
||||
request: BulkCreateHashtagChannelsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
) -> BulkCreateHashtagChannelsResponse:
|
||||
created_channels: list[Channel] = []
|
||||
existing_count = 0
|
||||
invalid_names: list[str] = []
|
||||
decrypt_started = False
|
||||
decrypt_total_packets = 0
|
||||
decrypt_targets: list[tuple[bytes, str, str]] = []
|
||||
|
||||
for raw_name in request.channel_names:
|
||||
normalized_name = _normalize_bulk_hashtag_name(raw_name)
|
||||
if normalized_name is None:
|
||||
invalid_names.append(raw_name)
|
||||
continue
|
||||
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
|
||||
existing = await ChannelRepository.get_by_key(key_hex)
|
||||
if existing is not None:
|
||||
existing_count += 1
|
||||
continue
|
||||
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=channel_name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Channel was created but could not be reloaded",
|
||||
)
|
||||
|
||||
created_channels.append(stored)
|
||||
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
|
||||
_broadcast_channel_update(stored)
|
||||
|
||||
if request.try_historical and decrypt_targets:
|
||||
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
|
||||
if decrypt_total_packets > 0:
|
||||
background_tasks.add_task(
|
||||
_run_historical_channel_decryption_for_channels, decrypt_targets
|
||||
)
|
||||
decrypt_started = True
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
|
||||
message = (
|
||||
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
|
||||
if created_channels
|
||||
else "No new rooms were added"
|
||||
)
|
||||
if request.try_historical and decrypt_targets:
|
||||
if decrypt_started:
|
||||
message += (
|
||||
f" and started background decrypt of {decrypt_total_packets} packet"
|
||||
f"{'s' if decrypt_total_packets != 1 else ''}"
|
||||
)
|
||||
else:
|
||||
message += "; no undecrypted packets were available"
|
||||
|
||||
return BulkCreateHashtagChannelsResponse(
|
||||
created_channels=created_channels,
|
||||
existing_count=existing_count,
|
||||
invalid_names=invalid_names,
|
||||
decrypt_started=decrypt_started,
|
||||
decrypt_total_packets=decrypt_total_packets,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{key}/mark-read")
|
||||
async def mark_channel_read(key: str) -> dict:
|
||||
"""Mark a channel as read (update last_read_at timestamp)."""
|
||||
|
||||
+50
-3
@@ -1,10 +1,12 @@
|
||||
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 (
|
||||
@@ -31,7 +33,7 @@ from app.repository import (
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
@@ -277,12 +279,18 @@ async def create_contact(
|
||||
# Check if contact already exists
|
||||
existing = await ContactRepository.get_by_key(request.public_key)
|
||||
if existing:
|
||||
# Update name if provided
|
||||
# Update name if provided and record name history
|
||||
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,
|
||||
@@ -317,9 +325,10 @@ async def create_contact(
|
||||
log=logger,
|
||||
)
|
||||
|
||||
await reconcile_contact_messages(
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=lower_key,
|
||||
contact_name=request.name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
@@ -347,6 +356,44 @@ 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)."""
|
||||
|
||||
+138
-19
@@ -1,17 +1,21 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
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 MessageRepository, StatisticsRepository
|
||||
from app.routers.health import HealthResponse, build_health_data
|
||||
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
|
||||
from app.routers.health import FanoutStatusResponse, build_health_data
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
from app.version_info import get_app_build_info, git_output
|
||||
|
||||
@@ -34,6 +38,13 @@ 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
|
||||
@@ -50,8 +61,6 @@ 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
|
||||
@@ -78,7 +87,6 @@ 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
|
||||
@@ -93,16 +101,53 @@ 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: HealthResponse
|
||||
health: DebugHealthSummary
|
||||
settings: DebugAppSettings
|
||||
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")
|
||||
@@ -158,6 +203,68 @@ 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:
|
||||
@@ -242,10 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
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 {}),
|
||||
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
stats_radio=stats_radio,
|
||||
@@ -264,24 +368,39 @@ 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."""
|
||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
||||
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()
|
||||
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=HealthResponse(**health_data),
|
||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||
settings=_build_debug_app_settings(app_settings),
|
||||
runtime=DebugRuntimeInfo(
|
||||
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,
|
||||
connection_info=connection_info,
|
||||
connection_desired=connection_desired,
|
||||
setup_in_progress=setup_in_progress,
|
||||
setup_complete=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(),
|
||||
|
||||
+14
-3
@@ -24,7 +24,10 @@ 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
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
@@ -214,11 +217,19 @@ 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_deleted", {"public_key": old_key})
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||
)
|
||||
|
||||
|
||||
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
@@ -21,8 +22,9 @@ from app.models import (
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
)
|
||||
from app.repository import ContactRepository
|
||||
from app.repository import ContactRepository, RepeaterTelemetryRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
batch_cli_fetch,
|
||||
@@ -108,7 +110,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
response = RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
@@ -128,6 +130,42 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
# Record to telemetry history as a JSON blob (best-effort)
|
||||
now = int(time.time())
|
||||
status_dict = response.model_dump(exclude={"telemetry_history"})
|
||||
try:
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=now,
|
||||
data=status_dict,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to record telemetry history: %s", e)
|
||||
|
||||
# Fetch recent history and embed in response
|
||||
try:
|
||||
since = now - 30 * 86400 # last 30 days
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch telemetry history: %s", e)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{public_key}/repeater/telemetry-history",
|
||||
response_model=list[TelemetryHistoryEntry],
|
||||
)
|
||||
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
|
||||
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
since = int(time.time()) - 30 * 86400
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
return [TelemetryHistoryEntry(**row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
|
||||
@@ -48,6 +48,13 @@ 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):
|
||||
@@ -122,6 +129,12 @@ 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:
|
||||
|
||||
@@ -204,35 +204,41 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
from app.config import settings as app_settings_config
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
if app_settings_config.skip_post_connect_sync:
|
||||
logger.info("Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
radio_manager._release_operation_lock("post_connect_setup")
|
||||
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
if not app_settings_config.skip_post_connect_sync:
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
finally:
|
||||
|
||||
@@ -60,8 +60,17 @@ async def sample_noise_floor_once(*, blocking: bool = False) -> None:
|
||||
|
||||
async def _noise_floor_sampling_loop() -> None:
|
||||
while True:
|
||||
await sample_noise_floor_once()
|
||||
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
|
||||
try:
|
||||
await sample_noise_floor_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Noise floor sampling loop crashed during sample")
|
||||
|
||||
try:
|
||||
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
|
||||
async def start_noise_floor_sampling() -> None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
remoteterm:
|
||||
# build: .
|
||||
image: jkingsman/remoteterm-meshcore:latest
|
||||
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||
|
||||
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
||||
# This is less reliable for serial-device access than running as root and may require
|
||||
@@ -12,9 +12,12 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
||||
################################################
|
||||
# Map your radio by stable device ID if available. #
|
||||
################################################
|
||||
#####################################################################
|
||||
# Map your radio by stable device ID if available. #
|
||||
# If your by-id path contains ':' characters, Docker Compose cannot #
|
||||
# represent it here directly; use a colon-free host alias instead. #
|
||||
# (e.g. /dev/ttyUSB0) #
|
||||
#####################################################################
|
||||
devices:
|
||||
- /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.2",
|
||||
"version": "3.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.2",
|
||||
"version": "3.6.3",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.6.3",
|
||||
"version": "3.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+78
-4
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
|
||||
import { api } from './api';
|
||||
import { takePrefetchOrFetch } from './prefetch';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
@@ -31,6 +31,12 @@ interface ChannelUnreadMarker {
|
||||
lastReadAt: number | null;
|
||||
}
|
||||
|
||||
interface NewMessagePrefillRequest {
|
||||
tab: 'hashtag';
|
||||
hashtagName: string;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
interface UnreadBoundaryBackfillParams {
|
||||
activeConversation: Conversation | null;
|
||||
unreadMarker: ChannelUnreadMarker | null;
|
||||
@@ -77,6 +83,10 @@ export function App() {
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
@@ -103,8 +113,8 @@ export function App() {
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
handleCloseNewMessage,
|
||||
handleOpenNewMessage: openNewMessageModal,
|
||||
handleCloseNewMessage: closeNewMessageModal,
|
||||
handleToggleCracker,
|
||||
} = useAppShell();
|
||||
|
||||
@@ -182,6 +192,7 @@ export function App() {
|
||||
handleCreateContact,
|
||||
handleCreateChannel,
|
||||
handleCreateHashtagChannel,
|
||||
handleBulkCreateHashtagChannels,
|
||||
handleDeleteChannel,
|
||||
handleDeleteContact,
|
||||
} = useContactsAndChannels({
|
||||
@@ -413,6 +424,52 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const handleOpenNewMessage = useCallback(
|
||||
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
setShowBulkAddChannelTab(event?.altKey === true);
|
||||
openNewMessageModal();
|
||||
},
|
||||
[openNewMessageModal]
|
||||
);
|
||||
|
||||
const handleCloseNewMessage = useCallback(() => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
setShowBulkAddChannelTab(false);
|
||||
closeNewMessageModal();
|
||||
}, [closeNewMessageModal]);
|
||||
|
||||
const handleCloseBulkAddResults = useCallback(() => {
|
||||
setBulkAddResult(null);
|
||||
}, []);
|
||||
|
||||
const handleChannelReferenceClick = useCallback(
|
||||
(channelName: string) => {
|
||||
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||
if (existingChannel) {
|
||||
handleNavigateToChannel(existingChannel.key);
|
||||
return;
|
||||
}
|
||||
|
||||
setNewMessagePrefillRequest((previous) => ({
|
||||
tab: 'hashtag',
|
||||
hashtagName: channelName.slice(1),
|
||||
nonce: (previous?.nonce ?? 0) + 1,
|
||||
}));
|
||||
setShowBulkAddChannelTab(false);
|
||||
openNewMessageModal();
|
||||
},
|
||||
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||
);
|
||||
|
||||
const handleBulkAddChannels = useCallback(
|
||||
async (channelNames: string[], tryHistorical: boolean) => {
|
||||
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||
setBulkAddResult(result);
|
||||
},
|
||||
[handleBulkCreateHashtagChannels]
|
||||
);
|
||||
|
||||
const statusProps = {
|
||||
health,
|
||||
config,
|
||||
@@ -435,6 +492,11 @@ export function App() {
|
||||
favorites,
|
||||
legacySortOrder: appSettings?.sidebar_sort_order,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
blockedNames: appSettings?.blocked_names ?? [],
|
||||
};
|
||||
const bulkAddChannelResultModalProps = {
|
||||
result: bulkAddResult,
|
||||
};
|
||||
const conversationPaneProps = {
|
||||
activeConversation,
|
||||
@@ -468,6 +530,7 @@ export function App() {
|
||||
onOpenContactInfo: handleOpenContactInfo,
|
||||
onOpenChannelInfo: handleOpenChannelInfo,
|
||||
onSenderClick: handleSenderClick,
|
||||
onChannelReferenceClick: handleChannelReferenceClick,
|
||||
onLoadOlder: fetchOlderMessages,
|
||||
onResendChannelMessage: handleResendChannelMessage,
|
||||
onTargetReached: () => setTargetMessageId(null),
|
||||
@@ -518,6 +581,11 @@ 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,
|
||||
@@ -526,9 +594,12 @@ export function App() {
|
||||
};
|
||||
const newMessageModalProps = {
|
||||
undecryptedCount,
|
||||
showBulkAddChannelTab,
|
||||
prefillRequest: newMessagePrefillRequest,
|
||||
onCreateContact: handleCreateContact,
|
||||
onCreateChannel: handleCreateChannel,
|
||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||
onBulkAddHashtagChannels: handleBulkAddChannels,
|
||||
};
|
||||
const contactInfoPaneProps = {
|
||||
contactKey: infoPaneContactKey,
|
||||
@@ -592,6 +663,7 @@ export function App() {
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showBulkAddResults={bulkAddResult !== null}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
@@ -602,6 +674,7 @@ export function App() {
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
@@ -610,6 +683,7 @@ export function App() {
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
BulkCreateHashtagChannelsResult,
|
||||
Channel,
|
||||
ChannelDetail,
|
||||
CommandResponse,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
@@ -149,6 +151,12 @@ 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',
|
||||
@@ -184,6 +192,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, key }),
|
||||
}),
|
||||
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
|
||||
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
|
||||
}),
|
||||
deleteChannel: (key: string) =>
|
||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
||||
@@ -402,6 +415,8 @@ export const api = {
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterTelemetryHistory: (publicKey: string) =>
|
||||
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { ConversationPane } from './ConversationPane';
|
||||
import { NewMessageModal } from './NewMessageModal';
|
||||
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
@@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
|
||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||
type BulkAddChannelResultModalProps = Omit<
|
||||
ComponentProps<typeof BulkAddChannelResultModal>,
|
||||
'open' | 'onClose'
|
||||
>;
|
||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||
|
||||
interface AppShellProps {
|
||||
localLabel: LocalLabel;
|
||||
showNewMessage: boolean;
|
||||
showBulkAddResults: boolean;
|
||||
showSettings: boolean;
|
||||
settingsSection: SettingsSection;
|
||||
sidebarOpen: boolean;
|
||||
@@ -50,6 +56,7 @@ interface AppShellProps {
|
||||
onToggleSettingsView: () => void;
|
||||
onCloseSettingsView: () => void;
|
||||
onCloseNewMessage: () => void;
|
||||
onCloseBulkAddResults: () => void;
|
||||
onLocalLabelChange: (label: LocalLabel) => void;
|
||||
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||
sidebarProps: SidebarProps;
|
||||
@@ -61,6 +68,7 @@ interface AppShellProps {
|
||||
>;
|
||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||
newMessageModalProps: NewMessageModalProps;
|
||||
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
}
|
||||
@@ -68,6 +76,7 @@ interface AppShellProps {
|
||||
export function AppShell({
|
||||
localLabel,
|
||||
showNewMessage,
|
||||
showBulkAddResults,
|
||||
showSettings,
|
||||
settingsSection,
|
||||
sidebarOpen,
|
||||
@@ -79,6 +88,7 @@ export function AppShell({
|
||||
onToggleSettingsView,
|
||||
onCloseSettingsView,
|
||||
onCloseNewMessage,
|
||||
onCloseBulkAddResults,
|
||||
onLocalLabelChange,
|
||||
statusProps,
|
||||
sidebarProps,
|
||||
@@ -87,6 +97,7 @@ export function AppShell({
|
||||
settingsProps,
|
||||
crackerProps,
|
||||
newMessageModalProps,
|
||||
bulkAddChannelResultModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
}: AppShellProps) {
|
||||
@@ -306,6 +317,11 @@ export function AppShell({
|
||||
open={showNewMessage}
|
||||
onClose={onCloseNewMessage}
|
||||
/>
|
||||
<BulkAddChannelResultModal
|
||||
{...bulkAddChannelResultModalProps}
|
||||
open={showBulkAddResults}
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
|
||||
import { getConversationHash } from '../utils/urlHash';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
|
||||
interface BulkAddChannelResultModalProps {
|
||||
open: boolean;
|
||||
result: BulkCreateHashtagChannelsResult | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getChannelHref(channel: Channel): string {
|
||||
const hash = getConversationHash({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
});
|
||||
if (typeof window === 'undefined') {
|
||||
return hash;
|
||||
}
|
||||
return `${window.location.origin}${window.location.pathname}${hash}`;
|
||||
}
|
||||
|
||||
export function BulkAddChannelResultModal({
|
||||
open,
|
||||
result,
|
||||
onClose,
|
||||
}: BulkAddChannelResultModalProps) {
|
||||
const createdChannels = result?.created_channels ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Add Complete</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result?.message ?? 'Review the newly added rooms below.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{result && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
|
||||
<div className="mt-1 font-medium">{createdChannels.length}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Already Present
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdChannels.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ctrl+click any room to open it in a new tab.
|
||||
</p>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
|
||||
<ul className="divide-y divide-border/70">
|
||||
{createdChannels.map((channel) => (
|
||||
<li key={channel.key}>
|
||||
<a
|
||||
href={getChannelHref(channel)}
|
||||
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
|
||||
>
|
||||
{channel.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
|
||||
)}
|
||||
|
||||
{result && result.invalid_names.length > 0 && (
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
Ignored invalid room names: {result.invalid_names.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
@@ -35,6 +35,7 @@ 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,
|
||||
@@ -158,6 +159,7 @@ 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()}>
|
||||
@@ -440,7 +442,7 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSearchMessagesByKey && (
|
||||
{!isRepeater && onSearchMessagesByKey && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
@@ -453,40 +455,60 @@ export function ContactInfoPane({
|
||||
</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>
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Geographically nearest repeaters (repeaters only) */}
|
||||
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
|
||||
<NearbyRepeatersSection
|
||||
contact={contact}
|
||||
contacts={contacts}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="space-y-1.5">
|
||||
{analytics.advert_paths.map((p) => (
|
||||
<div
|
||||
key={p.path + p.first_seen}
|
||||
className="flex justify-between items-center text-sm"
|
||||
className="flex justify-between items-start gap-2 text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">
|
||||
<span className="font-mono text-xs break-all">
|
||||
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -518,17 +540,21 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
/>
|
||||
{!isRepeater && (
|
||||
<>
|
||||
<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">
|
||||
@@ -826,6 +852,60 @@ 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>
|
||||
|
||||
@@ -65,6 +65,7 @@ interface ConversationPaneProps {
|
||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
onOpenChannelInfo: (channelKey: string) => void;
|
||||
onSenderClick: (sender: string) => void;
|
||||
onChannelReferenceClick?: (channelName: string) => void;
|
||||
onLoadOlder: () => Promise<void>;
|
||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||
onTargetReached: () => void;
|
||||
@@ -131,6 +132,7 @@ export function ConversationPane({
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
onSenderClick,
|
||||
onChannelReferenceClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
onTargetReached,
|
||||
@@ -231,6 +233,7 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -284,6 +287,7 @@ export function ConversationPane({
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { api } from '../api';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import {
|
||||
findLinkedChannelReferences,
|
||||
formatTime,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
@@ -33,6 +37,7 @@ interface MessageListProps {
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
onChannelReferenceClick?: (channelName: string) => void;
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||
@@ -48,8 +53,64 @@ interface MessageListProps {
|
||||
const URL_PATTERN =
|
||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||
|
||||
// Helper to convert URLs in a plain text string into clickable links
|
||||
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
function renderChannelReferences(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode[] {
|
||||
const references = findLinkedChannelReferences(text);
|
||||
if (references.length === 0) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
references.forEach((reference, index) => {
|
||||
if (reference.start > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, reference.start));
|
||||
}
|
||||
|
||||
const className =
|
||||
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
|
||||
if (onChannelReferenceClick) {
|
||||
parts.push(
|
||||
<button
|
||||
key={`${keyPrefix}-channel-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
className,
|
||||
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
onClick={() => onChannelReferenceClick(reference.label)}
|
||||
>
|
||||
{reference.label}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
<span key={`${keyPrefix}-channel-${index}`} className={className}>
|
||||
{reference.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = reference.end;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Helper to convert URLs and channel references in a plain text string into rich content
|
||||
function linkifyText(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
URL_PATTERN.lastIndex = 0;
|
||||
while ((match = URL_PATTERN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
parts.push(
|
||||
...renderChannelReferences(
|
||||
text.slice(lastIndex, match.index),
|
||||
`${keyPrefix}-text-${keyIndex}`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex === 0) return [text];
|
||||
if (lastIndex === 0) {
|
||||
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
parts.push(
|
||||
...renderChannelReferences(
|
||||
text.slice(lastIndex),
|
||||
`${keyPrefix}-tail`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
||||
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
function renderTextWithMentions(
|
||||
text: string,
|
||||
radioName?: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode {
|
||||
const mentionPattern = /@\[([^\]]+)\]/g;
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
while ((match = mentionPattern.exec(text)) !== null) {
|
||||
// Add text before the match (with linkification)
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
||||
parts.push(
|
||||
...linkifyText(
|
||||
text.slice(lastIndex, match.index),
|
||||
`pre-${keyIndex}`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const mentionedName = match[1];
|
||||
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
|
||||
// Add remaining text after last match (with linkification)
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
|
||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
@@ -188,6 +273,7 @@ export function MessageList({
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
onChannelReferenceClick,
|
||||
radioName,
|
||||
config,
|
||||
onOpenContactInfo,
|
||||
@@ -911,7 +997,7 @@ export function MessageList({
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName)}
|
||||
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -1,58 +1,156 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
|
||||
|
||||
interface BulkParseResult {
|
||||
channelNames: string[];
|
||||
invalidNames: string[];
|
||||
}
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
undecryptedCount: number;
|
||||
showBulkAddChannelTab?: boolean;
|
||||
prefillRequest?: {
|
||||
tab: 'hashtag';
|
||||
hashtagName: string;
|
||||
nonce: number;
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
function validateHashtagName(channelName: string): string | null {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
|
||||
const tokens = rawText
|
||||
.split(/[\s,]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const invalidNames: string[] = [];
|
||||
const channelNames: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const token of tokens) {
|
||||
const stripped = token.replace(/^#+/, '');
|
||||
const validationError = validateHashtagName(stripped);
|
||||
if (validationError) {
|
||||
invalidNames.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
|
||||
const channelName = `#${normalized}`;
|
||||
if (seen.has(channelName)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(channelName);
|
||||
channelNames.push(channelName);
|
||||
}
|
||||
|
||||
return { channelNames, invalidNames };
|
||||
}
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
undecryptedCount,
|
||||
showBulkAddChannelTab = false,
|
||||
prefillRequest = null,
|
||||
onClose,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
onBulkAddHashtagChannels,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('new-contact');
|
||||
const [name, setName] = useState('');
|
||||
const [contactKey, setContactKey] = useState('');
|
||||
const [channelKey, setChannelKey] = useState('');
|
||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefillRequest) {
|
||||
setTab(prefillRequest.tab);
|
||||
setName(prefillRequest.hashtagName);
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
hashtagInputRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (showBulkAddChannelTab) {
|
||||
setTab('bulk-hashtag');
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
bulkTextareaRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTab('new-contact');
|
||||
}, [open, prefillRequest, showBulkAddChannelTab]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
@@ -63,7 +161,6 @@ export function NewMessageModal({
|
||||
setError('Name and public key are required');
|
||||
return;
|
||||
}
|
||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||
} else if (tab === 'new-channel') {
|
||||
if (!name.trim() || !channelKey.trim()) {
|
||||
@@ -78,10 +175,24 @@ export function NewMessageModal({
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
// Normalize to lowercase unless user explicitly permits capitals
|
||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||
} else {
|
||||
const { channelNames, invalidNames } = parseBulkHashtagNames(
|
||||
bulkChannelText,
|
||||
permitCapitals
|
||||
);
|
||||
if (channelNames.length === 0) {
|
||||
setError('Enter at least one valid room name');
|
||||
return;
|
||||
}
|
||||
if (invalidNames.length > 0) {
|
||||
setError(`Invalid room names: ${invalidNames.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
await onBulkAddHashtagChannels(channelNames, tryHistorical);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -94,16 +205,6 @@ export function NewMessageModal({
|
||||
}
|
||||
};
|
||||
|
||||
const validateHashtagName = (channelName: string): string | null => {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreateAndAddAnother = async () => {
|
||||
setError('');
|
||||
const channelName = name.trim();
|
||||
@@ -115,7 +216,6 @@ export function NewMessageModal({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Normalize to lowercase unless user explicitly permits capitals
|
||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||
setName('');
|
||||
@@ -142,28 +242,36 @@ export function NewMessageModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as Tab);
|
||||
onValueChange={(value) => {
|
||||
setTab(value as Tab);
|
||||
resetForm();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList
|
||||
className={
|
||||
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
|
||||
}
|
||||
>
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
@@ -215,7 +323,7 @@ export function NewMessageModal({
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
setChannelKey(hex);
|
||||
}}
|
||||
@@ -244,20 +352,55 @@ export function NewMessageModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
|
||||
<textarea
|
||||
ref={bulkTextareaRef}
|
||||
id="bulk-hashtag-names"
|
||||
aria-label="Bulk channel names"
|
||||
value={bulkChannelText}
|
||||
onChange={(e) => setBulkChannelText(e.target.value)}
|
||||
placeholder={'#ops\nmesh-room\nanother-room'}
|
||||
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste room names separated by lines, spaces, or commas. Leading # marks are
|
||||
stripped automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{showHistoricalOption && (
|
||||
@@ -265,7 +408,7 @@ export function NewMessageModal({
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Label
|
||||
htmlFor="try-historical"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
className="cursor-pointer text-sm text-muted-foreground"
|
||||
>
|
||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
||||
{undecryptedCount !== 1 ? 's' : ''}
|
||||
@@ -277,7 +420,7 @@ export function NewMessageModal({
|
||||
/>
|
||||
</div>
|
||||
{tryHistorical && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
Messages will stream in as they decrypt in the background
|
||||
</p>
|
||||
)}
|
||||
@@ -306,7 +449,13 @@ export function NewMessageModal({
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
{loading
|
||||
? tab === 'bulk-hashtag'
|
||||
? 'Adding...'
|
||||
: 'Creating...'
|
||||
: tab === 'bulk-hashtag'
|
||||
? 'Add Channels'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -406,9 +406,12 @@ 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
|
||||
@@ -447,27 +450,38 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
||||
) : isAmbiguous ? (
|
||||
<div>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{(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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-medium truncate">
|
||||
|
||||
@@ -171,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
|
||||
return resolveContact(item.key, contacts) !== null;
|
||||
}
|
||||
|
||||
function formatStrongestPacketDetail(
|
||||
function formatStrongestNeighborDetail(
|
||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||
contacts: Contact[]
|
||||
): string | undefined {
|
||||
if (!stats.strongestPacketPayloadType) {
|
||||
const strongestNeighbor = stats.strongestNeighbors[0];
|
||||
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedLabel =
|
||||
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
|
||||
stats.strongestPacketSourceLabel;
|
||||
if (resolvedLabel) {
|
||||
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
|
||||
}
|
||||
if (stats.strongestPacketPayloadType === 'GroupText') {
|
||||
return '<unknown sender> · GroupText';
|
||||
}
|
||||
return stats.strongestPacketPayloadType;
|
||||
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
|
||||
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
|
||||
}
|
||||
|
||||
function getCoverageMessage(
|
||||
@@ -450,8 +443,13 @@ export function RawPacketFeedView({
|
||||
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||
);
|
||||
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||
const strongestPacketDetail = useMemo(
|
||||
() => formatStrongestPacketDetail(stats, contacts),
|
||||
const strongestNeighbor = useMemo(() => {
|
||||
const topNeighbor = stats.strongestNeighbors[0];
|
||||
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
|
||||
}, [contacts, stats]);
|
||||
|
||||
const strongestNeighborDetail = useMemo(
|
||||
() => formatStrongestNeighborDetail(stats, contacts),
|
||||
[contacts, stats]
|
||||
);
|
||||
const strongestNeighbors = useMemo(
|
||||
@@ -578,9 +576,9 @@ export function RawPacketFeedView({
|
||||
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Best RSSI"
|
||||
value={formatRssi(stats.bestRssi)}
|
||||
detail={strongestPacketDetail ?? 'No signal sample in window'}
|
||||
label="Strongest Neighbor"
|
||||
value={strongestNeighbor?.label ?? '-'}
|
||||
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
|
||||
/>
|
||||
<StatTile
|
||||
label="Median RSSI"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import type {
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
TelemetryHistoryEntry,
|
||||
} from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
|
||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||
@@ -45,6 +53,7 @@ interface RepeaterDashboardProps {
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
@@ -62,6 +71,7 @@ export function RepeaterDashboard({
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -88,7 +98,40 @@ export function RepeaterDashboard({
|
||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||
useRememberedServerPassword('repeater', conversation.id);
|
||||
|
||||
// Telemetry history: preload from stored data, refresh from live status
|
||||
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
|
||||
const telemetryHistoryRequestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
telemetryHistoryRequestRef.current += 1;
|
||||
telemetryHistorySourceRef.current = 'none';
|
||||
setTelemetryHistory([]);
|
||||
|
||||
if (!loggedIn) return;
|
||||
|
||||
const requestId = telemetryHistoryRequestRef.current;
|
||||
api
|
||||
.repeaterTelemetryHistory(conversation.id)
|
||||
.then((history) => {
|
||||
if (telemetryHistoryRequestRef.current !== requestId) return;
|
||||
if (telemetryHistorySourceRef.current === 'live') return;
|
||||
telemetryHistorySourceRef.current = 'preload';
|
||||
setTelemetryHistory(history);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [loggedIn, conversation.id]);
|
||||
|
||||
// When a live status fetch returns embedded telemetry_history, replace local state
|
||||
useEffect(() => {
|
||||
const liveHistory = paneData.status?.telemetry_history;
|
||||
if (!liveHistory) return;
|
||||
telemetryHistorySourceRef.current = 'live';
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
await login(nextPassword);
|
||||
persistAfterLogin(nextPassword);
|
||||
@@ -115,9 +158,24 @@ 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">
|
||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
||||
{conversation.name}
|
||||
</span>
|
||||
<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-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
@@ -336,6 +394,9 @@ export function RepeaterDashboard({
|
||||
loading={consoleLoading}
|
||||
onSend={sendConsoleCommand}
|
||||
/>
|
||||
|
||||
{/* Telemetry history chart — full width, below console */}
|
||||
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -47,6 +48,8 @@ interface SettingsModalBaseProps {
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -80,6 +83,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames,
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
onBulkDeleteContacts,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -239,6 +244,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -97,7 +97,7 @@ interface SidebarProps {
|
||||
channels: Channel[];
|
||||
activeConversation: Conversation | null;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onNewMessage: () => void;
|
||||
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadCounts: Record<string, number>;
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
@@ -110,6 +110,8 @@ 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 = {
|
||||
@@ -153,7 +155,16 @@ 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);
|
||||
@@ -398,38 +409,32 @@ export function Sidebar({
|
||||
[sortedChannels, query]
|
||||
);
|
||||
|
||||
const filteredNonRepeaterContacts = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedNonRepeaterContacts.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedNonRepeaterContacts,
|
||||
[sortedNonRepeaterContacts, 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 filteredRooms = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRooms.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRooms,
|
||||
[sortedRooms, 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 filteredRepeaters = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRepeaters.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRepeaters,
|
||||
[sortedRepeaters, 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]);
|
||||
|
||||
// Expand sections while searching; restore prior collapse state when search ends.
|
||||
useEffect(() => {
|
||||
@@ -654,8 +659,9 @@ export function Sidebar({
|
||||
}) => (
|
||||
<div
|
||||
key={key}
|
||||
data-active={active ? 'true' : undefined}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-accent border-l-primary'
|
||||
)}
|
||||
role="button"
|
||||
@@ -664,10 +670,10 @@ export function Sidebar({
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
|
||||
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -853,41 +859,45 @@ export function Sidebar({
|
||||
aria-label="Conversations"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNewMessage}
|
||||
title="New Message"
|
||||
aria-label="New message"
|
||||
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
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"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
<span>Add Channel/Contact</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||||
<div className="px-3 py-2 border-b border-border/60">
|
||||
<div className="relative min-w-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
{toolRows.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -318,8 +318,8 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto lg:overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||
<h2 className="text-base font-semibold">Trace</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
Build a repeater loop and trace it back to the local radio. The selectable hop list only
|
||||
@@ -329,7 +329,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
|
||||
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
|
||||
<div className="border-b border-border p-4">
|
||||
<div className="shrink-0 border-b border-border p-4">
|
||||
<h3 className="text-sm font-semibold">Repeater Hops</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Search by name or key, then add repeaters in the order you want to traverse them.
|
||||
@@ -446,14 +446,30 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
</section>
|
||||
|
||||
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Trace Path</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
The first node is display-only. The terminal node is the local radio.
|
||||
</p>
|
||||
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-h-[50%]">
|
||||
<div className="shrink-0 flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Trace Path</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
The first node is display-only. The terminal node is the local radio.
|
||||
</p>
|
||||
</div>
|
||||
{draftHops.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => {
|
||||
setDraftHops([]);
|
||||
clearPendingResult();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-[42vh] space-y-2 overflow-y-auto p-4 lg:max-h-none lg:overflow-y-visible">
|
||||
<div className="space-y-2 p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
|
||||
<TraceNodeRow
|
||||
title={localRadioName}
|
||||
subtitle={getShortKey(localRadioKey)}
|
||||
@@ -542,7 +558,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{draftHops.length === 0
|
||||
? 'No hops selected'
|
||||
@@ -555,12 +571,26 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
|
||||
</h3>
|
||||
{result || error ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-[42vh] min-h-0 flex-1 space-y-3 overflow-y-auto p-4 lg:max-h-none">
|
||||
<div className="min-h-0 flex-1 space-y-3 p-4 lg:overflow-y-auto">
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TelemetryHistoryEntry } from '../../types';
|
||||
|
||||
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||
|
||||
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
|
||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||
};
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||
} as const;
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
|
||||
return `${(seconds / 86400).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
|
||||
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||
|
||||
const config = METRIC_CONFIG[metric];
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return entries.map((e) => {
|
||||
const d = e.data;
|
||||
return {
|
||||
timestamp: e.timestamp,
|
||||
battery_volts: d.battery_volts,
|
||||
noise_floor_dbm: d.noise_floor_dbm,
|
||||
packets_received: d.packets_received,
|
||||
packets_sent: d.packets_sent,
|
||||
uptime_seconds: d.uptime_seconds,
|
||||
};
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{/* Metric selector */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={cn(
|
||||
'text-[11px] px-2 py-0.5 rounded transition-colors',
|
||||
metric === m
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{METRIC_CONFIG[m].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No history yet. Fetch status above to record data points.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatTime}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(ts) => formatTime(Number(ts))}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, name: any) => {
|
||||
const numVal = typeof value === 'number' ? value : Number(value);
|
||||
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
||||
const suffix =
|
||||
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
|
||||
const label =
|
||||
metric === 'packets'
|
||||
? name === 'packets_received'
|
||||
? 'Received'
|
||||
: 'Sent'
|
||||
: config.label;
|
||||
return [`${display}${suffix}`, label];
|
||||
}}
|
||||
/>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
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,7 +6,8 @@ import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
@@ -17,6 +18,8 @@ export function SettingsDatabaseSection({
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -27,18 +30,23 @@ 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 () => {
|
||||
@@ -92,7 +100,15 @@ export function SettingsDatabaseSection({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
|
||||
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);
|
||||
toast.success('Database settings saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save database settings:', err);
|
||||
@@ -105,93 +121,93 @@ export function SettingsDatabaseSection({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* ── Database Overview ── */}
|
||||
<div className="space-y-3">
|
||||
<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 ? (
|
||||
<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 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 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>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</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>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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"
|
||||
/>
|
||||
{/* ── 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>
|
||||
</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={handleCleanup}
|
||||
disabled={cleaning}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={handlePurgeDecryptedRawPackets}
|
||||
disabled={purgingDecryptedRaw}
|
||||
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{cleaning ? 'Deleting...' : 'Permanently Delete'}
|
||||
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── DM Decryption ── */}
|
||||
<div className="space-y-3">
|
||||
<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="text-base">DM Decryption</Label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -207,17 +223,87 @@ 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">
|
||||
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
|
||||
affected. Messages are still stored and will reappear if unblocked.
|
||||
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.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
@@ -268,15 +354,25 @@ export function SettingsDatabaseSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -347,17 +347,20 @@ function PreviewSidebarRow({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||
data-active={active ? 'true' : undefined}
|
||||
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
{leading}
|
||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
{leading}
|
||||
</span>
|
||||
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
{badge}
|
||||
{!badge && (
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Connection display */}
|
||||
{/* ── Connection ── */}
|
||||
<div className="space-y-3">
|
||||
<Label>Connection</Label>
|
||||
<Label className="text-base">Connection</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
@@ -428,15 +428,58 @@ export function SettingsRadioSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Radio Name */}
|
||||
<Separator />
|
||||
|
||||
{/* ── Identity ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Identity</Label>
|
||||
</div>
|
||||
|
||||
<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 Config */}
|
||||
{/* ── Radio Parameters ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Radio Parameters</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preset">Preset</Label>
|
||||
<select
|
||||
@@ -518,11 +561,36 @@ 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's route — your radio, every repeater, and the
|
||||
recipient — 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>Location</Label>
|
||||
<Label className="text-base">Location</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -585,53 +653,8 @@ 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's route — your radio, every repeater, and the
|
||||
recipient — 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}
|
||||
@@ -657,64 +680,28 @@ export function SettingsRadioSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Keys */}
|
||||
{/* ── Messaging ── */}
|
||||
<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" />
|
||||
<Label className="text-base">Messaging</Label>
|
||||
</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 />
|
||||
|
||||
{/* 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"
|
||||
<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"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||
<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>
|
||||
<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">
|
||||
@@ -746,6 +733,13 @@ 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 && (
|
||||
@@ -760,8 +754,28 @@ export function SettingsRadioSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Advertising & Discovery ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Hear & Be Heard</Label>
|
||||
<Label className="text-base">Advertising & 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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
|
||||
|
||||
interface UseContactsAndChannelsArgs {
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
@@ -112,6 +112,24 @@ export function useContactsAndChannels({
|
||||
[fetchUndecryptedCountInternal, setActiveConversation]
|
||||
);
|
||||
|
||||
const handleBulkCreateHashtagChannels = useCallback(
|
||||
async (
|
||||
channelNames: string[],
|
||||
tryHistorical: boolean
|
||||
): Promise<BulkCreateHashtagChannelsResult> => {
|
||||
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
|
||||
if (tryHistorical && result.decrypt_started) {
|
||||
fetchUndecryptedCountInternal();
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
[fetchUndecryptedCountInternal]
|
||||
);
|
||||
|
||||
const handleDeleteChannel = useCallback(
|
||||
async (key: string) => {
|
||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||
@@ -190,6 +208,7 @@ export function useContactsAndChannels({
|
||||
handleCreateContact,
|
||||
handleCreateChannel,
|
||||
handleCreateHashtagChannel,
|
||||
handleBulkCreateHashtagChannels,
|
||||
handleDeleteChannel,
|
||||
handleDeleteContact,
|
||||
};
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
--badge-mention: var(--destructive);
|
||||
--badge-mention-foreground: var(--destructive-foreground);
|
||||
|
||||
/* Sidebar navigation accents */
|
||||
--sidebar-icon-color: hsl(var(--foreground));
|
||||
--sidebar-icon-hover-color: hsl(var(--foreground));
|
||||
--sidebar-icon-active-color: hsl(var(--foreground));
|
||||
--sidebar-label-color: hsl(var(--muted-foreground));
|
||||
--sidebar-label-hover-color: hsl(var(--foreground));
|
||||
--sidebar-label-active-color: hsl(var(--foreground));
|
||||
|
||||
/* Error toast */
|
||||
--toast-error: 0 30% 14%;
|
||||
--toast-error-foreground: 0 56% 77%;
|
||||
@@ -126,6 +134,50 @@
|
||||
animation: message-highlight 2s ease-out forwards;
|
||||
}
|
||||
|
||||
.sidebar-tool-icon {
|
||||
display: inline-flex;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.45rem;
|
||||
color: var(--sidebar-icon-color);
|
||||
opacity: 1;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
opacity 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-tool-icon svg {
|
||||
stroke-width: 2.35;
|
||||
}
|
||||
|
||||
.sidebar-tool-label {
|
||||
color: var(--sidebar-label-color);
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-action-row:hover .sidebar-tool-icon,
|
||||
.sidebar-action-row:focus-visible .sidebar-tool-icon {
|
||||
color: var(--sidebar-icon-hover-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-action-row:hover .sidebar-tool-label,
|
||||
.sidebar-action-row:focus-visible .sidebar-tool-label {
|
||||
color: var(--sidebar-label-hover-color);
|
||||
}
|
||||
|
||||
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
|
||||
color: var(--sidebar-icon-active-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
|
||||
color: var(--sidebar-label-active-color);
|
||||
}
|
||||
|
||||
/* Constrain CodeMirror editor width */
|
||||
.cm-editor {
|
||||
max-width: 100% !important;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
|
||||
|
||||
describe('BulkAddChannelResultModal', () => {
|
||||
it('renders links only for newly created rooms', () => {
|
||||
render(
|
||||
<BulkAddChannelResultModal
|
||||
open
|
||||
onClose={() => {}}
|
||||
result={{
|
||||
created_channels: [
|
||||
{
|
||||
key: 'AA'.repeat(16),
|
||||
name: '#ops',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
},
|
||||
{
|
||||
key: 'BB'.repeat(16),
|
||||
name: '#mesh-room',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
},
|
||||
],
|
||||
existing_count: 3,
|
||||
invalid_names: ['bad_room'],
|
||||
decrypt_started: true,
|
||||
decrypt_total_packets: 8,
|
||||
message: 'Created 2 rooms',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const opsLink = screen.getByRole('link', { name: '#ops' });
|
||||
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
|
||||
|
||||
expect(opsLink.getAttribute('href')).toContain('#channel/');
|
||||
expect(meshLink.getAttribute('href')).toContain('#channel/');
|
||||
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
|
||||
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,85 @@ describe('MessageList channel sender rendering', () => {
|
||||
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders valid channel references as clickable links and ignores invalid ones', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChannelReferenceClick = vi.fn();
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
text: 'Alice: Join #mesh-room now skip #bad--room and visit https://example.com/#also-skip',
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const linkedChannel = screen.getByRole('button', { name: '#mesh-room' });
|
||||
expect(linkedChannel).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://example.com/#also-skip' })
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(linkedChannel);
|
||||
|
||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
||||
});
|
||||
|
||||
it('links valid channel references when followed by clause punctuation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChannelReferenceClick = vi.fn();
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
|
||||
await user.click(screen.getByRole('button', { name: '#ops-room' }));
|
||||
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
|
||||
|
||||
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
|
||||
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
|
||||
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
|
||||
});
|
||||
|
||||
it('links valid channel references in direct messages too', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChannelReferenceClick = vi.fn();
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
type: 'PRIV',
|
||||
text: 'check #ops-room',
|
||||
conversation_key: 'ab'.repeat(32),
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '#ops-room' }));
|
||||
|
||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room');
|
||||
});
|
||||
|
||||
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
|
||||
const user = userEvent.setup();
|
||||
const messages = [
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
findLinkedChannelReferences,
|
||||
formatTime,
|
||||
isValidLinkedChannelName,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
|
||||
describe('parseSenderFromText', () => {
|
||||
it('extracts sender and content from "sender: message" format', () => {
|
||||
@@ -95,3 +100,43 @@ describe('formatTime', () => {
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
|
||||
});
|
||||
});
|
||||
|
||||
describe('linked channel references', () => {
|
||||
it('accepts lowercase alphanumeric names with single dashes', () => {
|
||||
expect(isValidLinkedChannelName('ops')).toBe(true);
|
||||
expect(isValidLinkedChannelName('ops-1')).toBe(true);
|
||||
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
|
||||
expect(isValidLinkedChannelName('Ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('-ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops-')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops--room')).toBe(false);
|
||||
});
|
||||
|
||||
it('finds standalone linked channel references in message text', () => {
|
||||
expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([
|
||||
{ label: '#mesh-room', start: 5, end: 15 },
|
||||
{ label: '#ops2', start: 31, end: 36 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds linked channel references terminated by clause punctuation', () => {
|
||||
expect(
|
||||
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
|
||||
).toEqual([
|
||||
{ label: '#mesh-room', start: 5, end: 15 },
|
||||
{ label: '#ops2', start: 22, end: 27 },
|
||||
{ label: '#alpha-room', start: 37, end: 48 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores invalid or embedded channel-like text', () => {
|
||||
const references = findLinkedChannelReferences(
|
||||
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
||||
);
|
||||
|
||||
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,12 +27,16 @@ describe('NewMessageModal form reset', () => {
|
||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderModal(open = true) {
|
||||
function renderModal(
|
||||
open = true,
|
||||
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
|
||||
) {
|
||||
return render(
|
||||
<NewMessageModal
|
||||
open={open}
|
||||
@@ -41,6 +45,8 @@ describe('NewMessageModal form reset', () => {
|
||||
onCreateContact={onCreateContact}
|
||||
onCreateChannel={onCreateChannel}
|
||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
|
||||
{...overrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +56,26 @@ describe('NewMessageModal form reset', () => {
|
||||
}
|
||||
|
||||
describe('hashtag tab', () => {
|
||||
it('prefills the hashtag tab from a linked channel request', async () => {
|
||||
renderModal(true, {
|
||||
prefillRequest: {
|
||||
tab: 'hashtag',
|
||||
hashtagName: 'mesh-room',
|
||||
nonce: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active'
|
||||
);
|
||||
});
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe(
|
||||
'mesh-room'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears name after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderModal();
|
||||
@@ -87,6 +113,53 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk hashtag tab', () => {
|
||||
it('is only visible when enabled', () => {
|
||||
renderModal();
|
||||
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
|
||||
});
|
||||
|
||||
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal(true, { showBulkAddChannelTab: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active'
|
||||
);
|
||||
});
|
||||
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||
'#Ops{enter}mesh-room another-room #Ops'
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
|
||||
['#ops', '#mesh-room', '#another-room'],
|
||||
false
|
||||
);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows invalid bulk room names before submitting', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal(true, { showBulkAddChannelTab: true });
|
||||
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||
'good-room bad_room'
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||
|
||||
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-contact tab', () => {
|
||||
it('clears name and key after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Strongest Neighbor')).toBeInTheDocument();
|
||||
expect(screen.getByText('-70 dBm best heard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks unresolved neighbor identities explicitly', () => {
|
||||
|
||||
@@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
|
||||
useRepeaterDashboard: () => mockHook,
|
||||
}));
|
||||
|
||||
// Mock api module (TelemetryHistoryPane fetches on mount)
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
|
||||
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock sonner toast
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: {
|
||||
@@ -118,6 +126,16 @@ const defaultProps = {
|
||||
onDeleteContact: vi.fn(),
|
||||
};
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('RepeaterDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -418,6 +436,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
telemetry_history: [],
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
@@ -634,4 +653,106 @@ describe('RepeaterDashboard', () => {
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry history', () => {
|
||||
beforeEach(async () => {
|
||||
const { api } = await import('../api');
|
||||
vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('loads telemetry history on mount when logged in', async () => {
|
||||
const { api } = await import('../api');
|
||||
mockHook.loggedIn = true;
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows telemetry history pane in logged-in view even before status fetch', () => {
|
||||
mockHook.loggedIn = true;
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 samples')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates history from live status fetch', async () => {
|
||||
const { api } = await import('../api');
|
||||
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
|
||||
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
|
||||
historySpy.mockResolvedValue([]);
|
||||
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.status = {
|
||||
battery_volts: 4.2,
|
||||
tx_queue_len: 0,
|
||||
noise_floor_dbm: -120,
|
||||
last_rssi_dbm: -85,
|
||||
last_snr_db: 7.5,
|
||||
packets_received: 100,
|
||||
packets_sent: 50,
|
||||
airtime_seconds: 600,
|
||||
rx_airtime_seconds: 1200,
|
||||
uptime_seconds: 86400,
|
||||
sent_flood: 10,
|
||||
sent_direct: 40,
|
||||
recv_flood: 30,
|
||||
recv_direct: 70,
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
telemetry_history: [liveEntry],
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let an older preload overwrite newer live status history', async () => {
|
||||
const { api } = await import('../api');
|
||||
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
|
||||
const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>();
|
||||
historySpy.mockReturnValue(deferred.promise);
|
||||
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.status = {
|
||||
battery_volts: 4.2,
|
||||
tx_queue_len: 0,
|
||||
noise_floor_dbm: -120,
|
||||
last_rssi_dbm: -85,
|
||||
last_snr_db: 7.5,
|
||||
packets_received: 100,
|
||||
packets_sent: 50,
|
||||
airtime_seconds: 600,
|
||||
rx_airtime_seconds: 1200,
|
||||
uptime_seconds: 86400,
|
||||
sent_flood: 10,
|
||||
sent_direct: 40,
|
||||
recv_flood: 30,
|
||||
recv_direct: 70,
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]);
|
||||
await deferred.promise;
|
||||
|
||||
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ const baseSettings: AppSettings = {
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
@@ -615,10 +616,10 @@ describe('SettingsModal', () => {
|
||||
openDatabaseSection();
|
||||
|
||||
expect(
|
||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
||||
screen.getByText(/removes packet-analysis availability for those messages/i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
||||
|
||||
@@ -7,3 +7,19 @@ class ResizeObserver {
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
// Several components call matchMedia at import time for responsive detection
|
||||
if (typeof globalThis.matchMedia === 'undefined') {
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +122,46 @@ describe('Sidebar section summaries', () => {
|
||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a full add channel/contact button above search and calls onNewMessage', () => {
|
||||
const onNewMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[]}
|
||||
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={onNewMessage}
|
||||
lastMessageTimes={{}}
|
||||
unreadCounts={{}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: 'Add channel or contact' });
|
||||
const search = screen.getByLabelText('Search conversations');
|
||||
const nav = screen.getByRole('navigation', { name: 'Conversations' });
|
||||
const toolsButton = screen.getByRole('button', { name: 'Tools' });
|
||||
|
||||
expect(addButton).toHaveTextContent('Add Channel/Contact');
|
||||
expect(
|
||||
addButton.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
expect(nav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_CONTAINED_BY).toBeTruthy();
|
||||
expect(
|
||||
search.compareDocumentPosition(toolsButton) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(addButton);
|
||||
expect(onNewMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('turns favorites and channels rollups red when they contain a mention', () => {
|
||||
renderSidebar({
|
||||
mentions: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
||||
import type { Contact } from '../types';
|
||||
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('../api', () => ({
|
||||
@@ -18,6 +18,7 @@ vi.mock('../api', () => ({
|
||||
getChannels: vi.fn(),
|
||||
createContact: vi.fn(),
|
||||
createChannel: vi.fn(),
|
||||
bulkCreateHashtagChannels: vi.fn(),
|
||||
deleteContact: vi.fn(),
|
||||
deleteChannel: vi.fn(),
|
||||
decryptHistoricalPackets: vi.fn(),
|
||||
@@ -171,4 +172,41 @@ describe('useContactsAndChannels', () => {
|
||||
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk hashtag creation', () => {
|
||||
it('refreshes channels and returns the backend result', async () => {
|
||||
const { api } = await import('../api');
|
||||
const resultPayload: BulkCreateHashtagChannelsResult = {
|
||||
created_channels: [
|
||||
{
|
||||
key: 'AA'.repeat(16),
|
||||
name: '#ops',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
},
|
||||
],
|
||||
existing_count: 1,
|
||||
invalid_names: [],
|
||||
decrypt_started: true,
|
||||
decrypt_total_packets: 12,
|
||||
message: 'Created 1 room',
|
||||
};
|
||||
vi.mocked(api.bulkCreateHashtagChannels).mockResolvedValueOnce(resultPayload);
|
||||
vi.mocked(api.getChannels).mockResolvedValueOnce(resultPayload.created_channels);
|
||||
vi.mocked(api.getUndecryptedPacketCount).mockResolvedValueOnce({ count: 9 });
|
||||
|
||||
const { result } = renderUseContactsAndChannels();
|
||||
|
||||
let response: BulkCreateHashtagChannelsResult | null = null;
|
||||
await act(async () => {
|
||||
response = await result.current.handleBulkCreateHashtagChannels(['#ops'], true);
|
||||
});
|
||||
|
||||
expect(api.bulkCreateHashtagChannels).toHaveBeenCalledWith(['#ops'], true);
|
||||
expect(api.getChannels).toHaveBeenCalled();
|
||||
expect(api.getUndecryptedPacketCount).toHaveBeenCalled();
|
||||
expect(response).toEqual(resultPayload);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,10 +49,6 @@
|
||||
--overlay: 220 20% 10%;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .sidebar-tool-label {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ── Windows 95 ───────────────────────────────────────────── */
|
||||
:root[data-theme='windows-95'] {
|
||||
--background: 180 100% 25%;
|
||||
|
||||
@@ -235,6 +235,15 @@ export interface ChannelTopSender {
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface BulkCreateHashtagChannelsResult {
|
||||
created_channels: Channel[];
|
||||
existing_count: number;
|
||||
invalid_names: string[];
|
||||
decrypt_started: boolean;
|
||||
decrypt_total_packets: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ChannelDetail {
|
||||
channel: Channel;
|
||||
message_counts: ChannelMessageCounts;
|
||||
@@ -332,6 +341,7 @@ export interface AppSettings {
|
||||
flood_scope: string;
|
||||
blocked_keys: string[];
|
||||
blocked_names: string[];
|
||||
discovery_blocked_types: number[];
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -342,6 +352,7 @@ export interface AppSettingsUpdate {
|
||||
flood_scope?: string;
|
||||
blocked_keys?: string[];
|
||||
blocked_names?: string[];
|
||||
discovery_blocked_types?: number[];
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
@@ -405,6 +416,7 @@ export interface RepeaterStatusResponse {
|
||||
flood_dups: number;
|
||||
direct_dups: number;
|
||||
full_events: number;
|
||||
telemetry_history: TelemetryHistoryEntry[];
|
||||
}
|
||||
|
||||
export interface RepeaterNeighborsResponse {
|
||||
@@ -468,6 +480,11 @@ export interface PaneState {
|
||||
fetched_at?: number | null;
|
||||
}
|
||||
|
||||
export interface TelemetryHistoryEntry {
|
||||
timestamp: number;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface TraceResponse {
|
||||
remote_snr: number | null;
|
||||
local_snr: number | null;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* Parse sender from channel message text.
|
||||
* Channel messages have format "sender: message".
|
||||
*/
|
||||
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
|
||||
|
||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||
const colonIndex = text.indexOf(': ');
|
||||
if (colonIndex > 0 && colonIndex < 50) {
|
||||
@@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont
|
||||
return { sender: null, content: text };
|
||||
}
|
||||
|
||||
export interface HashtagChannelReference {
|
||||
label: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function isValidLinkedChannelName(name: string): boolean {
|
||||
return HASHTAG_CHANNEL_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
export function findLinkedChannelReferences(text: string): HashtagChannelReference[] {
|
||||
const references: HashtagChannelReference[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
HASHTAG_CHANNEL_REFERENCE_PATTERN.lastIndex = 0;
|
||||
while ((match = HASHTAG_CHANNEL_REFERENCE_PATTERN.exec(text)) !== null) {
|
||||
const prefix = match[1];
|
||||
const label = match[2];
|
||||
const start = match.index + prefix.length;
|
||||
references.push({
|
||||
label,
|
||||
start,
|
||||
end: start + label.length,
|
||||
});
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp to a time string.
|
||||
* Shows date for messages not from today.
|
||||
|
||||
@@ -106,9 +106,6 @@ export interface RawPacketStatsSnapshot {
|
||||
medianRssi: number | null;
|
||||
bestRssi: number | null;
|
||||
rssiBuckets: RankedPacketStat[];
|
||||
strongestPacketSourceKey: string | null;
|
||||
strongestPacketSourceLabel: string | null;
|
||||
strongestPacketPayloadType: string | null;
|
||||
coverageSeconds: number;
|
||||
windowFullyCovered: boolean;
|
||||
oldestStoredTimestamp: number | null;
|
||||
@@ -377,8 +374,6 @@ export function buildRawPacketStatsSnapshot(
|
||||
['Weak (<-85 dBm)', 0],
|
||||
]);
|
||||
|
||||
let strongestPacket: RawPacketStatsObservation | null = null;
|
||||
|
||||
for (const packet of packets) {
|
||||
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
|
||||
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
|
||||
@@ -436,10 +431,6 @@ export function buildRawPacketStatsSnapshot(
|
||||
} else {
|
||||
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) {
|
||||
strongestPacket = packet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,9 +518,6 @@ export function buildRawPacketStatsSnapshot(
|
||||
medianRssi,
|
||||
bestRssi,
|
||||
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
|
||||
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
|
||||
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
|
||||
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
|
||||
coverageSeconds,
|
||||
windowFullyCovered,
|
||||
oldestStoredTimestamp,
|
||||
|
||||
@@ -147,7 +147,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
|
||||
}
|
||||
|
||||
// Generate URL hash from conversation
|
||||
function getConversationHash(conv: Conversation | null): string {
|
||||
export function getConversationHash(conv: Conversation | null): string {
|
||||
if (!conv) return '';
|
||||
if (conv.type === 'raw') return '#raw';
|
||||
if (conv.type === 'map') return '#map';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.6.3"
|
||||
version = "3.7.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Regular → Executable
@@ -0,0 +1,106 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/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}"
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/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"
|
||||
Regular → Executable
Regular → Executable
+93
-155
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -10,95 +10,63 @@ NC='\033[0m' # No Color
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
RELEASE_WORK_DIR=""
|
||||
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
|
||||
RELEASE_ASSET=""
|
||||
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
|
||||
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"
|
||||
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
|
||||
VERSION=""
|
||||
NOTES_FILE=""
|
||||
SKIP_QUALITY=0
|
||||
RELEASE_ASSET_PATH=""
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}"
|
||||
echo
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
|
||||
bash scripts/build/collect_licenses.sh LICENSES.md
|
||||
@@ -113,13 +81,11 @@ echo -n " package.json: "
|
||||
grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
||||
echo
|
||||
|
||||
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
|
||||
if [ -z "$VERSION" ]; then
|
||||
read -r -p "Enter new version (e.g., 1.2.3): " VERSION
|
||||
fi
|
||||
VERSION="$(release_trim "$VERSION")"
|
||||
release_validate_version "$VERSION"
|
||||
|
||||
# Update pyproject.toml
|
||||
echo -e "${YELLOW}Updating pyproject.toml...${NC}"
|
||||
@@ -137,11 +103,28 @@ echo -e "${GREEN}Version updated to $VERSION${NC}"
|
||||
echo
|
||||
|
||||
# Prompt for changelog entry
|
||||
echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}"
|
||||
echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}"
|
||||
echo
|
||||
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
|
||||
|
||||
CHANGELOG_ENTRY=$(cat)
|
||||
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"
|
||||
|
||||
# Create changelog entry with date
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
@@ -157,7 +140,7 @@ if [ -f CHANGELOG.md ]; then
|
||||
echo
|
||||
echo "$CHANGELOG_HEADER"
|
||||
echo
|
||||
echo "$CHANGELOG_ENTRY"
|
||||
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||
echo
|
||||
tail -n +2 CHANGELOG.md
|
||||
} > CHANGELOG.md.tmp
|
||||
@@ -167,7 +150,7 @@ if [ -f CHANGELOG.md ]; then
|
||||
{
|
||||
echo "$CHANGELOG_HEADER"
|
||||
echo
|
||||
echo "$CHANGELOG_ENTRY"
|
||||
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||
echo
|
||||
cat CHANGELOG.md
|
||||
} > CHANGELOG.md.tmp
|
||||
@@ -180,7 +163,7 @@ else
|
||||
echo
|
||||
echo "$CHANGELOG_HEADER"
|
||||
echo
|
||||
echo "$CHANGELOG_ENTRY"
|
||||
cat "$FORMATTED_CHANGELOG_INPUT_FILE"
|
||||
} > CHANGELOG.md
|
||||
fi
|
||||
|
||||
@@ -200,78 +183,33 @@ 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}"
|
||||
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")"
|
||||
)
|
||||
scripts/build/package_release_artifact.sh \
|
||||
--version "$VERSION" \
|
||||
--git-hash "$GIT_HASH" \
|
||||
--full-git-hash "$FULL_GIT_HASH" \
|
||||
--output "$RELEASE_ASSET_PATH"
|
||||
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}"
|
||||
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 \
|
||||
.
|
||||
scripts/build/push_docker_multiarch.sh \
|
||||
--version "$VERSION" \
|
||||
--git-hash "$GIT_HASH" \
|
||||
--image "$DOCKER_IMAGE" \
|
||||
--platforms "$DOCKER_PLATFORMS"
|
||||
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}"
|
||||
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"
|
||||
scripts/build/create_github_release.sh \
|
||||
--version "$VERSION" \
|
||||
--full-git-hash "$FULL_GIT_HASH" \
|
||||
--asset "$RELEASE_ASSET_PATH"
|
||||
echo -e "${GREEN}GitHub release created!${NC}"
|
||||
echo
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/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[@]}"
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/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
|
||||
}
|
||||
Regular → Executable
+1
-1
@@ -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/ --check --quiet
|
||||
uv run ruff format app/ tests/ --quiet
|
||||
echo -e "${GREEN}Passed!${NC}"
|
||||
|
||||
echo -ne "${BLUE}[frontend lint]${NC} "
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+184
-12
@@ -21,10 +21,18 @@ NC='\033[0m'
|
||||
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"
|
||||
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"
|
||||
|
||||
IMAGE_MODE="image"
|
||||
TRANSPORT_MODE="serial"
|
||||
SERIAL_HOST_PATH="/dev/ttyACM0"
|
||||
SERIAL_COMPOSE_HOST_PATH="/dev/ttyACM0"
|
||||
SERIAL_CONTAINER_PATH="/dev/meshcore-radio"
|
||||
TCP_HOST=""
|
||||
TCP_PORT="4000"
|
||||
@@ -35,7 +43,9 @@ ENABLE_AUTH="N"
|
||||
AUTH_USERNAME=""
|
||||
AUTH_PASSWORD=""
|
||||
RUN_AS_HOST_USER="N"
|
||||
ENABLE_SNAKEOIL_TLS="Y"
|
||||
BLE_MANUAL_WARNING=false
|
||||
LOCAL_ACCESS_IP=""
|
||||
SERIAL_FOUND_HOST_PATHS=()
|
||||
SERIAL_FOUND_LABELS=()
|
||||
SERIAL_FOUND_DISPLAYS=()
|
||||
@@ -89,6 +99,118 @@ yaml_quote() {
|
||||
printf "'%s'" "$value"
|
||||
}
|
||||
|
||||
normalize_serial_host_path_for_compose() {
|
||||
local selected_path="$1"
|
||||
local resolved_path=""
|
||||
|
||||
if [[ "$selected_path" != *:* ]]; then
|
||||
SERIAL_COMPOSE_HOST_PATH="$selected_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
resolved_path="$(readlink -f "$selected_path" 2>/dev/null || true)"
|
||||
if [ -z "$resolved_path" ]; then
|
||||
echo -e "${RED}Error:${NC} the selected serial path contains ':' and could not be resolved to a raw /dev/tty-style device path."
|
||||
echo "Selected path: $selected_path"
|
||||
echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$resolved_path" == *:* ]]; then
|
||||
echo -e "${RED}Error:${NC} the selected serial path still resolves to a path containing ':', which Docker Compose cannot use here."
|
||||
echo "Selected path: $selected_path"
|
||||
echo "Resolved path: $resolved_path"
|
||||
echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Note:${NC} the selected serial path contains ':', so Docker Compose will use the resolved raw device path instead: ${resolved_path}"
|
||||
SERIAL_COMPOSE_HOST_PATH="$resolved_path"
|
||||
}
|
||||
|
||||
detect_primary_local_ip() {
|
||||
local ip=""
|
||||
local iface=""
|
||||
|
||||
if command -v hostname &>/dev/null; then
|
||||
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
if [ -z "$ip" ] && command -v ip &>/dev/null; then
|
||||
ip="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i = 1; i <= NF; i++) if ($i == "src") {print $(i + 1); exit}}')"
|
||||
fi
|
||||
|
||||
if [ -z "$ip" ] && command -v route &>/dev/null && command -v ipconfig &>/dev/null; then
|
||||
iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
|
||||
if [ -n "$iface" ]; then
|
||||
ip="$(ipconfig getifaddr "$iface" 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ip" ]; then
|
||||
ip="127.0.0.1"
|
||||
fi
|
||||
|
||||
printf '%s' "$ip"
|
||||
}
|
||||
|
||||
ensure_snakeoil_requirements() {
|
||||
local dep
|
||||
|
||||
for dep in openssl mktemp; do
|
||||
if ! command -v "$dep" &>/dev/null; then
|
||||
echo -e "${RED}Error: ${dep} is required to generate the snakeoil TLS certificate.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
generate_snakeoil_certificate() {
|
||||
local san_ip="$1"
|
||||
local tmp_config=""
|
||||
|
||||
mkdir -p "$SNAKEOIL_CERT_DIR"
|
||||
tmp_config="$(mktemp)"
|
||||
|
||||
cat >"$tmp_config" <<EOF
|
||||
[req]
|
||||
default_bits = 2048
|
||||
distinguished_name = req_distinguished_name
|
||||
x509_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = RemoteTerm Snakeoil
|
||||
O = RemoteTerm for MeshCore
|
||||
|
||||
[v3_req]
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
IP.1 = 127.0.0.1
|
||||
EOF
|
||||
|
||||
if [ -n "$san_ip" ] && [ "$san_ip" != "127.0.0.1" ]; then
|
||||
printf 'IP.2 = %s\n' "$san_ip" >>"$tmp_config"
|
||||
fi
|
||||
|
||||
openssl req \
|
||||
-x509 \
|
||||
-nodes \
|
||||
-newkey rsa:2048 \
|
||||
-days 3650 \
|
||||
-keyout "$SNAKEOIL_KEY_HOST_PATH" \
|
||||
-out "$SNAKEOIL_CERT_HOST_PATH" \
|
||||
-config "$tmp_config" \
|
||||
-extensions v3_req >/dev/null 2>&1
|
||||
|
||||
rm -f "$tmp_config"
|
||||
|
||||
chmod 600 "$SNAKEOIL_KEY_HOST_PATH"
|
||||
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
|
||||
}
|
||||
|
||||
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
|
||||
echo
|
||||
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
|
||||
@@ -183,7 +305,8 @@ case "$TRANSPORT_CHOICE" in
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Serial passthrough: ${SERIAL_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}"
|
||||
normalize_serial_host_path_for_compose "$SERIAL_HOST_PATH"
|
||||
echo -e "${GREEN}Serial passthrough: ${SERIAL_COMPOSE_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}"
|
||||
;;
|
||||
2)
|
||||
TRANSPORT_MODE="tcp"
|
||||
@@ -266,6 +389,24 @@ else
|
||||
fi
|
||||
echo
|
||||
|
||||
echo -e "${BOLD}─── HTTPS / Snakeoil TLS ────────────────────────────────────────────${NC}"
|
||||
echo "Generating a local self-signed certificate enables HTTPS-only browser features"
|
||||
echo "such as the channel key finder and, in some browsers, notifications."
|
||||
echo "Browsers will still warn that the certificate is untrusted."
|
||||
echo
|
||||
read -r -p "Generate and enable a snakeoil TLS certificate? [Y/n]: " ENABLE_SNAKEOIL_TLS
|
||||
ENABLE_SNAKEOIL_TLS="${ENABLE_SNAKEOIL_TLS:-Y}"
|
||||
LOCAL_ACCESS_IP="$(detect_primary_local_ip)"
|
||||
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
|
||||
ensure_snakeoil_requirements
|
||||
generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
|
||||
echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_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}"
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "$(uname -s)" = "Linux" ]; then
|
||||
echo -e "${BOLD}─── Container User ──────────────────────────────────────────────────${NC}"
|
||||
echo "The container runs as root by default for maximum serial compatibility."
|
||||
@@ -295,7 +436,7 @@ mkdir -p "$REPO_DIR/data"
|
||||
if [ "$IMAGE_MODE" = "build" ]; then
|
||||
echo " build: ."
|
||||
else
|
||||
echo " image: jkingsman/remoteterm-meshcore:latest"
|
||||
echo " image: docker.io/jkingsman/remoteterm-meshcore:latest"
|
||||
fi
|
||||
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
|
||||
echo " user: \"$(id -u):$(id -g)\""
|
||||
@@ -304,9 +445,27 @@ mkdir -p "$REPO_DIR/data"
|
||||
echo " - \"8000:8000\""
|
||||
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_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
|
||||
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")"
|
||||
@@ -333,15 +492,18 @@ echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
|
||||
echo
|
||||
echo -e "${BOLD}Docker commands${NC}"
|
||||
if [ "$IMAGE_MODE" = "build" ]; then
|
||||
echo " docker compose up -d --build # build the local image and start RemoteTerm in the background"
|
||||
echo " sudo docker compose up -d --build # build the local image and start RemoteTerm in the background"
|
||||
else
|
||||
echo " docker compose up -d # start RemoteTerm in the background"
|
||||
echo " sudo docker compose up -d # start RemoteTerm in the background"
|
||||
fi
|
||||
echo " docker compose logs -f # follow the container logs live"
|
||||
echo " sudo docker compose logs -f # follow the container logs live"
|
||||
echo
|
||||
echo " docker compose down # stop and remove the running container"
|
||||
echo " docker compose restart # restart the container without changing the image"
|
||||
echo " docker compose pull && docker compose up -d # upgrade to the latest published image and restart"
|
||||
echo " sudo docker compose down # stop and remove the running container"
|
||||
echo " sudo docker compose restart # restart the container without changing the image"
|
||||
echo " sudo docker compose pull && sudo docker compose up -d # upgrade to the latest published image and restart"
|
||||
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 [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
|
||||
echo
|
||||
echo -e "${RED}BLE requires more than the generated env vars.${NC}"
|
||||
@@ -351,6 +513,16 @@ echo
|
||||
echo -e "${GREEN}Your new docker file is ready at ${COMPOSE_FILE}.${NC}"
|
||||
echo -e "${GREEN}Feel free to edit it by hand as desired, or:${NC}"
|
||||
echo
|
||||
echo -e "${PURPLE}┌──────────────────────────────────────────────┐${NC}"
|
||||
echo -e "${PURPLE}│ Run ${GREEN}${BOLD}docker compose up -d${NC}${PURPLE} to get started. │${NC}"
|
||||
echo -e "${PURPLE}└──────────────────────────────────────────────┘${NC}"
|
||||
echo -e "${PURPLE}┌───────────────────────────────────────────────┐${NC}"
|
||||
echo -e "${PURPLE}│ Run ${GREEN}${BOLD}sudo docker compose up -d${NC}${PURPLE} to get started. │${NC}"
|
||||
echo -e "${PURPLE}└───────────────────────────────────────────────┘${NC}"
|
||||
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 "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
|
||||
else
|
||||
echo
|
||||
echo -e "After the container starts, open ${CYAN}http://${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."
|
||||
fi
|
||||
echo "If the interface does not appear, follow the logs to view errors with:"
|
||||
echo " sudo docker compose logs -f"
|
||||
|
||||
Regular → Executable
@@ -63,6 +63,7 @@ export default defineConfig({
|
||||
timeout: 180_000,
|
||||
env: {
|
||||
MESHCORE_DATABASE_PATH: path.join(tmpDir, 'e2e-test.db'),
|
||||
MESHCORE_SKIP_POST_CONNECT_SYNC: 'true',
|
||||
// Pass through the serial port from the environment
|
||||
...(process.env.MESHCORE_SERIAL_PORT
|
||||
? { MESHCORE_SERIAL_PORT: process.env.MESHCORE_SERIAL_PORT }
|
||||
|
||||
@@ -19,7 +19,7 @@ test.describe('Create contact flow', () => {
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Open new message modal
|
||||
await page.getByTitle('New Message').click();
|
||||
await page.getByRole('button', { name: /add channel or contact/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
|
||||
@@ -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.getByTitle('New Message').click();
|
||||
await page.getByRole('button', { name: /add channel or contact/i }).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.getByTitle('New Message').click();
|
||||
await page.getByRole('button', { name: /add channel or contact/i }).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.getByTitle('New Message').click();
|
||||
await page.getByRole('button', { name: /add channel or contact/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: /Hashtag/i }).click();
|
||||
|
||||
+150
-5
@@ -131,6 +131,62 @@ 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."""
|
||||
@@ -157,8 +213,21 @@ 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
|
||||
@@ -213,6 +282,47 @@ 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."""
|
||||
@@ -1057,7 +1167,14 @@ 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)
|
||||
await RawPacketRepository.mark_decrypted(old_id, 1)
|
||||
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)
|
||||
|
||||
# Prune packets older than 10 days
|
||||
deleted = await RawPacketRepository.prune_old_undecrypted(10)
|
||||
@@ -1081,10 +1198,24 @@ 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, 101)
|
||||
await RawPacketRepository.mark_decrypted(linked_2, 102)
|
||||
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
|
||||
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
|
||||
|
||||
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
|
||||
|
||||
@@ -1122,10 +1253,24 @@ 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, 201)
|
||||
await RawPacketRepository.mark_decrypted(linked_2, 202)
|
||||
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
|
||||
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
|
||||
|
||||
request = MaintenanceRequest(purge_linked_raw_packets=True)
|
||||
result = await run_maintenance(request)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Tests for the channels router endpoints."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
from hashlib import sha256
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -77,6 +78,55 @@ class TestCreateChannel:
|
||||
assert channel is not None
|
||||
assert channel.flood_scope_override is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hashtag_create_adds_only_new_rooms(self, test_db, client):
|
||||
ops_key = sha256(b"#ops").digest()[:16].hex().upper()
|
||||
await ChannelRepository.upsert(key=ops_key, name="#ops", is_hashtag=True)
|
||||
|
||||
response = await client.post(
|
||||
"/api/channels/bulk-hashtag",
|
||||
json={
|
||||
"channel_names": ["#ops", "mesh-room", "bad_room", "mesh-room", "another-room"],
|
||||
"try_historical": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert [channel["name"] for channel in data["created_channels"]] == [
|
||||
"#mesh-room",
|
||||
"#another-room",
|
||||
]
|
||||
assert data["existing_count"] == 2
|
||||
assert data["invalid_names"] == ["bad_room"]
|
||||
assert data["decrypt_started"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hashtag_create_can_start_one_decrypt_job(self, test_db, client):
|
||||
with (
|
||||
patch(
|
||||
"app.routers.channels.RawPacketRepository.get_undecrypted_count",
|
||||
new=AsyncMock(return_value=7),
|
||||
),
|
||||
patch(
|
||||
"app.routers.channels._run_historical_channel_decryption_for_channels",
|
||||
new=AsyncMock(),
|
||||
) as mock_decrypt,
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/channels/bulk-hashtag",
|
||||
json={
|
||||
"channel_names": ["ops", "mesh-room"],
|
||||
"try_historical": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["decrypt_started"] is True
|
||||
assert data["decrypt_total_packets"] == 7
|
||||
mock_decrypt.assert_awaited_once()
|
||||
|
||||
|
||||
class TestPublicChannelProtection:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -33,7 +33,7 @@ class TestTransportExclusivity:
|
||||
|
||||
def test_tcp_default_port(self):
|
||||
s = Settings(tcp_host="192.168.1.1")
|
||||
assert s.tcp_port == 4000
|
||||
assert s.tcp_port == 5000
|
||||
|
||||
def test_ble_only(self):
|
||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
||||
|
||||
+19
-17
@@ -513,7 +513,9 @@ class TestMigration018:
|
||||
from hashlib import sha256
|
||||
|
||||
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
|
||||
assert rows[1]["message_id"] == 42
|
||||
# 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
|
||||
|
||||
# Verify payload_hash unique index still works
|
||||
cursor = await conn.execute(
|
||||
@@ -1247,8 +1249,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1319,8 +1321,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1386,8 +1388,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1439,8 +1441,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1501,8 +1503,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1554,8 +1556,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1694,8 +1696,8 @@ class TestMigration046:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1788,8 +1790,8 @@ class TestMigration047:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 47
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 50
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import radio_noise_floor
|
||||
|
||||
|
||||
class TestNoiseFloorSamplingLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_and_continues_after_unexpected_sample_exception(self):
|
||||
sample_calls = 0
|
||||
sleep_calls = 0
|
||||
|
||||
async def fake_sample() -> None:
|
||||
nonlocal sample_calls
|
||||
sample_calls += 1
|
||||
if sample_calls == 1:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def fake_sleep(_seconds: int) -> None:
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with (
|
||||
patch.object(radio_noise_floor, "sample_noise_floor_once", side_effect=fake_sample),
|
||||
patch.object(radio_noise_floor.asyncio, "sleep", side_effect=fake_sleep),
|
||||
patch.object(radio_noise_floor.logger, "exception") as mock_exception,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await radio_noise_floor._noise_floor_sampling_loop()
|
||||
|
||||
assert sample_calls == 2
|
||||
assert sleep_calls == 2
|
||||
mock_exception.assert_called_once()
|
||||
@@ -381,6 +381,11 @@ 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"))
|
||||
@@ -454,6 +459,11 @@ 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"))
|
||||
@@ -779,6 +789,11 @@ 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"))
|
||||
|
||||
@@ -884,6 +884,11 @@ 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),
|
||||
):
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for repeater telemetry history: repository CRUD and embedded status response."""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER
|
||||
from app.repository import (
|
||||
ContactRepository,
|
||||
RepeaterTelemetryRepository,
|
||||
)
|
||||
|
||||
KEY_A = "aa" * 32
|
||||
KEY_B = "bb" * 32
|
||||
|
||||
SAMPLE_STATUS = {
|
||||
"battery_volts": 4.15,
|
||||
"tx_queue_len": 0,
|
||||
"noise_floor_dbm": -100,
|
||||
"last_rssi_dbm": -80,
|
||||
"last_snr_db": 5.0,
|
||||
"packets_received": 100,
|
||||
"packets_sent": 50,
|
||||
"airtime_seconds": 300,
|
||||
"rx_airtime_seconds": 200,
|
||||
"uptime_seconds": 1000,
|
||||
"sent_flood": 10,
|
||||
"sent_direct": 40,
|
||||
"recv_flood": 60,
|
||||
"recv_direct": 40,
|
||||
"flood_dups": 5,
|
||||
"direct_dups": 2,
|
||||
"full_events": 0,
|
||||
}
|
||||
|
||||
|
||||
async def _insert_repeater(public_key: str, name: str = "Repeater"):
|
||||
"""Insert a repeater contact into the test database."""
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": CONTACT_TYPE_REPEATER,
|
||||
"flags": 0,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
"first_seen": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def _db(test_db):
|
||||
"""Set up test DB and patch the repeater_telemetry module's db reference."""
|
||||
from app.repository import repeater_telemetry
|
||||
|
||||
original = repeater_telemetry.db
|
||||
repeater_telemetry.db = test_db
|
||||
try:
|
||||
yield test_db
|
||||
finally:
|
||||
repeater_telemetry.db = original
|
||||
|
||||
|
||||
class TestRepeaterTelemetryRepository:
|
||||
"""Tests for RepeaterTelemetryRepository CRUD operations with JSON blob storage."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_and_get_history(self, _db):
|
||||
await _insert_repeater(KEY_A)
|
||||
now = int(time.time())
|
||||
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=KEY_A,
|
||||
timestamp=now - 3600,
|
||||
data={**SAMPLE_STATUS, "battery_volts": 4.15},
|
||||
)
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=KEY_A,
|
||||
timestamp=now,
|
||||
data={**SAMPLE_STATUS, "battery_volts": 4.10},
|
||||
)
|
||||
|
||||
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200)
|
||||
assert len(history) == 2
|
||||
assert history[0]["data"]["battery_volts"] == 4.15
|
||||
assert history[1]["data"]["battery_volts"] == 4.10
|
||||
assert history[0]["timestamp"] < history[1]["timestamp"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_filters_by_time(self, _db):
|
||||
await _insert_repeater(KEY_A)
|
||||
now = int(time.time())
|
||||
|
||||
await RepeaterTelemetryRepository.record(KEY_A, now - 7200, SAMPLE_STATUS)
|
||||
await RepeaterTelemetryRepository.record(KEY_A, now - 3600, SAMPLE_STATUS)
|
||||
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||
|
||||
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601)
|
||||
assert len(history) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_isolates_by_key(self, _db):
|
||||
await _insert_repeater(KEY_A)
|
||||
await _insert_repeater(KEY_B)
|
||||
now = int(time.time())
|
||||
|
||||
await RepeaterTelemetryRepository.record(
|
||||
KEY_A, now, {**SAMPLE_STATUS, "battery_volts": 4.1}
|
||||
)
|
||||
await RepeaterTelemetryRepository.record(
|
||||
KEY_B, now, {**SAMPLE_STATUS, "battery_volts": 3.9}
|
||||
)
|
||||
|
||||
history_a = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
|
||||
history_b = await RepeaterTelemetryRepository.get_history(KEY_B, 0)
|
||||
assert len(history_a) == 1
|
||||
assert len(history_b) == 1
|
||||
assert history_a[0]["data"]["battery_volts"] == 4.1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_stored_as_json(self, _db):
|
||||
"""Verify the data column stores valid JSON that round-trips correctly."""
|
||||
await _insert_repeater(KEY_A)
|
||||
now = int(time.time())
|
||||
|
||||
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||
history = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
|
||||
assert len(history) == 1
|
||||
assert history[0]["data"] == SAMPLE_STATUS
|
||||
|
||||
|
||||
class TestTelemetryHistoryEndpoint:
|
||||
"""Tests for the read-only GET telemetry-history endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_history_for_repeater(self, _db, client):
|
||||
await _insert_repeater(KEY_A)
|
||||
now = int(time.time())
|
||||
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||
|
||||
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["data"]["battery_volts"] == 4.15
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_no_history(self, _db, client):
|
||||
await _insert_repeater(KEY_A)
|
||||
|
||||
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_non_repeater(self, _db, client):
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": KEY_A,
|
||||
"name": "Node",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
"first_seen": None,
|
||||
}
|
||||
)
|
||||
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_for_unknown_contact(self, _db, client):
|
||||
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||
assert resp.status_code == 404
|
||||
@@ -630,6 +630,7 @@ class TestAppSettingsRepository:
|
||||
"flood_scope": "",
|
||||
"blocked_keys": "[]",
|
||||
"blocked_names": "[]",
|
||||
"discovery_blocked_types": "[]",
|
||||
}
|
||||
)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
Reference in New Issue
Block a user