Compare commits

..

1 Commits

Author SHA1 Message Date
Jack Kingsman 8843836bdf Use nginx docke 2026-03-31 21:37:44 -07:00
68 changed files with 1090 additions and 4046 deletions
+1
View File
@@ -30,3 +30,4 @@ references/
docker-compose.yml docker-compose.yml
docker-compose.yaml docker-compose.yaml
.docker-certs/ .docker-certs/
.docker-nginx/
+334 -353
View File
@@ -1,191 +1,172 @@
## [3.6.7] - 2026-03-31
* Misc: Remove armv7 (for now)
## [3.6.6] - 2026-03-31
* Misc: Please I'm begging for the build scripts to be working now
## [3.6.5] - 2026-03-31
* Bugfix: Maybe fix problem with publish script
## [3.6.4] - 2026-03-31
* Feature: Clarify New Channel/Contact button
* Bugfix: Rename "Best RSSI" to "Strongest Neighbor"
* Bugfix: Improve layout of Trace pane
* Misc: Docker setup improvements
## [3.6.3] - 2026-03-30 ## [3.6.3] - 2026-03-30
* Feature: Add multi-byte trace Feature: Add multi-byte trace
* Feature: Show node name on discovered node if we know it Feature: Show node name on discovered node if we know it
* Feature: Add docker installation script Feature: Add docker installation script
* Feature: Add historical noise floor to stats Feature: Add historical noise floor to stats
* Feature: Add trace tool Feature: Add trace tool
* Bugfix: 100x performance on statistics endpoint with indices and better queries Bugfix: 100x performance on statistics endpoint with indices and better queries
* Misc: Performance and correctness improvements for backend-of-the-frontend Misc: Performance and correctness improvements for backend-of-the-frontend
* Misc: Reorganize scripts Misc: Reorganize scripts
## [3.6.2] - 2026-03-29 ## [3.6.2] - 2026-03-29
* Feature: Be more flexible about timing and volume of full contact offload 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: Improve room server and repeater ops to be much more clearer about auth status
* Feature: Show last error status on integrations Feature: Show last error status on integrations
* Feature: Push multi-platform docker builds Feature: Push multi-platform docker builds
* Bugfix: Fix advert interval time unit display Bugfix: Fix advert interval time unit display
* Bugfix: Don't cast RSSI/SNR to string for community MQTT Bugfix: Don't cast RSSI/SNR to string for community MQTT
* Bugfix: Map uploader follows redirect Bugfix: Map uploader follows redirect
* Misc: Thin out unnecessary cruft in unreads endpoint Misc: Thin out unnecessary cruft in unreads endpoint
* Misc: Fall back gracefully if linked to an unknown contact Misc: Fall back gracefully if linked to an unknown contact
## [3.6.1] - 2026-03-26 ## [3.6.1] - 2026-03-26
* Feature: MeshCore Map integration Feature: MeshCore Map integration
* Feature: Add warning screen about bots Feature: Add warning screen about bots
* Feature: Favicon reflects unread message state Feature: Favicon reflects unread message state
* Feature: Show hop map in larger modal Feature: Show hop map in larger modal
* Feature: Add prebuilt frontend install script Feature: Add prebuilt frontend install script
* Feature: Add clean service installer script Feature: Add clean service installer script
* Feature: Swipe in to show menu Feature: Swipe in to show menu
* Bugfix: Invalid backend API path serves error, not fallback index Bugfix: Invalid backend API path serves error, not fallback index
* Bugfix: Fix some spacing/page height issues Bugfix: Fix some spacing/page height issues
* Misc: Misc. bugfixes and performance and test improvements Misc: Misc. bugfixes and performance and test improvements
## [3.6.0] - 2026-03-22 ## [3.6.0] - 2026-03-22
* Feature: Add incoming-packet analytics Feature: Add incoming-packet analytics
* Feature: BYOPacket for analysis Feature: BYOPacket for analysis
* Feature: Add room activity to stats view Feature: Add room activity to stats view
* Bugfix: Handle Heltec v3 serial noise Bugfix: Handle Heltec v3 serial noise
* Misc: Swap repeaters and room servers for better ordering Misc: Swap repeaters and room servers for better ordering
## [3.5.0] - 2026-03-19 ## [3.5.0] - 2026-03-19
* Feature: Add room server alpha support Feature: Add room server alpha support
* Feature: Add option to force-reset node clock when it's too far ahead Feature: Add option to force-reset node clock when it's too far ahead
* Feature: DMs auto-retry before resorting to flood Feature: DMs auto-retry before resorting to flood
* Feature: Add impulse zero-hop advert Feature: Add impulse zero-hop advert
* Feature: Utilize PATH packets to correctly source a contact's route Feature: Utilize PATH packets to correctly source a contact's route
* Feature: Metrics view on raw packet pane Feature: Metrics view on raw packet pane
* Feature: Metric, Imperial, and Smoots are now selectable for distance display Feature: Metric, Imperial, and Smoots are now selectable for distance display
* Feature: Allow favorites to be sorted Feature: Allow favorites to be sorted
* Feature: Add multi-ack support Feature: Add multi-ack support
* Feature: Password-remember checkbox on repeaters + room servers Feature: Password-remember checkbox on repeaters + room servers
* Bugfix: Serialize radio disconnect in a lock Bugfix: Serialize radio disconnect in a lock
* Bugfix: Fix contact bar layout issues Bugfix: Fix contact bar layout issues
* Bugfix: Fix sidebar ordering for contacts by advert recency Bugfix: Fix sidebar ordering for contacts by advert recency
* Bugfix: Fix version reporting in community MQTT Bugfix: Fix version reporting in community MQTT
* Bugfix: Fix Apprise duplicate names Bugfix: Fix Apprise duplicate names
* Bugfix: Be better about identity resolution in the stats pane Bugfix: Be better about identity resolution in the stats pane
* Misc: Docs, test, and performance enhancements Misc: Docs, test, and performance enhancements
* Misc: Don't prompt "Are you sure" when leaving an unedited interation Misc: Don't prompt "Are you sure" when leaving an unedited interation
* Misc: Log node time on startup Misc: Log node time on startup
* Misc: Improve community MQTT error bubble-up Misc: Improve community MQTT error bubble-up
* Misc: Unread DMs always have a red unread counter Misc: Unread DMs always have a red unread counter
* Misc: Improve information in the debug view to show DB status Misc: Improve information in the debug view to show DB status
## [3.4.1] - 2026-03-16 ## [3.4.1] - 2026-03-16
* Bugfix: Improve handling of version information on prebuilt bundles Bugfix: Improve handling of version information on prebuilt bundles
* Bugfix: Improve frontend usability on disconnected radio Bugfix: Improve frontend usability on disconnected radio
* Misc: Docs and readme updates Misc: Docs and readme updates
* Misc: Overhaul DM ingest and frontend state handling Misc: Overhaul DM ingest and frontend state handling
## [3.4.0] - 2026-03-16 ## [3.4.0] - 2026-03-16
* Feature: Add radio model and stats display Feature: Add radio model and stats display
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
* Bugfix: Misc. frontend performance and correctness fixes Bugfix: Misc. frontend performance and correctness fixes
* Bugfix: Fix same-second same-content DM send collition Bugfix: Fix same-second same-content DM send collition
* Bugfix: Discard clearly-wrong GPS data Bugfix: Discard clearly-wrong GPS data
* Bugfix: Prevent repeater clock skew drift on page nav 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: 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` Misc: Don't permit invalid fanout configs to be saved ever`
## [3.3.0] - 2026-03-13 ## [3.3.0] - 2026-03-13
* Feature: Use dashed lines to show collapsed ambiguous router results Feature: Use dashed lines to show collapsed ambiguous router results
* Feature: Jump to unred Feature: Jump to unred
* Feature: Local channel management to prevent need to reload channel every time Feature: Local channel management to prevent need to reload channel every time
* Feature: Debug endpoint Feature: Debug endpoint
* Feature: Force-singleton channel management Feature: Force-singleton channel management
* Feature: Local node discovery Feature: Local node discovery
* Feature: Node routing discovery Feature: Node routing discovery
* Bugfix: Don't tell users to us npm ci Bugfix: Don't tell users to us npm ci
* Bugfix: Fallback polling dm message persistence Bugfix: Fallback polling dm message persistence
* Bugfix: All native-JS inputs are now modals Bugfix: All native-JS inputs are now modals
* Bugfix: Same-second send collision resolution Bugfix: Same-second send collision resolution
* Bugfix: Proper browser updates on resend 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 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 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: Visualizer data layer overhaul for future map work
* Misc: Parallelize docker tests Misc: Parallelize docker tests
## [3.2.0] - 2026-03-12 ## [3.2.0] - 2026-03-12
* Feature: Improve ambiguous-sender DM handling and visibility Feature: Improve ambiguous-sender DM handling and visibility
* Feature: Allow for toggling of node GPS broadcast Feature: Allow for toggling of node GPS broadcast
* Feature: Add path width to bot and move example to full kwargs Feature: Add path width to bot and move example to full kwargs
* Feature: Improve node map color contrast Feature: Improve node map color contrast
* Bugfix: More accurate tracking of contact data Bugfix: More accurate tracking of contact data
* Bugfix: Misc. frontend performance and bugfixes Bugfix: Misc. frontend performance and bugfixes
* Misc: Clearer warnings on user-key linkage Misc: Clearer warnings on user-key linkage
* Misc: Documentation improvements Misc: Documentation improvements
## [3.1.1] - 2026-03-11 ## [3.1.1] - 2026-03-11
* Feature: Add basic auth Feature: Add basic auth
* Feature: SQS fanout Feature: SQS fanout
* Feature: Enrich contact info pane Feature: Enrich contact info pane
* Feature: Search operators for node and channel Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time! Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.1.0] - 2026-03-11 ## [3.1.0] - 2026-03-11
* Feature: Add basic auth Feature: Add basic auth
* Feature: SQS fanout Feature: SQS fanout
* Feature: Enrich contact info pane Feature: Enrich contact info pane
* Feature: Search operators for node and channel Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time! Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.0.0] - 2026-03-10 ## [3.0.0] - 2026-03-10
* Feature: Custom regions per-channel Feature: Custom regions per-channel
* Feature: Add custom contact pathing Feature: Add custom contact pathing
* Feature: Corrupt packets are more clear that they're corrupt 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: 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: More consistent icons
* Feature: Add per-channel local notifications Feature: Add per-channel local notifications
* Feature: New themes Feature: New themes
* Feature: Massive codebase refactor and overhaul Feature: Massive codebase refactor and overhaul
* Bugfix: Fix packet parsing for trace packets Bugfix: Fix packet parsing for trace packets
* Bugfix: Refetch channels on reconnect Bugfix: Refetch channels on reconnect
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
* Bugfix: Timestamps in logs Bugfix: Timestamps in logs
* Bugfix: Correct wrong clock sync command Bugfix: Correct wrong clock sync command
* Misc: Improve bot error bubble up Misc: Improve bot error bubble up
* Misc: Update to non-lib-included meshcore-decoder version Misc: Update to non-lib-included meshcore-decoder version
* Misc: Revise refactors to be more LLM friendly Misc: Revise refactors to be more LLM friendly
* Misc: Fix script executability Misc: Fix script executability
* Misc: Better logging format with timestamp Misc: Better logging format with timestamp
* Misc: Repeater advert buttons separate flood and one-hop Misc: Repeater advert buttons separate flood and one-hop
* Misc: Preserve repeater pane on navigation away Misc: Preserve repeater pane on navigation away
* Misc: Clearer iconography and coloring for status bar buttons Misc: Clearer iconography and coloring for status bar buttons
* Misc: Search bar to top bar Misc: Search bar to top bar
## [2.7.9] - 2026-03-08 ## [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 ## [2.7.8] - 2026-03-08
@@ -193,287 +174,287 @@
## [2.7.8] - 2026-03-08 ## [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 ## [2.7.1] - 2026-03-08
* Bugfix: Fix historical DM packet length passing Bugfix: Fix historical DM packet length passing
* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package 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 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 ## [2.7.0] - 2026-03-08
* Feature: Multibyte path support Feature: Multibyte path support
* Feature: Add multibyte statistics to statistics pane Feature: Add multibyte statistics to statistics pane
* Feature: Add path bittage to contact info pane Feature: Add path bittage to contact info pane
* Feature: Put tools in a collapsible Feature: Put tools in a collapsible
## [2.6.1] - 2026-03-08 ## [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 ## [2.6.0] - 2026-03-08
* Feature: A11y improvements Feature: A11y improvements
* Feature: New themes Feature: New themes
* Feature: Backfill channel sender identity when available Feature: Backfill channel sender identity when available
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
* Bugfix: Unreads now respect blocklist Bugfix: Unreads now respect blocklist
* Bugfix: Unreads can't accumulate on an open thread Bugfix: Unreads can't accumulate on an open thread
* Bugfix: Channel name in broadcasts Bugfix: Channel name in broadcasts
* Bugfix: Add missing httpx dependency Bugfix: Add missing httpx dependency
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
* Misc: Improved button signage for app movement Misc: Improved button signage for app movement
* Misc: Test, performance, and documentation improvements Misc: Test, performance, and documentation improvements
## [2.5.0] - 2026-03-05 ## [2.5.0] - 2026-03-05
* Feature: Far better accessibility across the app (with far to go) Feature: Far better accessibility across the app (with far to go)
* Feature: Add community MQTT stats reporting, and improve over a few commits Feature: Add community MQTT stats reporting, and improve over a few commits
* Feature: Color schemes and misc. settings reorg Feature: Color schemes and misc. settings reorg
* Feature: Add why-active to filtered nodes Feature: Add why-active to filtered nodes
* Feature: Add channel and contact info box Feature: Add channel and contact info box
* Feature: Add contact blocking Feature: Add contact blocking
* Feature: Add potential repeater path map display Feature: Add potential repeater path map display
* Feature: Add flood scoping/regions Feature: Add flood scoping/regions
* Feature: Global message search Feature: Global message search
* Feature: Fully safe bot disable Feature: Fully safe bot disable
* Feature: Add default #remoteterm channel (lol sorry I had to) Feature: Add default #remoteterm channel (lol sorry I had to)
* Feature: Custom recency pruning in visualizer Feature: Custom recency pruning in visualizer
* Bugfix: Be more cautious around null byte stripping Bugfix: Be more cautious around null byte stripping
* Bugfix: Clear channel-add interface on not-add-another Bugfix: Clear channel-add interface on not-add-another
* Bugfix: Add status/name/MQTT LWT Bugfix: Add status/name/MQTT LWT
* Bugfix: Channel deletion propagates over WS Bugfix: Channel deletion propagates over WS
* Bugfix: Show map location for all nodes on link, not 7-day-limited Bugfix: Show map location for all nodes on link, not 7-day-limited
* Bugfix: Hide private key channel keys by default 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: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
* Misc: Doc, changelog, and test improvements Misc: Doc, changelog, and test improvements
* Misc: Add, and remove, package lock (sorry Windows users) Misc: Add, and remove, package lock (sorry Windows users)
* Misc: Don't show mark all as read if not necessary Misc: Don't show mark all as read if not necessary
* Misc: Fix stale closures and misc. frontend perf/correctness improvements Misc: Fix stale closures and misc. frontend perf/correctness improvements
* Misc: Add Windows startup notes Misc: Add Windows startup notes
* Misc: E2E expansion + improvement Misc: E2E expansion + improvement
* Misc: Move around visualizer settings Misc: Move around visualizer settings
## [2.4.0] - 2026-03-02 ## [2.4.0] - 2026-03-02
* Feature: Add community MQTT reporting (e.g. LetsMesh.net) Feature: Add community MQTT reporting (e.g. LetsMesh.net)
* Misc: Build scripts and library attribution Misc: Build scripts and library attribution
* Misc: Add sign of life to E2E tests Misc: Add sign of life to E2E tests
## [2.3.0] - 2026-03-01 ## [2.3.0] - 2026-03-01
* Feature: Click path description to reset to flood Feature: Click path description to reset to flood
* Feature: Add MQTT publishing Feature: Add MQTT publishing
* Feature: Visualizer remembers settings Feature: Visualizer remembers settings
* Bugfix: Fix prefetch usage Bugfix: Fix prefetch usage
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
* Misc: Drop py3.12 requirement Misc: Drop py3.12 requirement
* Misc: Performance, documentation, test, and file structure optimizations Misc: Performance, documentation, test, and file structure optimizations
* Misc: Add arrows between route nodes on contact info Misc: Add arrows between route nodes on contact info
* Misc: Show repeater path/type in title bar Misc: Show repeater path/type in title bar
## [2.2.0] - 2026-02-28 ## [2.2.0] - 2026-02-28
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer Feature: Track advert paths and use to disambiguate repeater identity in visualizer
* Feature: Contact info pane Feature: Contact info pane
* Feature: Overhaul repeater interface Feature: Overhaul repeater interface
* Bugfix: Misc. frontend rendering + perf improvements Bugfix: Misc. frontend rendering + perf improvements
* Bugfix: Better behavior around radio locking and autofetch/polling Bugfix: Better behavior around radio locking and autofetch/polling
* Bugfix: Clear channel name field on new-channel modal tab change Bugfix: Clear channel name field on new-channel modal tab change
* Bugfix: Repeater inforbox can scroll Bugfix: Repeater inforbox can scroll
* Bugfix: Better handling of historical DM encrypts Bugfix: Better handling of historical DM encrypts
* Bugfix: Handle errors if returned in prefetch phase Bugfix: Handle errors if returned in prefetch phase
* Misc: Radio event response failure is logged/surfaced better Misc: Radio event response failure is logged/surfaced better
* Misc: Improve test coverage and remove dead code Misc: Improve test coverage and remove dead code
* Misc: Documentation and errata improvements Misc: Documentation and errata improvements
* Misc: Database storage optimization Misc: Database storage optimization
## [2.1.0] - 2026-02-23 ## [2.1.0] - 2026-02-23
* Feature: Add ability to remember last-used channel on load Feature: Add ability to remember last-used channel on load
* Feature: Add `docker compose` support (thanks @suymur !) Feature: Add `docker compose` support (thanks @suymur !)
* Feature: Better-aligned favicon (lol) Feature: Better-aligned favicon (lol)
* Bugfix: Disable autocomplete on message field Bugfix: Disable autocomplete on message field
* Bugfix: Legacy hash restoration on page load Bugfix: Legacy hash restoration on page load
* Bugfix: Align resend buttons in pathing modal Bugfix: Align resend buttons in pathing modal
* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host 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: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
* Bugfix: Improved repeater comms on busy meshes Bugfix: Improved repeater comms on busy meshes
* Bugfix: Drain before autofetch from radio Bugfix: Drain before autofetch from radio
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure Bugfix: Fix, or document exceptions to, sub-second resolution message failure
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
* Bugfix: Force server-side keystore update when radio key changes Bugfix: Force server-side keystore update when radio key changes
* Bugfix: Reduce WS churn for incoming message handling Bugfix: Reduce WS churn for incoming message handling
* Bugfix: Fix content type signalling for irrelevant endpoints Bugfix: Fix content type signalling for irrelevant endpoints
* Bugfix: Handle stuck post-connect failure state Bugfix: Handle stuck post-connect failure state
* Misc: Documentation & version parsing improvements Misc: Documentation & version parsing improvements
* Misc: Hide char counter on mobile for short messages Misc: Hide char counter on mobile for short messages
* Misc: Typo fixes in docs and settings Misc: Typo fixes in docs and settings
* Misc: Add dynamic webmanifest for hosts that can support it 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: 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: Drop weird rounded bounding box for settings
* Misc: Move resend buttons to pathing modal Misc: Move resend buttons to pathing modal
* Misc: Improved comments around database ownership on *nix systems Misc: Improved comments around database ownership on *nix systems
* Misc: Move to SSoT for message dedupe on frontend Misc: Move to SSoT for message dedupe on frontend
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling Misc: Move DM ack clearing to standard poll, and increase hold time between polling
* Misc: Holistic testing overhaul Misc: Holistic testing overhaul
## [2.0.1] - 2026-02-16 ## [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 ## [2.0.0] - 2026-02-16
* Feature: Frontend UX + log overhaul Feature: Frontend UX + log overhaul
* Bugfix: Use contact object from DB for broadcast rather than handrolling Bugfix: Use contact object from DB for broadcast rather than handrolling
* Bugfix: Fix out of order path WS messages overwriting each other Bugfix: Fix out of order path WS messages overwriting each other
* Bugfix: Make broadcast timestamp match fallback logic used in storage code Bugfix: Make broadcast timestamp match fallback logic used in storage code
* Bugfix: Fix repeater command timestamp selection logic 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: 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: Add missing radio operation locks in a few spots
* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!) 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) 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 Misc: Visualizer layout refinement & option labels
## [1.10.0] - 2026-02-16 ## [1.10.0] - 2026-02-16
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !) Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
* Feature: 3D mesh visualizer Feature: 3D mesh visualizer
* Feature: Statistics pane Feature: Statistics pane
* Feature: Support incoming/outgoing indication for bot invocations 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!) 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: Fix top padding out outgoing message
* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes) 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: Multiple-sent messages had path appearing delays until rerender
* Bugfix: Fix ack/message race condition that caused dropped ack displays 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: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
* Misc: s/stopped/idle/ for room finder Misc: s/stopped/idle/ for room finder
## [1.9.3] - 2026-02-12 ## [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 ## [1.9.2] - 2026-02-12
* Feature: Options dialog sucks less Feature: Options dialog sucks less
* Bugfix: Move tests to isolated memory DB Bugfix: Move tests to isolated memory DB
* Bugfix: Mention case sensitivity Bugfix: Mention case sensitivity
* Bugfix: Stale header retention on settings page view Bugfix: Stale header retention on settings page view
* Bugfix: Non-isolated path writing Bugfix: Non-isolated path writing
* Bugfix: Nullable contact fields are now passed as real nulls Bugfix: Nullable contact fields are now passed as real nulls
* Bugfix: Look at all fields on message reconcile, not just text Bugfix: Look at all fields on message reconcile, not just text
* Bugfix: Make mark-all-as-read atomic 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: 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 Misc: Massive test and AGENTS.md overhauls and additions
## [1.9.1] - 2026-02-10 ## [1.9.1] - 2026-02-10
* Feature: Contacts and channels use keys, not names Feature: Contacts and channels use keys, not names
* Bugfix: Fix falsy casting of 0 in lat lon and timing data Bugfix: Fix falsy casting of 0 in lat lon and timing data
* Bugfix: Show message length in bytes, not chars Bugfix: Show message length in bytes, not chars
* Bugfix: Fix phantom unread badges on focused convos Bugfix: Fix phantom unread badges on focused convos
* Misc: Bot invocation to async Misc: Bot invocation to async
* Misc: Use full key, not prefix, where we can Misc: Use full key, not prefix, where we can
## [1.9.0] - 2026-02-10 ## [1.9.0] - 2026-02-10
* Feature: Favorited contacts are preferentially loaded onto the radio Feature: Favorited contacts are preferentially loaded onto the radio
* Feature: Add recent-message caching for fast switching Feature: Add recent-message caching for fast switching
* Feature: Add echo paths modal when echo-heard checkbox is clicked 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: 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 Frontend: Better styling on echo + message path display
* Bugfix: Prevent frontend static file serving path traversal vuln Bugfix: Prevent frontend static file serving path traversal vuln
* Bugfix: Safer prefix-claiming for DMs we don't have the key for Bugfix: Safer prefix-claiming for DMs we don't have the key for
* Bugfix: Prevent injection from mentions with special characters 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: 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 Bugfix: App can boot and test without a frontend dir
* Misc: Improve and consistent-ify (?) backend radio operation lock management Misc: Improve and consistent-ify (?) backend radio operation lock management
* Misc: Frontend performance and safety enhancements Misc: Frontend performance and safety enhancements
* Misc: Move builds to non-bundled; usage requires building the Frontend Misc: Move builds to non-bundled; usage requires building the Frontend
* Misc: Update tests and agent docs Misc: Update tests and agent docs
## [1.8.0] - 2026-02-07 ## [1.8.0] - 2026-02-07
* Feature: Single hop ping Feature: Single hop ping
* Feature: PWA viewport fixes(thanks @rgregg) Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P Feature (?): No frontend distribution; build it yourself ;P
* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot) 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: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
* Bugfix: Better guarding around reconnection Bugfix: Better guarding around reconnection
* Bugfix: Duplicate websocket connection fixes Bugfix: Duplicate websocket connection fixes
* Bugfix: Settings tab error cleanliness on tab swap Bugfix: Settings tab error cleanliness on tab swap
* Bugfix: Fix path traversal vuln Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier) UI: Swap visualizer legend ordering (yay prettier)
* Misc: Perf and locking improvements Misc: Perf and locking improvements
* Misc: Always flood advertisements Misc: Always flood advertisements
* Misc: Better packet dupe handling Misc: Better packet dupe handling
* Misc: Dead code cleanup, test improvements Misc: Dead code cleanup, test improvements
## [1.7.1] - 2026-02-03 ## [1.7.1] - 2026-02-03
* Feature: Clickable hyperlinks Feature: Clickable hyperlinks
* Bugfix: More consistent public key normalization Bugfix: More consistent public key normalization
* Bugfix: Use more reliable cursor paging Bugfix: Use more reliable cursor paging
* Bugfix: Fix null timestamp dedupe failure Bugfix: Fix null timestamp dedupe failure
* Bugfix: More consistent prefix-based message claiming on key receipt Bugfix: More consistent prefix-based message claiming on key receipt
* Misc: Bot can respond to its own messages Misc: Bot can respond to its own messages
* Misc: Additional tests Misc: Additional tests
* Misc: Remove unneeded message dedupe logic Misc: Remove unneeded message dedupe logic
* Misc: Resync settings after radio settings mutation Misc: Resync settings after radio settings mutation
## [1.7.0] - 2026-01-27 ## [1.7.0] - 2026-01-27
* Feature: Multi-bot functionality Feature: Multi-bot functionality
* Bugfix: Adjust bot code editor display and add line numbers Bugfix: Adjust bot code editor display and add line numbers
* Bugfix: Fix clock filtering and contact lookup behavior bugs Bugfix: Fix clock filtering and contact lookup behavior bugs
* Bugfix: Fix repeater message duplication issue Bugfix: Fix repeater message duplication issue
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming) Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
UI: Move advertise button to identity tab 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 ## [1.6.0] - 2026-01-26
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
* Feature: Customizable advertising interval Feature: Customizable advertising interval
* Feature: In-app bot setup Feature: In-app bot setup
* Bugfix: Force contact onto radio before DM send Bugfix: Force contact onto radio before DM send
* Misc: Remove unused code Misc: Remove unused code
## [1.5.0] - 2026-01-19 ## [1.5.0] - 2026-01-19
* Feature: Network visualizer Feature: Network visualizer
## [1.4.1] - 2026-01-19 ## [1.4.1] - 2026-01-19
* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default) 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: Server-side preference management for favorites, read status, etc.
UI: More compact hop labelling UI: More compact hop labelling
* Bugfix: Misc. race conditions and websocket handling 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: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
## [1.4.0] - 2026-01-18 ## [1.4.0] - 2026-01-18
UI: Improve button layout for room searcher UI: Improve button layout for room searcher
UI: Improve favicon coloring UI: Improve favicon coloring
UI: Improve status bar button layout on small screen UI: Improve status bar button layout on small screen
* Feature: Show multi-path hop display with distance estimates Feature: Show multi-path hop display with distance estimates
* Feature: Search rooms and contacts by key, not just name Feature: Search rooms and contacts by key, not just name
* Bugfix: Historical DM decryption now works as expected Bugfix: Historical DM decryption now works as expected
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
## [1.3.1] - 2026-01-17 ## [1.3.1] - 2026-01-17
UI: Rework restart handling 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 Bug: Improve error message rendering from server-side errors
UI: Remove octothorpe from channel listing UI: Remove octothorpe from channel listing
## [1.3.0] - 2026-01-17 ## [1.3.0] - 2026-01-17
* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level 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: 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: Drop repeater login wait time; vestigial from debugging a different issue
## [1.2.1] - 2026-01-17 ## [1.2.1] - 2026-01-17
@@ -481,27 +462,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
## [1.2.0] - 2026-01-16 ## [1.2.0] - 2026-01-16
* Feature: Add favorites Feature: Add favorites
## [1.1.0] - 2026-01-14 ## [1.1.0] - 2026-01-14
* Bugfix: Use actual pathing data from advertisements, not just always flood (oops) 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: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
## [1.0.3] - 2026-01-13 ## [1.0.3] - 2026-01-13
* Bugfix: Add missing test management packages Bugfix: Add missing test management packages
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable! Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
## [1.0.2] - 2026-01-13 ## [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 ## [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 ## [1.0.0] - 2026-01-13
* Initial full release! Initial full release!
+3 -13
View File
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
data BLOB NOT NULL, data BLOB NOT NULL,
message_id INTEGER, message_id INTEGER,
payload_hash BLOB, payload_hash BLOB,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL FOREIGN KEY (message_id) REFERENCES messages(id)
); );
CREATE TABLE IF NOT EXISTS contact_advert_paths ( 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, last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1, heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len), UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE FOREIGN KEY (public_key) REFERENCES contacts(public_key)
); );
CREATE TABLE IF NOT EXISTS contact_name_history ( 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, first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
UNIQUE(public_key, name), UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE FOREIGN KEY (public_key) REFERENCES contacts(public_key)
); );
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at); CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
@@ -132,12 +132,6 @@ class Database:
# migration 20 handles the one-time VACUUM to restructure the file. # migration 20 handles the one-time VACUUM to restructure the file.
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL") 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.executescript(SCHEMA)
await self._connection.commit() await self._connection.commit()
logger.debug("Database schema initialized") logger.debug("Database schema initialized")
@@ -147,10 +141,6 @@ class Database:
await run_migrations(self._connection) 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: async def disconnect(self) -> None:
if self._connection: if self._connection:
await self._connection.close() await self._connection.close()
+2 -222
View File
@@ -367,28 +367,6 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 47) await set_version(conn, 47)
applied += 1 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
if version < 50:
logger.info("Applying migration 50: repeater telemetry history table")
await _migrate_050_repeater_telemetry_history(conn)
await set_version(conn, 50)
applied += 1
if applied > 0: if applied > 0:
logger.info( logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn) "Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -851,7 +829,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200, max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]', favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1, auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent', sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}', last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0 preferences_migrated INTEGER DEFAULT 0
@@ -863,7 +841,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
await conn.execute( 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) INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 1, 'recent', '{}', 0) VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
""" """
) )
@@ -2931,201 +2909,3 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
""" """
) )
await conn.commit() 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.
Uses ON DELETE CASCADE so contact deletion automatically cleans up rows.
"""
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_history_key_ts "
"ON repeater_telemetry_history(public_key, timestamp DESC)"
)
await conn.commit()
+1 -16
View File
@@ -530,14 +530,6 @@ class RepeaterStatusResponse(BaseModel):
flood_dups: int = Field(description="Duplicate flood packets") flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets") direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count") full_events: int = Field(description="Full event queue count")
telemetry_history: list["TelemetryHistoryEntry"] = Field(
default_factory=list, description="Recent telemetry history snapshots"
)
class TelemetryHistoryEntry(BaseModel):
timestamp: int
data: dict
class RepeaterNodeInfoResponse(BaseModel): class RepeaterNodeInfoResponse(BaseModel):
@@ -813,7 +805,7 @@ class AppSettings(BaseModel):
default_factory=list, description="List of favorited conversations" default_factory=list, description="List of favorited conversations"
) )
auto_decrypt_dm_on_advert: bool = Field( auto_decrypt_dm_on_advert: bool = Field(
default=True, default=False,
description="Whether to attempt historical DM decryption on new contact advertisement", description="Whether to attempt historical DM decryption on new contact advertisement",
) )
sidebar_sort_order: Literal["recent", "alpha"] = Field( sidebar_sort_order: Literal["recent", "alpha"] = Field(
@@ -848,13 +840,6 @@ class AppSettings(BaseModel):
default_factory=list, default_factory=list,
description="Display names whose messages are hidden from the UI", 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): class FanoutConfig(BaseModel):
+8 -24
View File
@@ -462,19 +462,14 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0) advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
) )
# Check discovery_blocked_types: skip new contacts whose type is blocked. # Keep recent unique advert paths for all contacts.
# Existing contacts are always updated (location, name, last_seen, etc.). await ContactAdvertPathRepository.record_observation(
if existing is None and contact_type > 0: public_key=advert.public_key.lower(),
from app.repository import AppSettingsRepository path_hex=new_path_hex,
timestamp=timestamp,
settings = await AppSettingsRepository.get() max_paths=10,
if contact_type in settings.discovery_blocked_types: hop_count=new_path_len,
logger.debug( )
"Skipping new contact %s: type %d is in discovery_blocked_types",
advert.public_key[:12],
contact_type,
)
return
contact_upsert = ContactUpsert( contact_upsert = ContactUpsert(
public_key=advert.public_key.lower(), public_key=advert.public_key.lower(),
@@ -487,18 +482,7 @@ async def _process_advertisement(
first_seen=timestamp, # COALESCE in upsert preserves existing value 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) 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( promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key, public_key=advert.public_key,
log=logger, log=logger,
+4 -35
View File
@@ -29,10 +29,7 @@ from app.repository import (
ChannelRepository, ChannelRepository,
ContactRepository, ContactRepository,
) )
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import reconcile_contact_messages
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.messages import create_fallback_channel_message from app.services.messages import create_fallback_channel_message
from app.services.radio_runtime import radio_runtime as radio_manager from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error, broadcast_event from app.websocket import broadcast_error, broadcast_event
@@ -66,25 +63,13 @@ async def _reconcile_contact_messages_background(
public_key: str, public_key: str,
contact_name: str | None, contact_name: str | None,
) -> None: ) -> None:
"""Run prefix promotion and contact/message reconciliation outside the radio critical path.""" """Run contact/message reconciliation outside the radio critical path."""
try: try:
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
await reconcile_contact_messages( await reconcile_contact_messages(
public_key=public_key, public_key=public_key,
contact_name=contact_name, contact_name=contact_name,
log=logger, 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: except Exception as exc:
logger.warning( logger.warning(
"Background contact reconciliation failed for %s: %s", "Background contact reconciliation failed for %s: %s",
@@ -194,22 +179,6 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95 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]: def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
"""Return (refill_target, full_sync_trigger) for the configured capacity.""" """Return (refill_target, full_sync_trigger) for the configured capacity."""
capacity = max(1, max_contacts) capacity = max(1, max_contacts)
@@ -224,7 +193,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
async def should_run_full_periodic_sync(mc: MeshCore) -> bool: async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
"""Check current radio occupancy and decide whether to offload/reload.""" """Check current radio occupancy and decide whether to offload/reload."""
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
capacity = _effective_radio_capacity(app_settings.max_radio_contacts) capacity = app_settings.max_radio_contacts
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity) refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts() result = await mc.commands.get_contacts()
@@ -1332,7 +1301,7 @@ async def stop_background_contact_reconciliation() -> None:
async def get_contacts_selected_for_radio_sync() -> list[Contact]: async def get_contacts_selected_for_radio_sync() -> list[Contact]:
"""Return the contacts that would be loaded onto the radio right now.""" """Return the contacts that would be loaded onto the radio right now."""
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts) max_contacts = app_settings.max_radio_contacts
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts) refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
selected_contacts: list[Contact] = [] selected_contacts: list[Contact] = []
selected_keys: set[str] = set() selected_keys: set[str] = set()
-2
View File
@@ -8,7 +8,6 @@ from app.repository.contacts import (
from app.repository.fanout import FanoutConfigRepository from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository from app.repository.raw_packets import RawPacketRepository
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository from app.repository.settings import AppSettingsRepository, StatisticsRepository
__all__ = [ __all__ = [
@@ -21,6 +20,5 @@ __all__ = [
"FanoutConfigRepository", "FanoutConfigRepository",
"MessageRepository", "MessageRepository",
"RawPacketRepository", "RawPacketRepository",
"RepeaterTelemetryRepository",
"StatisticsRepository", "StatisticsRepository",
] ]
+51 -63
View File
@@ -1,4 +1,3 @@
import logging
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
@@ -13,8 +12,6 @@ from app.models import (
) )
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
logger = logging.getLogger(__name__)
class AmbiguousPublicKeyPrefixError(ValueError): class AmbiguousPublicKeyPrefixError(ValueError):
"""Raised when a public key prefix matches multiple contacts.""" """Raised when a public key prefix matches multiple contacts."""
@@ -487,6 +484,7 @@ class ContactRepository:
return [] return []
promoted_keys: list[str] = [] promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows: for row in rows:
old_key = row["public_key"] old_key = row["public_key"]
@@ -503,70 +501,60 @@ class ContactRepository:
(old_key,), (old_key,),
) )
match_row = await match_cursor.fetchone() match_row = await match_cursor.fetchone()
match_count = match_row["match_count"] if match_row is not None else 0 if (match_row["match_count"] if match_row is not None else 0) != 1:
if match_count != 1:
logger.warning(
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
old_key,
match_count,
)
continue continue
await migrate_child_rows(old_key, normalized_full_key) await migrate_child_rows(old_key, normalized_full_key)
# Merge timestamp metadata from the old prefix contact into the if full_exists:
# full-key contact (which all callers guarantee already exists), await db.conn.execute(
# then delete the prefix placeholder. """
await db.conn.execute( UPDATE contacts
""" SET last_seen = CASE
UPDATE contacts WHEN contacts.last_seen IS NULL THEN ?
SET last_seen = CASE WHEN ? IS NULL THEN contacts.last_seen
WHEN contacts.last_seen IS NULL THEN ? WHEN ? > contacts.last_seen THEN ?
WHEN ? IS NULL THEN contacts.last_seen ELSE contacts.last_seen
WHEN ? > contacts.last_seen THEN ? END,
ELSE contacts.last_seen last_contacted = CASE
END, WHEN contacts.last_contacted IS NULL THEN ?
last_contacted = CASE WHEN ? IS NULL THEN contacts.last_contacted
WHEN contacts.last_contacted IS NULL THEN ? WHEN ? > contacts.last_contacted THEN ?
WHEN ? IS NULL THEN contacts.last_contacted ELSE contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ? END,
ELSE contacts.last_contacted first_seen = CASE
END, WHEN contacts.first_seen IS NULL THEN ?
first_seen = CASE WHEN ? IS NULL THEN contacts.first_seen
WHEN contacts.first_seen IS NULL THEN ? WHEN ? < contacts.first_seen THEN ?
WHEN ? IS NULL THEN contacts.first_seen ELSE contacts.first_seen
WHEN ? < contacts.first_seen THEN ? END,
ELSE contacts.first_seen last_read_at = COALESCE(contacts.last_read_at, ?)
END, WHERE public_key = ?
last_read_at = CASE """,
WHEN contacts.last_read_at IS NULL THEN ? (
WHEN ? IS NULL THEN contacts.last_read_at row["last_seen"],
WHEN ? > contacts.last_read_at THEN ? row["last_seen"],
ELSE contacts.last_read_at row["last_seen"],
END row["last_seen"],
WHERE public_key = ? row["last_contacted"],
""", row["last_contacted"],
( row["last_contacted"],
row["last_seen"], row["last_contacted"],
row["last_seen"], row["first_seen"],
row["last_seen"], row["first_seen"],
row["last_seen"], row["first_seen"],
row["last_contacted"], row["first_seen"],
row["last_contacted"], row["last_read_at"],
row["last_contacted"], normalized_full_key,
row["last_contacted"], ),
row["first_seen"], )
row["first_seen"], await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
row["first_seen"], else:
row["first_seen"], await db.conn.execute(
row["last_read_at"], "UPDATE contacts SET public_key = ? WHERE public_key = ?",
row["last_read_at"], (normalized_full_key, old_key),
row["last_read_at"], )
row["last_read_at"], full_exists = True
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
promoted_keys.append(old_key) promoted_keys.append(old_key)
+2 -9
View File
@@ -158,11 +158,7 @@ class MessageRepository:
""" """
lower_key = full_key.lower() lower_key = full_key.lower()
cursor = await db.conn.execute( 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 WHERE type = 'PRIV' AND length(conversation_key) < 64
AND ? LIKE conversation_key || '%' AND ? LIKE conversation_key || '%'
AND ( AND (
@@ -170,7 +166,7 @@ class MessageRepository:
WHERE length(public_key) = 64 WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%' AND public_key LIKE messages.conversation_key || '%'
) = 1""", ) = 1""",
(lower_key, lower_key, lower_key, lower_key), (lower_key, lower_key),
) )
await db.conn.commit() await db.conn.commit()
return cursor.rowcount return cursor.rowcount
@@ -576,9 +572,6 @@ class MessageRepository:
@staticmethod @staticmethod
async def delete_by_id(message_id: int) -> None: async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID.""" """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.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit() await db.conn.commit()
-75
View File
@@ -1,75 +0,0 @@
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
]
+1 -15
View File
@@ -29,7 +29,7 @@ class AppSettingsRepository:
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated, sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope, advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types blocked_keys, blocked_names
FROM app_settings WHERE id = 1 FROM app_settings WHERE id = 1
""" """
) )
@@ -81,14 +81,6 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
blocked_names = [] 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) # Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"] sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"): if sort_order not in ("recent", "alpha"):
@@ -106,7 +98,6 @@ class AppSettingsRepository:
flood_scope=row["flood_scope"] or "", flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys, blocked_keys=blocked_keys,
blocked_names=blocked_names, blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
) )
@staticmethod @staticmethod
@@ -122,7 +113,6 @@ class AppSettingsRepository:
flood_scope: str | None = None, flood_scope: str | None = None,
blocked_keys: list[str] | None = None, blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None, blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
) -> AppSettings: ) -> AppSettings:
"""Update app settings. Only provided fields are updated.""" """Update app settings. Only provided fields are updated."""
updates = [] updates = []
@@ -173,10 +163,6 @@ class AppSettingsRepository:
updates.append("blocked_names = ?") updates.append("blocked_names = ?")
params.append(json.dumps(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: if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params) await db.conn.execute(query, params)
+47 -224
View File
@@ -1,8 +1,7 @@
import logging import logging
import re
from hashlib import sha256 from hashlib import sha256
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.channel_constants import ( from app.channel_constants import (
@@ -11,12 +10,10 @@ from app.channel_constants import (
is_public_channel_key, is_public_channel_key,
is_public_channel_name, 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.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.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event, broadcast_success from app.websocket import broadcast_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"]) router = APIRouter(prefix="/channels", tags=["channels"])
@@ -34,154 +31,12 @@ 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): class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field( flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope" 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]) @router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]: async def list_channels() -> list[Channel]:
"""List all channels from the database.""" """List all channels from the database."""
@@ -214,7 +69,50 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
automatically when sending a message (see messages.py send_channel_message). automatically when sending a message (see messages.py send_channel_message).
""" """
requested_name = request.name requested_name = request.name
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key) 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
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag) logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
@@ -234,81 +132,6 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
return stored 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") @router.post("/{key}/mark-read")
async def mark_channel_read(key: str) -> dict: async def mark_channel_read(key: str) -> dict:
"""Mark a channel as read (update last_read_at timestamp).""" """Mark a channel as read (update last_read_at timestamp)."""
+3 -50
View File
@@ -1,12 +1,10 @@
import asyncio import asyncio
import logging import logging
import random import random
import time
from contextlib import suppress from contextlib import suppress
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected from app.dependencies import require_connected
from app.models import ( from app.models import (
@@ -33,7 +31,7 @@ from app.repository import (
) )
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact, promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile, reconcile_contact_messages,
) )
from app.services.radio_runtime import radio_runtime as radio_manager from app.services.radio_runtime import radio_runtime as radio_manager
@@ -279,18 +277,12 @@ async def create_contact(
# Check if contact already exists # Check if contact already exists
existing = await ContactRepository.get_by_key(request.public_key) existing = await ContactRepository.get_by_key(request.public_key)
if existing: if existing:
# Update name if provided and record name history # Update name if provided
if request.name: if request.name:
await ContactRepository.upsert(existing.to_upsert(name=request.name)) await ContactRepository.upsert(existing.to_upsert(name=request.name))
refreshed = await ContactRepository.get_by_key(request.public_key) refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None: if refreshed is not None:
existing = refreshed 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( promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key, public_key=request.public_key,
@@ -325,10 +317,9 @@ async def create_contact(
log=logger, log=logger,
) )
await record_contact_name_and_reconcile( await reconcile_contact_messages(
public_key=lower_key, public_key=lower_key,
contact_name=request.name, contact_name=request.name,
timestamp=int(time.time()),
log=logger, log=logger,
) )
@@ -356,44 +347,6 @@ async def mark_contact_read(public_key: str) -> dict:
return {"status": "ok", "public_key": contact.public_key} 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}") @router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict: async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present).""" """Delete a contact from the database (and radio if present)."""
+19 -138
View File
@@ -1,21 +1,17 @@
import hashlib import hashlib
import logging import logging
import os
import platform
import struct
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from meshcore import EventType from meshcore import EventType
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings 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.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository from app.repository import MessageRepository, StatisticsRepository
from app.routers.health import FanoutStatusResponse, build_health_data from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output from app.version_info import get_app_build_info, git_output
@@ -38,13 +34,6 @@ LOG_COPY_BOUNDARY_PREFIX = [
] ]
class DebugSystemInfo(BaseModel):
os: str
arch: str
arch_bits: int
total_ram_mb: int
class DebugApplicationInfo(BaseModel): class DebugApplicationInfo(BaseModel):
version: str version: str
version_source: str version_source: str
@@ -61,6 +50,8 @@ class DebugRuntimeInfo(BaseModel):
setup_in_progress: bool setup_in_progress: bool
setup_complete: bool setup_complete: bool
channels_with_incoming_messages: int channels_with_incoming_messages: int
max_channels: int
path_hash_mode: int
path_hash_mode_supported: bool path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int channel_send_cache_capacity: int
@@ -87,6 +78,7 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel): class DebugRadioProbe(BaseModel):
performed: bool performed: bool
errors: list[str] = Field(default_factory=list) errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None stats_core: dict[str, Any] | None = None
@@ -101,53 +93,16 @@ class DebugDatabaseInfo(BaseModel):
total_outgoing: int 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): class DebugSnapshotResponse(BaseModel):
captured_at: str captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo application: DebugApplicationInfo
health: DebugHealthSummary health: HealthResponse
settings: DebugAppSettings
runtime: DebugRuntimeInfo runtime: DebugRuntimeInfo
database: DebugDatabaseInfo database: DebugDatabaseInfo
radio_probe: DebugRadioProbe radio_probe: DebugRadioProbe
logs: list[str] 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: def _build_application_info() -> DebugApplicationInfo:
build_info = get_app_build_info() build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain") dirty_output = git_output("status", "--porcelain")
@@ -203,68 +158,6 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
return None 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( async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]], observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit: ) -> DebugContactAudit:
@@ -349,7 +242,10 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe( return DebugRadioProbe(
performed=True, performed=True,
errors=errors, errors=errors,
self_info=_sanitize_radio_probe_self_info(mc.self_info), multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info, device_info=device_info,
stats_core=stats_core, stats_core=stats_core,
stats_radio=stats_radio, stats_radio=stats_radio,
@@ -368,39 +264,24 @@ async def _probe_radio() -> DebugRadioProbe:
@router.get("/debug", response_model=DebugSnapshotResponse) @router.get("/debug", response_model=DebugSnapshotResponse)
async def debug_support_snapshot() -> DebugSnapshotResponse: async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state.""" """Return a support/debug snapshot with recent logs and live radio state."""
connection_info = radio_runtime.connection_info health_data = await build_health_data(radio_runtime.is_connected, 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() message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio() radio_probe = await _probe_radio()
channels_with_incoming_messages = ( channels_with_incoming_messages = (
await MessageRepository.count_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( return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(), captured_at=datetime.now(timezone.utc).isoformat(),
system=_build_system_info(),
application=_build_application_info(), application=_build_application_info(),
health=_build_debug_health_summary(health_data, radio_state=radio_state), health=HealthResponse(**health_data),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo( runtime=DebugRuntimeInfo(
connection_info=connection_info, connection_info=radio_runtime.connection_info,
connection_desired=connection_desired, connection_desired=radio_runtime.connection_desired,
setup_in_progress=setup_in_progress, setup_in_progress=radio_runtime.is_setup_in_progress,
setup_complete=setup_complete, setup_complete=radio_runtime.is_setup_complete,
channels_with_incoming_messages=channels_with_incoming_messages, 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, path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(), channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(), channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
+3 -14
View File
@@ -24,10 +24,7 @@ from app.models import (
from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time from app.radio_sync import sync_radio_time
from app.repository import ContactRepository from app.repository import ContactRepository
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.radio_commands import ( from app.services.radio_commands import (
KeystoreRefreshError, KeystoreRefreshError,
PathHashModeUnsupportedError, PathHashModeUnsupportedError,
@@ -217,19 +214,11 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
public_key=result.public_key, public_key=result.public_key,
log=logger, 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) created = await ContactRepository.get_by_key(result.public_key)
if created is not None: if created is not None:
broadcast_event("contact", created.model_dump()) broadcast_event("contact", created.model_dump())
for old_key in promoted_keys: for old_key in promoted_keys:
broadcast_event( broadcast_event("contact_deleted", {"public_key": old_key})
"contact_resolved",
{"previous_public_key": old_key, "contact": created.model_dump()},
)
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None: async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
+2 -40
View File
@@ -1,5 +1,4 @@
import logging import logging
import time
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -22,9 +21,8 @@ from app.models import (
RepeaterOwnerInfoResponse, RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse, RepeaterRadioSettingsResponse,
RepeaterStatusResponse, RepeaterStatusResponse,
TelemetryHistoryEntry,
) )
from app.repository import ContactRepository, RepeaterTelemetryRepository from app.repository import ContactRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404 from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import ( from app.routers.server_control import (
batch_cli_fetch, batch_cli_fetch,
@@ -110,7 +108,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
if status is None: if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater") raise HTTPException(status_code=504, detail="No status response from repeater")
response = RepeaterStatusResponse( return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0, battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0), tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0), noise_floor_dbm=status.get("noise_floor", 0),
@@ -130,42 +128,6 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
full_events=status.get("full_evts", 0), 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) @router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
-13
View File
@@ -48,13 +48,6 @@ class AppSettingsUpdate(BaseModel):
default=None, default=None,
description="Display names whose messages are hidden from the UI", 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): class BlockKeyRequest(BaseModel):
@@ -129,12 +122,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
if update.blocked_names is not None: if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names 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
flood_scope_changed = False flood_scope_changed = False
if update.flood_scope is not None: if update.flood_scope is not None:
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"private": true, "private": true,
"version": "3.6.7", "version": "3.6.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6 -42
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react'; import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { api } from './api'; import { api } from './api';
import { takePrefetchOrFetch } from './prefetch'; import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket'; import { useWebSocket } from './useWebSocket';
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser'; import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState'; import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types'; import type { Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types'; import { CONTACT_TYPE_ROOM } from './types';
interface ChannelUnreadMarker { interface ChannelUnreadMarker {
@@ -85,8 +85,6 @@ export function App() {
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null); const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] = const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null); useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0); const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null); const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const { const {
@@ -192,7 +190,6 @@ export function App() {
handleCreateContact, handleCreateContact,
handleCreateChannel, handleCreateChannel,
handleCreateHashtagChannel, handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel, handleDeleteChannel,
handleDeleteContact, handleDeleteContact,
} = useContactsAndChannels({ } = useContactsAndChannels({
@@ -424,25 +421,16 @@ export function App() {
[fetchUndecryptedCount, setChannels] [fetchUndecryptedCount, setChannels]
); );
const handleOpenNewMessage = useCallback( const handleOpenNewMessage = useCallback(() => {
(event?: MouseEvent<HTMLButtonElement>) => { setNewMessagePrefillRequest(null);
setNewMessagePrefillRequest(null); openNewMessageModal();
setShowBulkAddChannelTab(event?.altKey === true); }, [openNewMessageModal]);
openNewMessageModal();
},
[openNewMessageModal]
);
const handleCloseNewMessage = useCallback(() => { const handleCloseNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null); setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(false);
closeNewMessageModal(); closeNewMessageModal();
}, [closeNewMessageModal]); }, [closeNewMessageModal]);
const handleCloseBulkAddResults = useCallback(() => {
setBulkAddResult(null);
}, []);
const handleChannelReferenceClick = useCallback( const handleChannelReferenceClick = useCallback(
(channelName: string) => { (channelName: string) => {
const existingChannel = channels.find((channel) => channel.name === channelName); const existingChannel = channels.find((channel) => channel.name === channelName);
@@ -456,20 +444,11 @@ export function App() {
hashtagName: channelName.slice(1), hashtagName: channelName.slice(1),
nonce: (previous?.nonce ?? 0) + 1, nonce: (previous?.nonce ?? 0) + 1,
})); }));
setShowBulkAddChannelTab(false);
openNewMessageModal(); openNewMessageModal();
}, },
[channels, handleNavigateToChannel, openNewMessageModal] [channels, handleNavigateToChannel, openNewMessageModal]
); );
const handleBulkAddChannels = useCallback(
async (channelNames: string[], tryHistorical: boolean) => {
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
setBulkAddResult(result);
},
[handleBulkCreateHashtagChannels]
);
const statusProps = { const statusProps = {
health, health,
config, config,
@@ -492,11 +471,6 @@ export function App() {
favorites, favorites,
legacySortOrder: appSettings?.sidebar_sort_order, legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled, isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
};
const bulkAddChannelResultModalProps = {
result: bulkAddResult,
}; };
const conversationPaneProps = { const conversationPaneProps = {
activeConversation, activeConversation,
@@ -581,11 +555,6 @@ export function App() {
blockedNames: appSettings?.blocked_names, blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey, onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName, 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 = { const crackerProps = {
packets: rawPackets, packets: rawPackets,
@@ -594,12 +563,10 @@ export function App() {
}; };
const newMessageModalProps = { const newMessageModalProps = {
undecryptedCount, undecryptedCount,
showBulkAddChannelTab,
prefillRequest: newMessagePrefillRequest, prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact, onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel, onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel, onCreateHashtagChannel: handleCreateHashtagChannel,
onBulkAddHashtagChannels: handleBulkAddChannels,
}; };
const contactInfoPaneProps = { const contactInfoPaneProps = {
contactKey: infoPaneContactKey, contactKey: infoPaneContactKey,
@@ -663,7 +630,6 @@ export function App() {
<AppShell <AppShell
localLabel={localLabel} localLabel={localLabel}
showNewMessage={showNewMessage} showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings} showSettings={showSettings}
settingsSection={settingsSection} settingsSection={settingsSection}
sidebarOpen={sidebarOpen} sidebarOpen={sidebarOpen}
@@ -674,7 +640,6 @@ export function App() {
onToggleSettingsView={handleToggleSettingsView} onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView} onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage} onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel} onLocalLabelChange={setLocalLabel}
statusProps={statusProps} statusProps={statusProps}
sidebarProps={sidebarProps} sidebarProps={sidebarProps}
@@ -683,7 +648,6 @@ export function App() {
settingsProps={settingsProps} settingsProps={settingsProps}
crackerProps={crackerProps} crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps} newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps} contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps} channelInfoPaneProps={channelInfoPaneProps}
/> />
-15
View File
@@ -1,7 +1,6 @@
import type { import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
BulkCreateHashtagChannelsResult,
Channel, Channel,
ChannelDetail, ChannelDetail,
CommandResponse, CommandResponse,
@@ -35,7 +34,6 @@ import type {
RepeaterOwnerInfoResponse, RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse, RepeaterRadioSettingsResponse,
RepeaterStatusResponse, RepeaterStatusResponse,
TelemetryHistoryEntry,
StatisticsResponse, StatisticsResponse,
TraceResponse, TraceResponse,
UnreadCounts, UnreadCounts,
@@ -151,12 +149,6 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, { fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE', 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) => createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', { fetchJson<Contact>('/contacts', {
method: 'POST', method: 'POST',
@@ -192,11 +184,6 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, key }), 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) => deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }), fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`), getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
@@ -415,8 +402,6 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, { fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST', method: 'POST',
}), }),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
roomLogin: (publicKey: string, password: string) => roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, { fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST', method: 'POST',
-16
View File
@@ -5,7 +5,6 @@ import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { ConversationPane } from './ConversationPane'; import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal'; import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane'; import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane';
import { SecurityWarningModal } from './SecurityWarningModal'; import { SecurityWarningModal } from './SecurityWarningModal';
@@ -34,17 +33,12 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
type SidebarProps = ComponentProps<typeof Sidebar>; type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>; type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>; type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type BulkAddChannelResultModalProps = Omit<
ComponentProps<typeof BulkAddChannelResultModal>,
'open' | 'onClose'
>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>; type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>; type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface AppShellProps { interface AppShellProps {
localLabel: LocalLabel; localLabel: LocalLabel;
showNewMessage: boolean; showNewMessage: boolean;
showBulkAddResults: boolean;
showSettings: boolean; showSettings: boolean;
settingsSection: SettingsSection; settingsSection: SettingsSection;
sidebarOpen: boolean; sidebarOpen: boolean;
@@ -56,7 +50,6 @@ interface AppShellProps {
onToggleSettingsView: () => void; onToggleSettingsView: () => void;
onCloseSettingsView: () => void; onCloseSettingsView: () => void;
onCloseNewMessage: () => void; onCloseNewMessage: () => void;
onCloseBulkAddResults: () => void;
onLocalLabelChange: (label: LocalLabel) => void; onLocalLabelChange: (label: LocalLabel) => void;
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>; statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
sidebarProps: SidebarProps; sidebarProps: SidebarProps;
@@ -68,7 +61,6 @@ interface AppShellProps {
>; >;
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>; crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
newMessageModalProps: NewMessageModalProps; newMessageModalProps: NewMessageModalProps;
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps; contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps; channelInfoPaneProps: ChannelInfoPaneProps;
} }
@@ -76,7 +68,6 @@ interface AppShellProps {
export function AppShell({ export function AppShell({
localLabel, localLabel,
showNewMessage, showNewMessage,
showBulkAddResults,
showSettings, showSettings,
settingsSection, settingsSection,
sidebarOpen, sidebarOpen,
@@ -88,7 +79,6 @@ export function AppShell({
onToggleSettingsView, onToggleSettingsView,
onCloseSettingsView, onCloseSettingsView,
onCloseNewMessage, onCloseNewMessage,
onCloseBulkAddResults,
onLocalLabelChange, onLocalLabelChange,
statusProps, statusProps,
sidebarProps, sidebarProps,
@@ -97,7 +87,6 @@ export function AppShell({
settingsProps, settingsProps,
crackerProps, crackerProps,
newMessageModalProps, newMessageModalProps,
bulkAddChannelResultModalProps,
contactInfoPaneProps, contactInfoPaneProps,
channelInfoPaneProps, channelInfoPaneProps,
}: AppShellProps) { }: AppShellProps) {
@@ -317,11 +306,6 @@ export function AppShell({
open={showNewMessage} open={showNewMessage}
onClose={onCloseNewMessage} onClose={onCloseNewMessage}
/> />
<BulkAddChannelResultModal
{...bulkAddChannelResultModalProps}
open={showBulkAddResults}
onClose={onCloseBulkAddResults}
/>
<SecurityWarningModal health={statusProps.health} /> <SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} /> <ContactInfoPane {...contactInfoPaneProps} />
@@ -1,101 +0,0 @@
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>
);
}
+32 -112
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react'; import { Ban, Search, Star } from 'lucide-react';
import { import {
LineChart, LineChart,
@@ -35,7 +35,6 @@ import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext'; import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { CONTACT_TYPE_REPEATER } from '../types';
import type { import type {
Contact, Contact,
ContactActiveRoom, ContactActiveRoom,
@@ -159,7 +158,6 @@ export function ContactInfoPane({
contact !== null && contact !== null &&
!isPrefixOnlyResolvedContact && !isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert); isUnknownFullKeyContact(contact.public_key, contact.last_advert);
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
return ( return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}> <Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -442,7 +440,7 @@ export function ContactInfoPane({
</div> </div>
)} )}
{!isRepeater && onSearchMessagesByKey && ( {onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border"> <div className="px-5 py-3 border-b border-border">
<button <button
type="button" type="button"
@@ -455,60 +453,40 @@ export function ContactInfoPane({
</div> </div>
)} )}
{/* Nearest Repeaters (Hops) — last 7 days only */} {/* Nearest Repeaters */}
{analytics && {analytics && analytics.nearest_repeaters.length > 0 && (
(() => { <div className="px-5 py-3 border-b border-border">
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400; <SectionLabel>Nearest Repeaters</SectionLabel>
const recent = analytics.nearest_repeaters.filter( <div className="space-y-1">
(r) => r.last_seen >= sevenDaysAgo {analytics.nearest_repeaters.map((r) => (
); <div key={r.public_key} className="flex justify-between items-center text-sm">
if (recent.length === 0) return null; <span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
return ( <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
<div className="px-5 py-3 border-b border-border"> {r.path_len === 0
<SectionLabel>Nearest Repeaters Hops (last 7 days)</SectionLabel> ? 'direct'
<div className="space-y-1"> : `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
{recent.map((r) => ( · {r.heard_count}x
<div </span>
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>
})()} </div>
{/* Geographically nearest repeaters (repeaters only) */}
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
<NearbyRepeatersSection
contact={contact}
contacts={contacts}
distanceUnit={distanceUnit}
/>
)} )}
{/* Advert Paths */} {/* Advert Paths */}
{analytics && analytics.advert_paths.length > 0 && ( {analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border"> <div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel> <SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1.5"> <div className="space-y-1">
{analytics.advert_paths.map((p) => ( {analytics.advert_paths.map((p) => (
<div <div
key={p.path + p.first_seen} key={p.path + p.first_seen}
className="flex justify-between items-start gap-2 text-sm" className="flex justify-between items-center text-sm"
> >
<span className="font-mono text-xs break-all"> <span className="font-mono text-xs truncate">
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'} {p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span> </span>
<span className="text-xs text-muted-foreground flex-shrink-0"> <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)} {p.heard_count}x · {formatTime(p.last_seen)}
</span> </span>
</div> </div>
@@ -540,21 +518,17 @@ export function ContactInfoPane({
</div> </div>
)} )}
{!isRepeater && ( <MessageStatsSection
<> dmMessageCount={analytics?.dm_message_count ?? 0}
<MessageStatsSection channelMessageCount={analytics?.channel_message_count ?? 0}
dmMessageCount={analytics?.dm_message_count ?? 0} />
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} /> <ActivityChartsSection analytics={analytics} />
<MostActiveChannelsSection <MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []} channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel} onNavigateToChannel={onNavigateToChannel}
/> />
</>
)}
</div> </div>
) : ( ) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground"> <div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -852,60 +826,6 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
); );
} }
function NearbyRepeatersSection({
contact,
contacts,
distanceUnit,
}: {
contact: Contact;
contacts: Contact[];
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
}) {
const nearby = useMemo(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
for (const other of contacts) {
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
if (
other.public_key === contact.public_key ||
other.type !== CONTACT_TYPE_REPEATER ||
!isValidLocation(other.lat, other.lon) ||
heardAt < sevenDaysAgo
) {
continue;
}
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
if (dist !== null) {
results.push({
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
publicKey: other.public_key,
distance: dist,
});
}
}
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5);
}, [contact.public_key, contact.lat, contact.lon, contacts]);
if (nearby.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Geo (last 7 days)</SectionLabel>
<div className="space-y-1">
{nearby.map((r) => (
<div key={r.publicKey} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistance(r.distance, distanceUnit)}
</span>
</div>
))}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) { function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<div> <div>
@@ -233,7 +233,6 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications} onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact} onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
/> />
</Suspense> </Suspense>
); );
+41 -166
View File
@@ -3,29 +3,23 @@ import { Dice5 } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog'; } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox'; import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag'; type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface BulkParseResult {
channelNames: string[];
invalidNames: string[];
}
interface NewMessageModalProps { interface NewMessageModalProps {
open: boolean; open: boolean;
undecryptedCount: number; undecryptedCount: number;
showBulkAddChannelTab?: boolean;
prefillRequest?: { prefillRequest?: {
tab: 'hashtag'; tab: 'hashtag';
hashtagName: string; hashtagName: string;
@@ -35,121 +29,53 @@ interface NewMessageModalProps {
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>; onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>; onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: 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({ export function NewMessageModal({
open, open,
undecryptedCount, undecryptedCount,
showBulkAddChannelTab = false,
prefillRequest = null, prefillRequest = null,
onClose, onClose,
onCreateContact, onCreateContact,
onCreateChannel, onCreateChannel,
onCreateHashtagChannel, onCreateHashtagChannel,
onBulkAddHashtagChannels,
}: NewMessageModalProps) { }: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('new-contact'); const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [contactKey, setContactKey] = useState(''); const [contactKey, setContactKey] = useState('');
const [channelKey, setChannelKey] = useState(''); const [channelKey, setChannelKey] = useState('');
const [bulkChannelText, setBulkChannelText] = useState('');
const [tryHistorical, setTryHistorical] = useState(false); const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false); const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null); const hashtagInputRef = useRef<HTMLInputElement>(null);
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
const resetForm = () => { const resetForm = () => {
setName(''); setName('');
setContactKey(''); setContactKey('');
setChannelKey(''); setChannelKey('');
setBulkChannelText('');
setTryHistorical(false); setTryHistorical(false);
setPermitCapitals(false); setPermitCapitals(false);
setError(''); setError('');
}; };
useEffect(() => { useEffect(() => {
if (!open) { if (!open || !prefillRequest) {
return; return;
} }
if (prefillRequest) { setTab(prefillRequest.tab);
setTab(prefillRequest.tab); setName(prefillRequest.hashtagName);
setName(prefillRequest.hashtagName); setContactKey('');
setContactKey(''); setChannelKey('');
setChannelKey(''); setTryHistorical(false);
setBulkChannelText(''); setPermitCapitals(false);
setTryHistorical(false); setError('');
setPermitCapitals(false); setLoading(false);
setError(''); requestAnimationFrame(() => {
setLoading(false); hashtagInputRef.current?.focus();
requestAnimationFrame(() => { });
hashtagInputRef.current?.focus(); }, [open, prefillRequest]);
});
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 () => { const handleCreate = async () => {
setError(''); setError('');
@@ -161,6 +87,7 @@ export function NewMessageModal({
setError('Name and public key are required'); setError('Name and public key are required');
return; return;
} }
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical); await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-channel') { } else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) { if (!name.trim() || !channelKey.trim()) {
@@ -175,24 +102,10 @@ export function NewMessageModal({
setError(validationError); setError(validationError);
return; return;
} }
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); 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(); resetForm();
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -205,6 +118,16 @@ 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 () => { const handleCreateAndAddAnother = async () => {
setError(''); setError('');
const channelName = name.trim(); const channelName = name.trim();
@@ -216,6 +139,7 @@ export function NewMessageModal({
setLoading(true); setLoading(true);
try { try {
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
setName(''); setName('');
@@ -242,36 +166,28 @@ export function NewMessageModal({
} }
}} }}
> >
<DialogContent className="sm:max-w-[560px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>New Conversation</DialogTitle> <DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'} {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 === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'} {tab === 'hashtag' && 'Join a public hashtag channel'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs <Tabs
value={tab} value={tab}
onValueChange={(value) => { onValueChange={(v) => {
setTab(value as Tab); setTab(v as Tab);
resetForm(); resetForm();
}} }}
className="w-full" className="w-full"
> >
<TabsList <TabsList className="grid w-full grid-cols-3">
className={
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
}
>
<TabsTrigger value="new-contact">Contact</TabsTrigger> <TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger> <TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger> <TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
{showBulkAddChannelTab && (
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="new-contact" className="mt-4 space-y-4"> <TabsContent value="new-contact" className="mt-4 space-y-4">
@@ -323,7 +239,7 @@ export function NewMessageModal({
const bytes = new Uint8Array(16); const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes); crypto.getRandomValues(bytes);
const hex = Array.from(bytes) const hex = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join(''); .join('');
setChannelKey(hex); setChannelKey(hex);
}} }}
@@ -352,55 +268,20 @@ export function NewMessageModal({
</div> </div>
</div> </div>
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
<label className="flex cursor-pointer items-center gap-3"> <label className="flex items-center gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={permitCapitals} checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)} onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary" className="w-4 h-4 rounded border-input accent-primary"
/> />
<span className="text-sm">Permit capitals in channel key derivation</span> <span className="text-sm">Permit capitals in channel key derivation</span>
</label> </label>
<p className="pl-7 text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground pl-7">
Not recommended; most companions normalize to lowercase Not recommended; most companions normalize to lowercase
</p> </p>
</div> </div>
</TabsContent> </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> </Tabs>
{showHistoricalOption && ( {showHistoricalOption && (
@@ -408,7 +289,7 @@ export function NewMessageModal({
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<Label <Label
htmlFor="try-historical" htmlFor="try-historical"
className="cursor-pointer text-sm text-muted-foreground" className="text-sm text-muted-foreground cursor-pointer"
> >
Try decrypting {undecryptedCount.toLocaleString()} stored packet Try decrypting {undecryptedCount.toLocaleString()} stored packet
{undecryptedCount !== 1 ? 's' : ''} {undecryptedCount !== 1 ? 's' : ''}
@@ -420,7 +301,7 @@ export function NewMessageModal({
/> />
</div> </div>
{tryHistorical && ( {tryHistorical && (
<p className="text-right text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground text-right">
Messages will stream in as they decrypt in the background Messages will stream in as they decrypt in the background
</p> </p>
)} )}
@@ -449,13 +330,7 @@ export function NewMessageModal({
</Button> </Button>
)} )}
<Button onClick={handleCreate} disabled={loading}> <Button onClick={handleCreate} disabled={loading}>
{loading {loading ? 'Creating...' : 'Create'}
? tab === 'bulk-hashtag'
? 'Adding...'
: 'Creating...'
: tab === 'bulk-hashtag'
? 'Add Channels'
: 'Create'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+21 -35
View File
@@ -406,12 +406,9 @@ interface HopNodeProps {
distanceUnit: DistanceUnit; distanceUnit: DistanceUnit;
} }
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) { function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1; const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0; const isUnknown = hop.matches.length === 0;
const [expanded, setExpanded] = useState(false);
// Calculate distance from previous location for a contact // Calculate distance from previous location for a contact
// Returns null if prev location unknown/ambiguous or contact has no valid location // Returns null if prev location unknown/ambiguous or contact has no valid location
@@ -450,38 +447,27 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
<div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div> <div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div>
) : isAmbiguous ? ( ) : isAmbiguous ? (
<div> <div>
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map( {hop.matches.map((contact) => {
(contact) => { const dist = getDistanceForContact(contact);
const dist = getDistanceForContact(contact); const hasLocation = isValidLocation(contact.lat, contact.lon);
const hasLocation = isValidLocation(contact.lat, contact.lon); return (
return ( <div key={contact.public_key} className="font-medium truncate">
<div key={contact.public_key} className="font-medium truncate"> {contact.name || contact.public_key.slice(0, 12)}
{contact.name || contact.public_key.slice(0, 12)} {dist !== null && (
{dist !== null && ( <span className="text-xs text-muted-foreground ml-1">
<span className="text-xs text-muted-foreground ml-1"> - {formatDistance(dist, distanceUnit)}
- {formatDistance(dist, distanceUnit)} </span>
</span> )}
)} {hasLocation && (
{hasLocation && ( <CoordinateLink
<CoordinateLink lat={contact.lat!}
lat={contact.lat!} lon={contact.lon!}
lon={contact.lon!} publicKey={contact.public_key}
publicKey={contact.public_key} />
/> )}
)} </div>
</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>
) : ( ) : (
<div className="font-medium truncate"> <div className="font-medium truncate">
+6 -50
View File
@@ -1,9 +1,8 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react'; import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon'; import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin'; import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner'; import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
@@ -13,13 +12,7 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils'; import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo'; import { ContactStatusInfo } from './ContactStatusInfo';
import type { import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane'; import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane'; import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -30,7 +23,6 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane'; import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane'; import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane'; import { ConsolePane } from './repeater/RepeaterConsolePane';
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts) // Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
@@ -53,7 +45,6 @@ interface RepeaterDashboardProps {
onToggleNotifications: () => void; onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void; onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
} }
export function RepeaterDashboard({ export function RepeaterDashboard({
@@ -71,7 +62,6 @@ export function RepeaterDashboard({
onToggleNotifications, onToggleNotifications,
onToggleFavorite, onToggleFavorite,
onDeleteContact, onDeleteContact,
onOpenContactInfo,
}: RepeaterDashboardProps) { }: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null; const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -98,24 +88,6 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id); useRememberedServerPassword('repeater', conversation.id);
// Telemetry history: preload from stored data, refresh from live status
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
useEffect(() => {
if (!loggedIn) return;
api
.repeaterTelemetryHistory(conversation.id)
.then(setTelemetryHistory)
.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 && liveHistory.length > 0) {
setTelemetryHistory(liveHistory);
}
}, [paneData.status?.telemetry_history]);
const isFav = isFavorite(favorites, 'contact', conversation.id); const isFav = isFavorite(favorites, 'contact', conversation.id);
const handleRepeaterLogin = async (nextPassword: string) => { const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword); await login(nextPassword);
@@ -143,24 +115,9 @@ export function RepeaterDashboard({
<span className="flex min-w-0 flex-col"> <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-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2"> <span className="flex min-w-0 flex-1 items-baseline gap-2">
<h2 className="min-w-0 flex-shrink font-semibold text-base"> <span className="min-w-0 flex-shrink truncate font-semibold text-base">
{onOpenContactInfo ? ( {conversation.name}
<button </span>
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 <span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary" className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button" role="button"
@@ -317,7 +274,6 @@ export function RepeaterDashboard({
onRefresh={() => refreshPane('status')} onRefresh={() => refreshPane('status')}
disabled={anyLoading} disabled={anyLoading}
/> />
<TelemetryHistoryPane entries={telemetryHistory} />
<RadioSettingsPane <RadioSettingsPane
data={paneData.radioSettings} data={paneData.radioSettings}
state={paneStates.radioSettings} state={paneStates.radioSettings}
@@ -2,7 +2,6 @@ import { useState, useEffect, type ReactNode } from 'react';
import type { import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
Contact,
HealthStatus, HealthStatus,
RadioAdvertMode, RadioAdvertMode,
RadioConfig, RadioConfig,
@@ -48,8 +47,6 @@ interface SettingsModalBaseProps {
blockedNames?: string[]; blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void; onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void; onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
} }
export type SettingsModalProps = SettingsModalBaseProps & export type SettingsModalProps = SettingsModalBaseProps &
@@ -83,8 +80,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames, blockedNames,
onToggleBlockedKey, onToggleBlockedKey,
onToggleBlockedName, onToggleBlockedName,
contacts,
onBulkDeleteContacts,
} = props; } = props;
const externalSidebarNav = props.externalSidebarNav === true; const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -244,8 +239,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames={blockedNames} blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey} onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName} onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
className={sectionContentClass} className={sectionContentClass}
/> />
) : ( ) : (
+35 -41
View File
@@ -97,7 +97,7 @@ interface SidebarProps {
channels: Channel[]; channels: Channel[];
activeConversation: Conversation | null; activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void; onSelectConversation: (conversation: Conversation) => void;
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void; onNewMessage: () => void;
lastMessageTimes: ConversationTimes; lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>; unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */ /** Tracks which conversations have unread messages that mention the user */
@@ -110,8 +110,6 @@ interface SidebarProps {
/** Legacy global sort order, used only to seed per-section local preferences. */ /** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder; legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
} }
type InitialSectionSortState = { type InitialSectionSortState = {
@@ -155,16 +153,7 @@ export function Sidebar({
favorites, favorites,
legacySortOrder, legacySortOrder,
isConversationNotificationsEnabled, isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
}: SidebarProps) { }: 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 [searchQuery, setSearchQuery] = useState('');
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []); const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders); const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
@@ -409,32 +398,38 @@ export function Sidebar({
[sortedChannels, query] [sortedChannels, query]
); );
const filteredNonRepeaterContacts = useMemo(() => { const filteredNonRepeaterContacts = useMemo(
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c)); () =>
return query query
? visible.filter( ? sortedNonRepeaterContacts.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) (c) =>
) c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
: visible; )
}, [sortedNonRepeaterContacts, query, isContactBlocked]); : sortedNonRepeaterContacts,
[sortedNonRepeaterContacts, query]
);
const filteredRooms = useMemo(() => { const filteredRooms = useMemo(
const visible = sortedRooms.filter((c) => !isContactBlocked(c)); () =>
return query query
? visible.filter( ? sortedRooms.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) (c) =>
) c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
: visible; )
}, [sortedRooms, query, isContactBlocked]); : sortedRooms,
[sortedRooms, query]
);
const filteredRepeaters = useMemo(() => { const filteredRepeaters = useMemo(
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c)); () =>
return query query
? visible.filter( ? sortedRepeaters.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) (c) =>
) c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
: visible; )
}, [sortedRepeaters, query, isContactBlocked]); : sortedRepeaters,
[sortedRepeaters, query]
);
// Expand sections while searching; restore prior collapse state when search ends. // Expand sections while searching; restore prior collapse state when search ends.
useEffect(() => { useEffect(() => {
@@ -659,9 +654,8 @@ export function Sidebar({
}) => ( }) => (
<div <div
key={key} key={key}
data-active={active ? 'true' : undefined}
className={cn( className={cn(
'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', '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' active && 'bg-accent border-l-primary'
)} )}
role="button" role="button"
@@ -670,10 +664,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
onClick={onClick} onClick={onClick}
> >
<span className="sidebar-tool-icon" aria-hidden="true"> <span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
{icon} {icon}
</span> </span>
<span className="sidebar-tool-label flex-1 truncate">{label}</span> <span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
</div> </div>
); );
@@ -866,7 +860,7 @@ export function Sidebar({
onClick={onNewMessage} onClick={onNewMessage}
title="Add channel or contact" title="Add channel or contact"
aria-label="Add channel or contact" aria-label="Add channel or contact"
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary" className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
> >
<SquarePen className="h-4 w-4" /> <SquarePen className="h-4 w-4" />
<span>Add Channel/Contact</span> <span>Add Channel/Contact</span>
@@ -1,167 +0,0 @@
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>
);
}
@@ -1,352 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { api } from '../../api';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { toast } from '../ui/sonner';
import type { Contact } from '../../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
1: 'Client',
2: 'Repeater',
3: 'Room',
4: 'Sensor',
};
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateISO(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function datetimeToUnix(datetimeStr: string): number {
const d = new Date(datetimeStr);
return Math.floor(d.getTime() / 1000);
}
interface BulkDeleteContactsModalProps {
open: boolean;
onClose: () => void;
contacts: Contact[];
onDeleted: (deletedKeys: string[]) => void;
}
export function BulkDeleteContactsModal({
open,
onClose,
contacts,
onDeleted,
}: BulkDeleteContactsModalProps) {
const [step, setStep] = useState<'select' | 'confirm'>('select');
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
const [deleting, setDeleting] = useState(false);
const lastClickedKeyRef = useRef<string | null>(null);
const resetAndClose = useCallback(() => {
setStep('select');
setSelectedKeys(new Set());
setStartDate('');
setEndDate('');
setTypeFilter('all');
lastClickedKeyRef.current = null;
onClose();
}, [onClose]);
const filteredContacts = useMemo(() => {
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
if (typeFilter !== 'all') {
list = list.filter((c) => c.type === typeFilter);
}
if (startDate) {
const start = datetimeToUnix(startDate);
list = list.filter((c) => (c.first_seen ?? 0) >= start);
}
if (endDate) {
const end = datetimeToUnix(endDate);
list = list.filter((c) => (c.first_seen ?? 0) <= end);
}
return list;
}, [contacts, typeFilter, startDate, endDate]);
const handleToggle = (key: string, shiftKey: boolean) => {
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
const keys = filteredContacts.map((c) => c.public_key);
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
const curIdx = keys.indexOf(key);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
const rangeKeys = keys.slice(from, to + 1);
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const k of rangeKeys) next.add(k);
return next;
});
lastClickedKeyRef.current = key;
return;
}
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
lastClickedKeyRef.current = key;
};
const handleSelectAll = () => {
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
};
const handleSelectNone = () => {
setSelectedKeys(new Set());
};
const selectedContacts = useMemo(
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
[contacts, selectedKeys]
);
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
const minDate =
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
const maxDate =
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
const handleDelete = async () => {
setDeleting(true);
try {
const keysToDelete = [...selectedKeys];
const result = await api.bulkDeleteContacts(keysToDelete);
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
onDeleted(keysToDelete);
resetAndClose();
} catch (err) {
console.error('Bulk delete failed:', err);
toast.error('Bulk delete failed', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
</DialogTitle>
<DialogDescription>
{step === 'select'
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
: 'Review the contacts that will be permanently deleted.'}
</DialogDescription>
</DialogHeader>
{step === 'select' && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
Select all
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
Select none
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
{(startDate || endDate) && ' (filtered)'}
{' · '}
{selectedKeys.size} selected
</div>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
{filteredContacts.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No contacts match the selected date range.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5 w-8" />
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{filteredContacts.map((c) => (
<tr
key={c.public_key}
className="border-t border-border hover:bg-accent/50 cursor-pointer"
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
>
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={selectedKeys.has(c.public_key)}
onChange={(e) =>
handleToggle(
c.public_key,
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
)
}
onClick={(e) => e.stopPropagation()}
className="rounded border-input"
/>
</td>
<td className="px-3 py-1.5 truncate max-w-[10rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={resetAndClose}>
Cancel
</Button>
<Button
variant="outline"
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
disabled={selectedKeys.size === 0}
onClick={() => setStep('confirm')}
>
Proceed to confirmation ({selectedKeys.size})
</Button>
</div>
</>
)}
{step === 'confirm' && (
<>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{selectedContacts.map((c) => (
<tr key={c.public_key} className="border-t border-border">
<td className="px-3 py-1.5 truncate max-w-[12rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
variant="destructive"
className="w-full h-auto py-3 text-wrap"
disabled={deleting}
onClick={handleDelete}
>
{deleting
? 'Deleting...'
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
repeaterCount > 0 &&
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
]
.filter(Boolean)
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
</Button>
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
Back
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -6,8 +6,7 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner'; import { toast } from '../ui/sonner';
import { api } from '../../api'; import { api } from '../../api';
import { formatTime } from '../../utils/messageParser'; import { formatTime } from '../../utils/messageParser';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal'; import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
export function SettingsDatabaseSection({ export function SettingsDatabaseSection({
appSettings, appSettings,
@@ -18,8 +17,6 @@ export function SettingsDatabaseSection({
blockedNames = [], blockedNames = [],
onToggleBlockedKey, onToggleBlockedKey,
onToggleBlockedName, onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
className, className,
}: { }: {
appSettings: AppSettings; appSettings: AppSettings;
@@ -30,23 +27,18 @@ export function SettingsDatabaseSection({
blockedNames?: string[]; blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void; onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void; onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
className?: string; className?: string;
}) { }) {
const [retentionDays, setRetentionDays] = useState('14'); const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false); const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false); const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]); }, [appSettings]);
const handleCleanup = async () => { const handleCleanup = async () => {
@@ -100,15 +92,7 @@ export function SettingsDatabaseSection({
setError(null); setError(null);
try { try {
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert }; await onSaveAppSettings({ 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'); toast.success('Database settings saved');
} catch (err) { } catch (err) {
console.error('Failed to save database settings:', err); console.error('Failed to save database settings:', err);
@@ -121,93 +105,93 @@ export function SettingsDatabaseSection({
return ( return (
<div className={className}> <div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base">Database Overview</Label> <div className="flex justify-between items-center">
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2"> <span className="text-sm text-muted-foreground">Database size</span>
<div className="flex justify-between items-center"> <span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
<span className="text-sm">Database size</span>
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Oldest undecrypted packet</span>
{health?.oldest_undecrypted_timestamp ? (
<span className="text-sm font-semibold">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="font-normal text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days)
</span>
</span>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</div>
</div> </div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div> </div>
<Separator /> <Separator />
{/* ── Storage Cleanup ── */} <div className="space-y-3">
<div className="space-y-4"> <Label>Delete Undecrypted Packets</Label>
<Label className="text-base">Storage Cleanup</Label> <p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets containing DMs and channel messages that have not
<div className="rounded-md border border-border p-3 space-y-2"> yet been decrypted. These packets are retained in case you later obtain the correct key
<Label className="text-sm">Delete Undecrypted Packets</Label> once deleted, these messages can never be recovered or decrypted.
<p className="text-xs text-muted-foreground"> </p>
Permanently deletes stored raw packets that have not yet been decrypted. These are <div className="flex gap-2 items-end">
retained in case you later obtain the correct key once deleted, these messages can <div className="space-y-1">
never be recovered. <Label htmlFor="retention-days" className="text-xs">
</p> Older than (days)
<div className="flex gap-2 items-end"> </Label>
<div className="space-y-1"> <Input
<Label htmlFor="retention-days" className="text-xs text-muted-foreground"> id="retention-days"
Older than (days) type="number"
</Label> min="1"
<Input max="365"
id="retention-days" value={retentionDays}
type="number" onChange={(e) => setRetentionDays(e.target.value)}
min="1" className="w-24"
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>
<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 <Button
variant="outline" variant="outline"
onClick={handlePurgeDecryptedRawPackets} onClick={handleCleanup}
disabled={purgingDecryptedRaw} disabled={cleaning}
className="w-full border-warning/50 text-warning hover:bg-warning/10" className="border-destructive/50 text-destructive hover:bg-destructive/10"
> >
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'} {cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button> </Button>
</div> </div>
</div> </div>
<Separator /> <Separator />
{/* ── DM Decryption ── */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base">DM Decryption</Label> <Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -223,87 +207,17 @@ export function SettingsDatabaseSection({
</p> </p>
</div> </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 /> <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"> <div className="space-y-3">
<Label>Blocked Contacts</Label> <Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
MQTT forwarding and bot responses are not affected. Messages are still stored and will affected. Messages are still stored and will reappear if unblocked.
reappear if unblocked.
</p> </p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? ( {blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No blocked contacts</p>
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"> <div className="space-y-2">
{blockedKeys.length > 0 && ( {blockedKeys.length > 0 && (
@@ -354,25 +268,15 @@ export function SettingsDatabaseSection({
)} )}
</div> </div>
<Separator /> {error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
{/* Bulk delete */} <Button onClick={handleSave} disabled={busy} className="w-full">
<div className="space-y-3"> {busy ? 'Saving...' : 'Save Settings'}
<Label>Bulk Delete Contacts</Label> </Button>
<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> </div>
); );
} }
@@ -347,20 +347,17 @@ function PreviewSidebarRow({
}) { }) {
return ( return (
<div <div
data-active={active ? 'true' : undefined} className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
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' active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`} }`}
> >
<span className="sidebar-tool-icon" aria-hidden="true"> {leading}
{leading} <span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
</span>
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
{label} {label}
</span> </span>
{badge} {badge}
{!badge && ( {!badge && (
<span className="sidebar-tool-icon" aria-hidden="true"> <span className="text-muted-foreground" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" /> <MessageSquare className="h-3.5 w-3.5" />
</span> </span>
)} )}
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
return ( return (
<div className={className}> <div className={className}>
{/* ── Connection ── */} {/* Connection display */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base">Connection</Label> <Label>Connection</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`w-2 h-2 rounded-full ${ className={`w-2 h-2 rounded-full ${
@@ -428,58 +428,15 @@ export function SettingsRadioSection({
</p> </p>
</div> </div>
<Separator /> {/* Radio Name */}
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Radio Name</Label> <Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} /> <Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div> </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 /> <Separator />
{/* ── Radio Parameters ── */} {/* Radio Config */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="preset">Preset</Label> <Label htmlFor="preset">Preset</Label>
<select <select
@@ -561,36 +518,11 @@ export function SettingsRadioSection({
</div> </div>
</div> </div>
{config.path_hash_mode_supported && (
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
)}
<Separator /> <Separator />
{/* ── Location ── */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-base">Location</Label> <Label>Location</Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -653,8 +585,53 @@ export function SettingsRadioSection({
library. library.
</p> </p>
</div> </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> </div>
{config.path_hash_mode_supported && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
</>
)}
{error && ( {error && (
<div className="text-sm text-destructive" role="alert"> <div className="text-sm text-destructive" role="alert">
{error} {error}
@@ -680,28 +657,64 @@ export function SettingsRadioSection({
<Separator /> <Separator />
{/* ── Messaging ── */} {/* Keys */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-base">Messaging</Label> <Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3"> <Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Checkbox <Input
id="multi-acks-enabled" id="private-key"
checked={multiAcksEnabled} type="password"
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)} autoComplete="off"
className="mt-0.5" value={privateKey}
/> onChange={(e) => setPrivateKey(e.target.value)}
<div className="space-y-1"> placeholder="64-character hex private key"
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label> />
<p className="text-xs text-muted-foreground"> <Button
When enabled, the radio sends one extra direct ACK transmission before the normal ACK onClick={handleSetPrivateKey}
for received direct messages. This is a firmware-level receive behavior, not a disabled={identityBusy || identityRebooting || !privateKey.trim()}
RemoteTerm retry setting. className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
</p> variant="outline"
</div> >
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div> </div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -733,13 +746,6 @@ export function SettingsRadioSection({
Configured radio contact capacity. Favorites reload first, then background maintenance Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%. refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p> </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> </div>
{floodError && ( {floodError && (
@@ -754,28 +760,8 @@ export function SettingsRadioSection({
<Separator /> <Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-base">Advertising &amp; Discovery</Label> <Label className="text-base">Hear &amp; Be Heard</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>
<div className="space-y-2"> <div className="space-y-2">
+1 -20
View File
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner'; import { toast } from '../components/ui/sonner';
import { getContactDisplayName } from '../utils/pubkey'; import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel'; import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types'; import type { Channel, Contact, Conversation } from '../types';
interface UseContactsAndChannelsArgs { interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void; setActiveConversation: (conv: Conversation | null) => void;
@@ -112,24 +112,6 @@ export function useContactsAndChannels({
[fetchUndecryptedCountInternal, setActiveConversation] [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( const handleDeleteChannel = useCallback(
async (key: string) => { async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return; if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -208,7 +190,6 @@ export function useContactsAndChannels({
handleCreateContact, handleCreateContact,
handleCreateChannel, handleCreateChannel,
handleCreateHashtagChannel, handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel, handleDeleteChannel,
handleDeleteContact, handleDeleteContact,
}; };
-52
View File
@@ -56,14 +56,6 @@
--badge-mention: var(--destructive); --badge-mention: var(--destructive);
--badge-mention-foreground: var(--destructive-foreground); --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 */ /* Error toast */
--toast-error: 0 30% 14%; --toast-error: 0 30% 14%;
--toast-error-foreground: 0 56% 77%; --toast-error-foreground: 0 56% 77%;
@@ -134,50 +126,6 @@
animation: message-highlight 2s ease-out forwards; 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 */ /* Constrain CodeMirror editor width */
.cm-editor { .cm-editor {
max-width: 100% !important; max-width: 100% !important;
@@ -1,46 +0,0 @@
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();
});
});
-26
View File
@@ -169,32 +169,6 @@ describe('MessageList channel sender rendering', () => {
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room'); 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 () => { it('links valid channel references in direct messages too', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChannelReferenceClick = vi.fn(); const onChannelReferenceClick = vi.fn();
+5 -15
View File
@@ -122,21 +122,11 @@ describe('linked channel references', () => {
]); ]);
}); });
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', () => { it('ignores invalid or embedded channel-like text', () => {
const references = findLinkedChannelReferences( expect(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,' findLinkedChannelReferences(
); 'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
)
expect(references.map((reference) => reference.label)).toEqual(['#good-room']); ).toEqual([]);
}); });
}); });
@@ -27,7 +27,6 @@ describe('NewMessageModal form reset', () => {
const onCreateContact = vi.fn().mockResolvedValue(undefined); const onCreateContact = vi.fn().mockResolvedValue(undefined);
const onCreateChannel = vi.fn().mockResolvedValue(undefined); const onCreateChannel = vi.fn().mockResolvedValue(undefined);
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined); const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -45,7 +44,6 @@ describe('NewMessageModal form reset', () => {
onCreateContact={onCreateContact} onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel} onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel} onCreateHashtagChannel={onCreateHashtagChannel}
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
{...overrides} {...overrides}
/> />
); );
@@ -113,53 +111,6 @@ 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', () => { describe('new-contact tab', () => {
it('clears name and key after successful Create', async () => { it('clears name and key after successful Create', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -51,14 +51,6 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook, 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 // Mock sonner toast
vi.mock('../components/ui/sonner', () => ({ vi.mock('../components/ui/sonner', () => ({
toast: { toast: {
@@ -426,7 +418,6 @@ describe('RepeaterDashboard', () => {
flood_dups: 1, flood_dups: 1,
direct_dups: 0, direct_dups: 0,
full_events: 0, full_events: 0,
telemetry_history: [],
}; };
render(<RepeaterDashboard {...defaultProps} />); render(<RepeaterDashboard {...defaultProps} />);
@@ -643,61 +634,4 @@ describe('RepeaterDashboard', () => {
overrideSpy.mockRestore(); overrideSpy.mockRestore();
}); });
}); });
describe('telemetry history', () => {
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();
});
});
});
}); });
+2 -3
View File
@@ -69,7 +69,6 @@ const baseSettings: AppSettings = {
flood_scope: '', flood_scope: '',
blocked_keys: [], blocked_keys: [],
blocked_names: [], blocked_names: [],
discovery_blocked_types: [],
}; };
function renderModal(overrides?: { function renderModal(overrides?: {
@@ -616,10 +615,10 @@ describe('SettingsModal', () => {
openDatabaseSection(); openDatabaseSection();
expect( expect(
screen.getByText(/removes packet-analysis availability for those messages/i) screen.getByText(/remove packet-analysis availability for those historical messages/i)
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' })); fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
await waitFor(() => { await waitFor(() => {
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true }); expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useContactsAndChannels } from '../hooks/useContactsAndChannels'; import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
import type { BulkCreateHashtagChannelsResult, Contact } from '../types'; import type { Contact } from '../types';
// Mock api module // Mock api module
vi.mock('../api', () => ({ vi.mock('../api', () => ({
@@ -18,7 +18,6 @@ vi.mock('../api', () => ({
getChannels: vi.fn(), getChannels: vi.fn(),
createContact: vi.fn(), createContact: vi.fn(),
createChannel: vi.fn(), createChannel: vi.fn(),
bulkCreateHashtagChannels: vi.fn(),
deleteContact: vi.fn(), deleteContact: vi.fn(),
deleteChannel: vi.fn(), deleteChannel: vi.fn(),
decryptHistoricalPackets: vi.fn(), decryptHistoricalPackets: vi.fn(),
@@ -172,41 +171,4 @@ describe('useContactsAndChannels', () => {
expect(api.getContacts).toHaveBeenCalledTimes(2); 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);
});
});
}); });
+4
View File
@@ -49,6 +49,10 @@
--overlay: 220 20% 10%; --overlay: 220 20% 10%;
} }
:root[data-theme='light'] .sidebar-tool-label {
color: hsl(var(--foreground));
}
/* ── Windows 95 ───────────────────────────────────────────── */ /* ── Windows 95 ───────────────────────────────────────────── */
:root[data-theme='windows-95'] { :root[data-theme='windows-95'] {
--background: 180 100% 25%; --background: 180 100% 25%;
-17
View File
@@ -243,15 +243,6 @@ export interface ChannelDetail {
top_senders_24h: ChannelTopSender[]; top_senders_24h: ChannelTopSender[];
} }
export interface BulkCreateHashtagChannelsResult {
created_channels: Channel[];
existing_count: number;
invalid_names: string[];
decrypt_started: boolean;
decrypt_total_packets: number;
message: string;
}
/** A single path that a message took to reach us */ /** A single path that a message took to reach us */
export interface MessagePath { export interface MessagePath {
/** Hex-encoded routing path */ /** Hex-encoded routing path */
@@ -341,7 +332,6 @@ export interface AppSettings {
flood_scope: string; flood_scope: string;
blocked_keys: string[]; blocked_keys: string[];
blocked_names: string[]; blocked_names: string[];
discovery_blocked_types: number[];
} }
export interface AppSettingsUpdate { export interface AppSettingsUpdate {
@@ -352,7 +342,6 @@ export interface AppSettingsUpdate {
flood_scope?: string; flood_scope?: string;
blocked_keys?: string[]; blocked_keys?: string[];
blocked_names?: string[]; blocked_names?: string[];
discovery_blocked_types?: number[];
} }
export interface MigratePreferencesRequest { export interface MigratePreferencesRequest {
@@ -416,12 +405,6 @@ export interface RepeaterStatusResponse {
flood_dups: number; flood_dups: number;
direct_dups: number; direct_dups: number;
full_events: number; full_events: number;
telemetry_history: TelemetryHistoryEntry[];
}
export interface TelemetryHistoryEntry {
timestamp: number;
data: Record<string, number>;
} }
export interface RepeaterNeighborsResponse { export interface RepeaterNeighborsResponse {
+1 -1
View File
@@ -3,7 +3,7 @@
* Channel messages have format "sender: message". * Channel messages have format "sender: message".
*/ */
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; 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; const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
export function parseSenderFromText(text: string): { sender: string | null; content: string } { export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': '); const colonIndex = text.indexOf(': ');
+1 -1
View File
@@ -147,7 +147,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
} }
// Generate URL hash from conversation // Generate URL hash from conversation
export function getConversationHash(conv: Conversation | null): string { function getConversationHash(conv: Conversation | null): string {
if (!conv) return ''; if (!conv) return '';
if (conv.type === 'raw') return '#raw'; if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map'; if (conv.type === 'map') return '#map';
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "remoteterm-meshcore" name = "remoteterm-meshcore"
version = "3.6.7" version = "3.6.3"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks" description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/create_github_release.sh --version X.Y.Z --asset PATH [options]
Options:
--version VERSION Release version / tag (required)
--asset PATH Asset to attach; may be specified multiple times
--notes-file PATH Markdown release notes file; defaults to CHANGELOG section
--full-git-hash HASH Commit to tag if the tag does not already exist locally
--title TITLE Release title (default: version)
--help Show this message
EOF
}
VERSION=""
TITLE=""
NOTES_FILE=""
FULL_GIT_HASH=""
ASSETS=()
TEMP_NOTES_FILE=""
cleanup() {
if [ -n "$TEMP_NOTES_FILE" ] && [ -f "$TEMP_NOTES_FILE" ]; then
rm -f "$TEMP_NOTES_FILE"
fi
}
trap cleanup EXIT
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--asset)
ASSETS+=("${2:-}")
shift 2
;;
--notes-file)
NOTES_FILE="${2:-}"
shift 2
;;
--full-git-hash)
FULL_GIT_HASH="${2:-}"
shift 2
;;
--title)
TITLE="${2:-}"
shift 2
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
[ "${#ASSETS[@]}" -gt 0 ] || release_die "At least one --asset is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
TITLE="${TITLE:-$VERSION}"
FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}"
for asset in "${ASSETS[@]}"; do
[ -f "$asset" ] || release_die "Asset not found: $asset"
done
if [ -z "$NOTES_FILE" ]; then
TEMP_NOTES_FILE="$(mktemp)"
release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$TEMP_NOTES_FILE"
NOTES_FILE="$TEMP_NOTES_FILE"
fi
[ -f "$NOTES_FILE" ] || release_die "Notes file not found: $NOTES_FILE"
if ! git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "[create_github_release] Creating local tag $VERSION at $FULL_GIT_HASH..." >&2
git -C "$REPO_ROOT" tag -a "$VERSION" "$FULL_GIT_HASH" -F "$NOTES_FILE"
fi
if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
echo "[create_github_release] Pushing tag $VERSION to origin..." >&2
git -C "$REPO_ROOT" push origin "$VERSION"
fi
if gh release view "$VERSION" >/dev/null 2>&1; then
echo "[create_github_release] Updating existing GitHub release $VERSION..." >&2
gh release upload "$VERSION" "${ASSETS[@]}" --clobber
gh release edit "$VERSION" --title "$TITLE" --notes-file "$NOTES_FILE"
else
echo "[create_github_release] Creating GitHub release $VERSION..." >&2
gh release create "$VERSION" "${ASSETS[@]}" --title "$TITLE" --notes-file "$NOTES_FILE" --verify-tag
fi
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/extract_release_notes.sh --version X.Y.Z --output PATH
Options:
--version VERSION Release version to extract from CHANGELOG.md
--output PATH Output markdown file path
--changelog PATH Override changelog path
--help Show this message
EOF
}
VERSION=""
OUTPUT_FILE=""
CHANGELOG_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--output)
OUTPUT_FILE="${2:-}"
shift 2
;;
--changelog)
CHANGELOG_PATH="${2:-}"
shift 2
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
[ -n "$OUTPUT_FILE" ] || release_die "--output is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$OUTPUT_FILE" "${CHANGELOG_PATH:-$REPO_ROOT/CHANGELOG.md}"
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/package_release_artifact.sh --version X.Y.Z [options]
Options:
--version VERSION Release version (required)
--git-hash HASH Short git hash to embed in artifact naming
--full-git-hash HASH Full git hash to archive
--output PATH Output zip path
--bundle-name NAME Bundle folder name inside the zip
--skip-prebuilt-build Reuse existing frontend/prebuilt instead of rebuilding it
--help Show this message
EOF
}
VERSION=""
GIT_HASH=""
FULL_GIT_HASH=""
OUTPUT_PATH=""
BUNDLE_NAME="Remote-Terminal-for-MeshCore"
SKIP_PREBUILT_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--git-hash)
GIT_HASH="${2:-}"
shift 2
;;
--full-git-hash)
FULL_GIT_HASH="${2:-}"
shift 2
;;
--output)
OUTPUT_PATH="${2:-}"
shift 2
;;
--bundle-name)
BUNDLE_NAME="${2:-}"
shift 2
;;
--skip-prebuilt-build)
SKIP_PREBUILT_BUILD=1
shift
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}"
GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT" "$FULL_GIT_HASH")}"
OUTPUT_PATH="${OUTPUT_PATH:-$REPO_ROOT/remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip}"
WORK_DIR="$(mktemp -d)"
BUNDLE_DIR="$WORK_DIR/$BUNDLE_NAME"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
if [ "$SKIP_PREBUILT_BUILD" -eq 0 ]; then
echo "[package_release_artifact] Building frontend prebuilt bundle..." >&2
(
cd "$REPO_ROOT/frontend"
npm run packaged-build
)
fi
[ -d "$REPO_ROOT/frontend/prebuilt" ] || release_die "frontend/prebuilt is missing; run with frontend built or omit --skip-prebuilt-build"
mkdir -p "$BUNDLE_DIR/frontend"
git -C "$REPO_ROOT" archive "$FULL_GIT_HASH" | tar -x -C "$BUNDLE_DIR"
cp -R "$REPO_ROOT/frontend/prebuilt" "$BUNDLE_DIR/frontend/prebuilt"
cat > "$BUNDLE_DIR/build_info.json" <<EOF
{
"version": "$VERSION",
"commit_hash": "$GIT_HASH",
"build_source": "prebuilt-release"
}
EOF
rm -f "$OUTPUT_PATH"
(
cd "$WORK_DIR"
zip -qr "$OUTPUT_PATH" "$BUNDLE_NAME"
)
echo "$OUTPUT_PATH"
+155 -93
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -e
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@@ -10,63 +10,95 @@ NC='\033[0m' # No Color
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT" cd "$REPO_ROOT"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" RELEASE_WORK_DIR=""
# shellcheck source=scripts/build/release_common.sh RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
source "$SCRIPT_DIR/release_common.sh" RELEASE_ASSET=""
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
DOCKER_IMAGE="docker.io/jkingsman/remoteterm-meshcore"
DOCKER_PLATFORMS="linux/amd64,linux/arm64" DOCKER_PLATFORMS="linux/amd64,linux/arm64"
VERSION=""
NOTES_FILE=""
SKIP_QUALITY=0
RELEASE_ASSET_PATH=""
usage() { cleanup_release_build_artifacts() {
cat <<'EOF' if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then
Usage: scripts/build/publish.sh [options] rm -rf "$REPO_ROOT/frontend/prebuilt"
fi
Options: if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
--version VERSION Release version; prompts if omitted rm -rf "$RELEASE_WORK_DIR"
--notes-file PATH File containing changelog entry lines; prompts if omitted fi
--skip-quality Skip ./scripts/quality/all_quality.sh if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then
--help Show this message rm -f "$REPO_ROOT/$RELEASE_ASSET"
EOF fi
} }
while [ $# -gt 0 ]; do trap cleanup_release_build_artifacts EXIT
case "$1" in
--version) ensure_buildx_builder() {
VERSION="${2:-}" if ! docker buildx version >/dev/null 2>&1; then
shift 2 echo -e "${RED}Error: docker buildx is required for multi-arch Docker builds.${NC}"
;; exit 1
--notes-file) fi
NOTES_FILE="${2:-}"
shift 2 local current_builder
;; current_builder="$(docker buildx inspect --format '{{ .Name }}' 2>/dev/null || true)"
--skip-quality)
SKIP_QUALITY=1 if [ -n "$current_builder" ]; then
shift docker buildx inspect --bootstrap >/dev/null
;; return
--help) fi
usage
exit 0 if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then
;; docker buildx use remoteterm-multiarch >/dev/null
*) else
usage >&2 docker buildx create --name remoteterm-multiarch --use >/dev/null
release_die "Unknown argument: $1" fi
;; docker buildx inspect --bootstrap >/dev/null
esac }
done
echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}" echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}"
echo echo
if [ "$SKIP_QUALITY" -eq 0 ]; then # Run backend linting and type checking
echo -e "${YELLOW}Running repo quality gate...${NC}" echo -e "${YELLOW}Running backend lint (Ruff)...${NC}"
./scripts/quality/all_quality.sh uv run ruff check app/ tests/ --fix
echo -e "${GREEN}Quality gate passed!${NC}" uv run ruff format app/ tests/
echo # validate
fi uv run ruff check app/ tests/
uv run ruff format --check app/ tests/
echo -e "${GREEN}Backend lint passed!${NC}"
echo
echo -e "${YELLOW}Running backend type check (Pyright)...${NC}"
uv run pyright app/
echo -e "${GREEN}Backend type check passed!${NC}"
echo
# Run backend tests
echo -e "${YELLOW}Running backend tests...${NC}"
PYTHONPATH=. uv run pytest tests/ -v
echo -e "${GREEN}Backend tests passed!${NC}"
echo
# Run frontend linting and formatting check
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
cd "$REPO_ROOT/frontend"
npm run lint
echo -e "${GREEN}Frontend lint passed!${NC}"
echo
echo -e "${YELLOW}Checking frontend formatting (Prettier)...${NC}"
npm run format:check
echo -e "${GREEN}Frontend formatting OK!${NC}"
echo
# Run frontend tests and build
echo -e "${YELLOW}Running frontend tests...${NC}"
npm run test:run
echo -e "${GREEN}Frontend tests passed!${NC}"
echo
echo -e "${YELLOW}Building frontend...${NC}"
npm run build
echo -e "${GREEN}Frontend build complete!${NC}"
cd "$REPO_ROOT"
echo
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}" echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
bash scripts/build/collect_licenses.sh LICENSES.md bash scripts/build/collect_licenses.sh LICENSES.md
@@ -81,11 +113,13 @@ echo -n " package.json: "
grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/' grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
echo echo
if [ -z "$VERSION" ]; then read -r -p "Enter new version (e.g., 1.2.3): " VERSION
read -r -p "Enter new version (e.g., 1.2.3): " VERSION VERSION="$(printf '%s' "$VERSION" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Error: Version must be in format X.Y.Z${NC}"
exit 1
fi fi
VERSION="$(release_trim "$VERSION")"
release_validate_version "$VERSION"
# Update pyproject.toml # Update pyproject.toml
echo -e "${YELLOW}Updating pyproject.toml...${NC}" echo -e "${YELLOW}Updating pyproject.toml...${NC}"
@@ -103,28 +137,11 @@ echo -e "${GREEN}Version updated to $VERSION${NC}"
echo echo
# Prompt for changelog entry # Prompt for changelog entry
RAW_CHANGELOG_INPUT_FILE="$(mktemp)" echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}"
FORMATTED_CHANGELOG_INPUT_FILE="$(mktemp)" echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}"
cleanup() { echo
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
if [ -n "$NOTES_FILE" ]; then CHANGELOG_ENTRY=$(cat)
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 # Create changelog entry with date
DATE=$(date +%Y-%m-%d) DATE=$(date +%Y-%m-%d)
@@ -140,7 +157,7 @@ if [ -f CHANGELOG.md ]; then
echo echo
echo "$CHANGELOG_HEADER" echo "$CHANGELOG_HEADER"
echo echo
cat "$FORMATTED_CHANGELOG_INPUT_FILE" echo "$CHANGELOG_ENTRY"
echo echo
tail -n +2 CHANGELOG.md tail -n +2 CHANGELOG.md
} > CHANGELOG.md.tmp } > CHANGELOG.md.tmp
@@ -150,7 +167,7 @@ if [ -f CHANGELOG.md ]; then
{ {
echo "$CHANGELOG_HEADER" echo "$CHANGELOG_HEADER"
echo echo
cat "$FORMATTED_CHANGELOG_INPUT_FILE" echo "$CHANGELOG_ENTRY"
echo echo
cat CHANGELOG.md cat CHANGELOG.md
} > CHANGELOG.md.tmp } > CHANGELOG.md.tmp
@@ -163,7 +180,7 @@ else
echo echo
echo "$CHANGELOG_HEADER" echo "$CHANGELOG_HEADER"
echo echo
cat "$FORMATTED_CHANGELOG_INPUT_FILE" echo "$CHANGELOG_ENTRY"
} > CHANGELOG.md } > CHANGELOG.md
fi fi
@@ -183,33 +200,78 @@ echo
GIT_HASH=$(git rev-parse --short HEAD) GIT_HASH=$(git rev-parse --short HEAD)
FULL_GIT_HASH=$(git rev-parse HEAD) FULL_GIT_HASH=$(git rev-parse HEAD)
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip" 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}" echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
scripts/build/package_release_artifact.sh \ cd "$REPO_ROOT/frontend"
--version "$VERSION" \ npm run packaged-build
--git-hash "$GIT_HASH" \ cd "$REPO_ROOT"
--full-git-hash "$FULL_GIT_HASH" \
--output "$RELEASE_ASSET_PATH" RELEASE_WORK_DIR=$(mktemp -d)
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
mkdir -p "$RELEASE_BUNDLE_DIR"
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
{
"version": "$VERSION",
"commit_hash": "$GIT_HASH",
"build_source": "prebuilt-release"
}
EOF
rm -f "$REPO_ROOT/$RELEASE_ASSET"
(
cd "$RELEASE_WORK_DIR"
zip -qr "$REPO_ROOT/$RELEASE_ASSET" "$(basename "$RELEASE_BUNDLE_DIR")"
)
echo -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}" echo -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}"
echo echo
# Build and push multi-arch docker image # Build and push multi-arch docker image
echo -e "${YELLOW}Building and pushing multi-arch Docker image...${NC}" echo -e "${YELLOW}Building and pushing multi-arch Docker image...${NC}"
scripts/build/push_docker_multiarch.sh \ ensure_buildx_builder
--version "$VERSION" \ docker buildx build \
--git-hash "$GIT_HASH" \ --platform "$DOCKER_PLATFORMS" \
--image "$DOCKER_IMAGE" \ --build-arg COMMIT_HASH="$GIT_HASH" \
--platforms "$DOCKER_PLATFORMS" -t "$DOCKER_IMAGE:latest" \
-t "$DOCKER_IMAGE:$VERSION" \
-t "$DOCKER_IMAGE:$GIT_HASH" \
--push \
.
echo -e "${GREEN}Multi-arch Docker build + push complete!${NC}" echo -e "${GREEN}Multi-arch Docker build + push complete!${NC}"
echo echo
# Create GitHub release using the changelog notes for this version. # Create GitHub release using the changelog notes for this version.
echo -e "${YELLOW}Creating GitHub release...${NC}" echo -e "${YELLOW}Creating GitHub release...${NC}"
scripts/build/create_github_release.sh \ RELEASE_NOTES_FILE=$(mktemp)
--version "$VERSION" \ {
--full-git-hash "$FULL_GIT_HASH" \ echo "$CHANGELOG_HEADER"
--asset "$RELEASE_ASSET_PATH" echo
echo "$CHANGELOG_ENTRY"
} > "$RELEASE_NOTES_FILE"
# Create and push the release tag first so GitHub release creation does not
# depend on resolving a symbolic ref like HEAD on the remote side. Use the same
# changelog-derived notes for the annotated tag message.
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo -e "${YELLOW}Tag $VERSION already exists locally; reusing it.${NC}"
else
git tag -a "$VERSION" "$FULL_GIT_HASH" -F "$RELEASE_NOTES_FILE"
fi
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
echo -e "${YELLOW}Tag $VERSION already exists on origin; not pushing it again.${NC}"
else
git push origin "$VERSION"
fi
gh release create "$VERSION" \
"$RELEASE_ASSET" \
--title "$VERSION" \
--notes-file "$RELEASE_NOTES_FILE" \
--verify-tag
rm -f "$RELEASE_NOTES_FILE"
echo -e "${GREEN}GitHub release created!${NC}" echo -e "${GREEN}GitHub release created!${NC}"
echo echo
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/build/release_common.sh
source "$SCRIPT_DIR/release_common.sh"
usage() {
cat <<'EOF'
Usage: scripts/build/push_docker_multiarch.sh --version X.Y.Z [options]
Options:
--version VERSION Release version (required)
--git-hash HASH Short git hash to tag alongside the version
--image IMAGE Docker image name (default: docker.io/jkingsman/remoteterm-meshcore)
--platforms CSV Buildx platforms CSV (default: linux/amd64,linux/arm64)
--help Show this message
EOF
}
VERSION=""
GIT_HASH=""
IMAGE="docker.io/jkingsman/remoteterm-meshcore"
PLATFORMS="linux/amd64,linux/arm64"
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--git-hash)
GIT_HASH="${2:-}"
shift 2
;;
--image)
IMAGE="${2:-}"
shift 2
;;
--platforms)
PLATFORMS="${2:-}"
shift 2
;;
--help)
usage
exit 0
;;
*)
usage >&2
release_die "Unknown argument: $1"
;;
esac
done
[ -n "$VERSION" ] || release_die "--version is required"
release_validate_version "$VERSION"
REPO_ROOT="$(release_repo_root)"
GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT")}"
echo "[push_docker_multiarch] Ensuring docker buildx builder..." >&2
release_ensure_buildx_builder
docker_buildx_args=(
build
--platform "$PLATFORMS"
--build-arg "COMMIT_HASH=$GIT_HASH"
-t "$IMAGE:latest"
-t "$IMAGE:$VERSION"
-t "$IMAGE:$GIT_HASH"
--push
.
)
echo "[push_docker_multiarch] Building and pushing $IMAGE for $PLATFORMS..." >&2
(
cd "$REPO_ROOT"
docker buildx "${docker_buildx_args[@]}"
)
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env bash
release_repo_root() {
(
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
)
}
release_die() {
echo "Error: $*" >&2
exit 1
}
release_trim() {
printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
release_validate_version() {
local version="$1"
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || release_die "Version must be in format X.Y.Z"
}
release_resolve_full_hash() {
local repo_root="$1"
local ref="${2:-HEAD}"
git -C "$repo_root" rev-parse "$ref"
}
release_resolve_short_hash() {
local repo_root="$1"
local ref="${2:-HEAD}"
git -C "$repo_root" rev-parse --short "$ref"
}
release_format_markdown_list() {
local input_file="$1"
local output_file="$2"
awk '
/^[[:space:]]*$/ { next }
{
sub(/^[[:space:]]+/, "", $0)
if ($0 ~ /^\* /) {
print
} else if ($0 ~ /^- /) {
sub(/^- /, "* ", $0)
print
} else {
print "* " $0
}
}
' "$input_file" > "$output_file"
}
release_extract_changelog_section() {
local repo_root="$1"
local version="$2"
local output_file="$3"
local changelog_path="${4:-$repo_root/CHANGELOG.md}"
# Use index() for literal matching so dots in version strings are not
# treated as regex wildcards (e.g. 3.6.5 won't match 31615).
awk -v ver="$version" '
BEGIN { header = "## [" ver "]" }
index($0, header) == 1 { capture = 1; print; next }
capture && /^## \[/ { exit }
capture { print }
' "$changelog_path" > "$output_file"
[ -s "$output_file" ] || release_die "Could not find CHANGELOG entry for version $version"
}
release_ensure_buildx_builder() {
if ! docker buildx version >/dev/null 2>&1; then
release_die "docker buildx is required for multi-arch Docker builds"
fi
# Multi-platform builds require the docker-container driver. The default
# builder uses the "docker" driver which only supports the host platform.
# Check the current builder's driver first; only create a new one if needed.
local current_driver
current_driver="$(docker buildx inspect --format '{{ .Driver }}' 2>/dev/null || true)"
if [ "$current_driver" = "docker-container" ]; then
docker buildx inspect --bootstrap >/dev/null
return
fi
if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then
docker buildx use remoteterm-multiarch >/dev/null
else
docker buildx create --name remoteterm-multiarch --use >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
}
+1 -1
View File
@@ -26,7 +26,7 @@ echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
echo -ne "${BLUE}[backend lint]${NC} " echo -ne "${BLUE}[backend lint]${NC} "
cd "$REPO_ROOT" cd "$REPO_ROOT"
uv run ruff check app/ tests/ --fix --quiet uv run ruff check app/ tests/ --fix --quiet
uv run ruff format app/ tests/ --quiet uv run ruff format app/ tests/ --check --quiet
echo -e "${GREEN}Passed!${NC}" echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[frontend lint]${NC} " echo -ne "${BLUE}[frontend lint]${NC} "
+76 -22
View File
@@ -22,12 +22,15 @@ REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
COMPOSE_FILE="$REPO_DIR/docker-compose.yml" COMPOSE_FILE="$REPO_DIR/docker-compose.yml"
EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml" EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml"
SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs" SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs"
NGINX_CONFIG_DIR="$REPO_DIR/.docker-nginx"
NGINX_CONFIG_BASENAME="remoteterm.conf"
NGINX_CONFIG_HOST_PATH="$NGINX_CONFIG_DIR/$NGINX_CONFIG_BASENAME"
SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt" SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt"
SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key" SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key"
SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME" SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME" SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME"
SNAKEOIL_CERT_CONTAINER_PATH="/app/certs/$SNAKEOIL_CERT_BASENAME" SNAKEOIL_CERT_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_CONTAINER_PATH="/app/certs/$SNAKEOIL_KEY_BASENAME" SNAKEOIL_KEY_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_KEY_BASENAME"
IMAGE_MODE="image" IMAGE_MODE="image"
TRANSPORT_MODE="serial" TRANSPORT_MODE="serial"
@@ -211,6 +214,49 @@ EOF
chmod 644 "$SNAKEOIL_CERT_HOST_PATH" chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
} }
generate_nginx_tls_config() {
mkdir -p "$NGINX_CONFIG_DIR"
cat >"$NGINX_CONFIG_HOST_PATH" <<EOF
server {
listen 80;
server_name _;
return 308 https://\$host:8000\$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate $SNAKEOIL_CERT_CONTAINER_PATH;
ssl_certificate_key $SNAKEOIL_KEY_CONTAINER_PATH;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location /api/ws {
proxy_pass http://remoteterm:8000/api/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
}
location / {
proxy_pass http://remoteterm:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
proxy_read_timeout 300s;
}
}
EOF
}
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}" echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
echo echo
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}" echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
@@ -400,7 +446,9 @@ LOCAL_ACCESS_IP="$(detect_primary_local_ip)"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
ensure_snakeoil_requirements ensure_snakeoil_requirements
generate_snakeoil_certificate "$LOCAL_ACCESS_IP" generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
generate_nginx_tls_config
echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}" echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}"
echo -e "${GREEN}Generated nginx TLS proxy config in ${NGINX_CONFIG_DIR}.${NC}"
echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}" echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}"
else else
echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}" echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}"
@@ -441,32 +489,19 @@ mkdir -p "$REPO_DIR/data"
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
echo " user: \"$(id -u):$(id -g)\"" echo " user: \"$(id -u):$(id -g)\""
fi fi
echo " ports:" if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " - \"8000:8000\"" echo " expose:"
echo " - \"8000\""
else
echo " ports:"
echo " - \"8000:8000\""
fi
echo " volumes:" echo " volumes:"
echo " - ./data:/app/data" echo " - ./data:/app/data"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " - ./.docker-certs:/app/certs:ro"
fi
if [ "$TRANSPORT_MODE" = "serial" ]; then if [ "$TRANSPORT_MODE" = "serial" ]; then
echo " devices:" echo " devices:"
echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}" echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
fi 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 " environment:"
echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")" echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")"
if [ "$TRANSPORT_MODE" = "serial" ]; then if [ "$TRANSPORT_MODE" = "serial" ]; then
@@ -486,6 +521,19 @@ mkdir -p "$REPO_DIR/data"
echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")" echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")"
fi fi
echo " restart: unless-stopped" echo " restart: unless-stopped"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " nginx:"
echo " image: nginx:alpine"
echo " depends_on:"
echo " - remoteterm"
echo " ports:"
echo " - \"80:80\""
echo " - \"8000:443\""
echo " volumes:"
echo " - ./.docker-certs:/etc/nginx/certs:ro"
echo " - ./.docker-nginx/$NGINX_CONFIG_BASENAME:/etc/nginx/conf.d/default.conf:ro"
echo " restart: unless-stopped"
fi
} >"$COMPOSE_FILE" } >"$COMPOSE_FILE"
echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}" echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
@@ -504,6 +552,11 @@ echo " sudo docker compose pull && sudo docker compose up -d # upgrade to the
echo echo
echo -e "${YELLOW}Note:${NC} serial passthrough generally needs ${BOLD}rootful Docker${NC}." 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." echo "If Docker is running rootless on this host, serial-device mappings may fail even with a valid compose file."
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo
echo -e "${GREEN}HTTPS will be handled by an nginx sidecar.${NC}"
echo "Host port 80 will redirect to HTTPS on port 8000."
fi
if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
echo echo
echo -e "${RED}BLE requires more than the generated env vars.${NC}" echo -e "${RED}BLE requires more than the generated env vars.${NC}"
@@ -519,6 +572,7 @@ echo -e "${PURPLE}└───────────────────
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo 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 "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
echo -e "Plain HTTP on ${CYAN}http://${LOCAL_ACCESS_IP}${NC} will redirect there automatically."
echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}" echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
else else
echo echo
+1 -1
View File
@@ -19,7 +19,7 @@ test.describe('Create contact flow', () => {
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open new message modal // Open new message modal
await page.getByRole('button', { name: /add channel or contact/i }).click(); await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
+2 -2
View File
@@ -26,7 +26,7 @@ test.describe('Create hashtag channel flow', () => {
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open new message modal // Open new message modal
await page.getByRole('button', { name: /add channel or contact/i }).click(); await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
@@ -49,7 +49,7 @@ test.describe('Create hashtag channel flow', () => {
await page.goto('/'); await page.goto('/');
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
await page.getByRole('button', { name: /add channel or contact/i }).click(); await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
@@ -37,7 +37,7 @@ test.describe('Historical packet decryption', () => {
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open new message modal → Hashtag tab // Open new message modal → Hashtag tab
await page.getByRole('button', { name: /add channel or contact/i }).click(); await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: /Hashtag/i }).click(); await dialog.getByRole('tab', { name: /Hashtag/i }).click();
+5 -150
View File
@@ -131,62 +131,6 @@ class TestHealthEndpoint:
class TestDebugEndpoint: class TestDebugEndpoint:
"""Test the debug support snapshot endpoint.""" """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 @pytest.mark.asyncio
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client): 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.""" """Debug snapshot should still return logs and runtime state when radio is disconnected."""
@@ -213,21 +157,8 @@ class TestDebugEndpoint:
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() 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"]["performed"] is False
assert payload["radio_probe"]["errors"] == ["Radio not connected"] 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["runtime"]["channels_with_incoming_messages"] == 0
assert payload["database"]["total_dms"] == 0 assert payload["database"]["total_dms"] == 0
assert payload["database"]["total_channel_messages"] == 0 assert payload["database"]["total_channel_messages"] == 0
@@ -282,47 +213,6 @@ class TestDebugEndpoint:
assert response.status_code == 200 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: class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503.""" """Test that RadioDisconnectedError maps to 503."""
@@ -1167,14 +1057,7 @@ class TestRawPacketRepository:
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp) await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
# Insert old but decrypted packet (should NOT be deleted) # Insert old but decrypted packet (should NOT be deleted)
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp) old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
msg_id = await MessageRepository.create( await RawPacketRepository.mark_decrypted(old_id, 1)
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 # Prune packets older than 10 days
deleted = await RawPacketRepository.prune_old_undecrypted(10) deleted = await RawPacketRepository.prune_old_undecrypted(10)
@@ -1198,24 +1081,10 @@ class TestRawPacketRepository:
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db): 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.""" """Purge linked raw packets removes only rows with a message_id."""
ts = int(time.time()) 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_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts) linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1) await RawPacketRepository.mark_decrypted(linked_1, 101)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2) await RawPacketRepository.mark_decrypted(linked_2, 102)
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
@@ -1253,24 +1122,10 @@ class TestMaintenanceEndpoint:
from app.routers.packets import MaintenanceRequest, run_maintenance from app.routers.packets import MaintenanceRequest, run_maintenance
ts = int(time.time()) 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_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts) linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1) await RawPacketRepository.mark_decrypted(linked_1, 201)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2) await RawPacketRepository.mark_decrypted(linked_2, 202)
request = MaintenanceRequest(purge_linked_raw_packets=True) request = MaintenanceRequest(purge_linked_raw_packets=True)
result = await run_maintenance(request) result = await run_maintenance(request)
+1 -51
View File
@@ -1,8 +1,7 @@
"""Tests for the channels router endpoints.""" """Tests for the channels router endpoints."""
import time import time
from hashlib import sha256 from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -78,55 +77,6 @@ class TestCreateChannel:
assert channel is not None assert channel is not None
assert channel.flood_scope_override is 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: class TestPublicChannelProtection:
@pytest.mark.asyncio @pytest.mark.asyncio
+17 -19
View File
@@ -513,9 +513,7 @@ class TestMigration018:
from hashlib import sha256 from hashlib import sha256
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest() assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
# message_id=42 was orphaned (no matching messages row), so assert rows[1]["message_id"] == 42
# migration 49's orphan cleanup NULLs it out.
assert rows[1]["message_id"] is None
# Verify payload_hash unique index still works # Verify payload_hash unique index still works
cursor = await conn.execute( cursor = await conn.execute(
@@ -1249,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 12 assert applied == 9
assert await get_version(conn) == 50 assert await get_version(conn) == 47
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1321,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 12 assert applied == 9
assert await get_version(conn) == 50 assert await get_version(conn) == 47
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1388,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 6 assert applied == 3
assert await get_version(conn) == 50 assert await get_version(conn) == 47
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1441,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 11 assert applied == 8
assert await get_version(conn) == 50 assert await get_version(conn) == 47
await conn.execute( await conn.execute(
""" """
@@ -1503,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 10 assert applied == 7
assert await get_version(conn) == 50 assert await get_version(conn) == 47
await conn.execute( await conn.execute(
""" """
@@ -1556,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 9 assert applied == 6
assert await get_version(conn) == 50 assert await get_version(conn) == 47
await conn.execute( await conn.execute(
""" """
@@ -1696,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 5 assert applied == 2
assert await get_version(conn) == 50 assert await get_version(conn) == 47
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1790,8 +1788,8 @@ class TestMigration047:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 4 assert applied == 1
assert await get_version(conn) == 50 assert await get_version(conn) == 47
cursor = await conn.execute( cursor = await conn.execute(
""" """
-15
View File
@@ -381,11 +381,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=[], return_value=[],
), ),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"), patch("app.routers.radio.broadcast_event"),
): ):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters")) response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -459,11 +454,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=[], return_value=[],
) as mock_promote, ) 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, patch("app.routers.radio.broadcast_event") as mock_broadcast,
): ):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters")) response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -789,11 +779,6 @@ class TestTracePath:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=[], return_value=[],
), ),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"), patch("app.routers.radio.broadcast_event"),
): ):
response = await discover_mesh(RadioDiscoveryRequest(target="all")) response = await discover_mesh(RadioDiscoveryRequest(target="all"))
-5
View File
@@ -884,11 +884,6 @@ class TestSyncAndOffloadContacts:
return task return task
with ( 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.reconcile_contact_messages", side_effect=_slow_reconcile),
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task), patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
): ):
-189
View File
@@ -1,189 +0,0 @@
"""Tests for repeater telemetry history: repository CRUD and read-only endpoint."""
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
-1
View File
@@ -630,7 +630,6 @@ class TestAppSettingsRepository:
"flood_scope": "", "flood_scope": "",
"blocked_keys": "[]", "blocked_keys": "[]",
"blocked_names": "[]", "blocked_names": "[]",
"discovery_blocked_types": "[]",
} }
) )
mock_conn.execute = AsyncMock(return_value=mock_cursor) mock_conn.execute = AsyncMock(return_value=mock_cursor)
Generated
+1 -1
View File
@@ -1098,7 +1098,7 @@ wheels = [
[[package]] [[package]]
name = "remoteterm-meshcore" name = "remoteterm-meshcore"
version = "3.6.7" version = "3.6.3"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiomqtt" }, { name = "aiomqtt" },