Compare commits
410 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24332a4e23 | |||
| ebc5754684 | |||
| 340b300aa4 | |||
| bf7026cc9f | |||
| 1b04b52509 | |||
| fca334f472 | |||
| d81d644319 | |||
| 400cf1114f | |||
| fec38adc78 | |||
| 993a7d2626 | |||
| dbe09411ac | |||
| 0afc47fcdd | |||
| 4862b285a8 | |||
| 41dd1555d7 | |||
| 0cf3a25ac6 | |||
| 3674b6e2d6 | |||
| 4c9bcb00c3 | |||
| 2067d0bf84 | |||
| c0fa59d10e | |||
| 37add84d59 | |||
| c23019b8c0 | |||
| b4edd35f5f | |||
| 812f85b9a9 | |||
| 77888b7d88 | |||
| 4a38d7512d | |||
| 5d0df18dac | |||
| d18e38800e | |||
| 76e595aaec | |||
| dfb9897fa1 | |||
| 82ad784fcb | |||
| 4bd7077d64 | |||
| 3f6b9cc5ef | |||
| 0742647571 | |||
| 33090419df | |||
| 4042d0e5f1 | |||
| d3a0b41fba | |||
| 2fefea5618 | |||
| d75f7c794f | |||
| 503b91ea87 | |||
| 43db7c309d | |||
| 6e57927409 | |||
| a404f5ded9 | |||
| f6a6aab623 | |||
| 2cfbc0addc | |||
| 07d6ef984e | |||
| 50227ccae6 | |||
| 8f3c636c61 | |||
| 42761bbdbc | |||
| 0f2eba302c | |||
| 83dd58721f | |||
| d658d0b81e | |||
| e04113628a | |||
| b1e92326b6 | |||
| 9ac63bd75f | |||
| f795180c7d | |||
| d1f1ce1f4b | |||
| 334073089f | |||
| df634dc741 | |||
| a76dfde02d | |||
| 36f8349bc7 | |||
| 130a3a2d8e | |||
| bd6fa27970 | |||
| 630bc2971a | |||
| 7182f7803a | |||
| a64a7c414c | |||
| f0cc396a6b | |||
| 5f588a5513 | |||
| 599df7734b | |||
| 49fa02142d | |||
| 333dc00ee2 | |||
| 2bc71e44ad | |||
| 92265da5fb | |||
| 9c1516c086 | |||
| cd7940bdc2 | |||
| 4a5f3e1802 | |||
| 1b5bf4c061 | |||
| 384d02649a | |||
| d51da40a67 | |||
| 3a6bd3711e | |||
| d28d371caf | |||
| 05d96b6077 | |||
| f6197592bb | |||
| aca7f56808 | |||
| 872cc806eb | |||
| 7b847e0541 | |||
| 17b46a13c2 | |||
| ede3a5841b | |||
| 7270f827a9 | |||
| 468812bc09 | |||
| 7bef63aede | |||
| 21dec0d53a | |||
| 52997b3c78 | |||
| 765e1384b5 | |||
| e18f85370f | |||
| a0604a43c0 | |||
| 9cb44c6273 | |||
| eacf6d4970 | |||
| 07ae227cee | |||
| 18ef6218d8 | |||
| 0c7ac816e9 | |||
| 8e204725b2 | |||
| 40acca20b2 | |||
| ae804f92b2 | |||
| 0a6effccae | |||
| 0cf73b1234 | |||
| 8d354755f0 | |||
| 166f598386 | |||
| 6e51739654 | |||
| ec22823e59 | |||
| 87cd10194f | |||
| 933575b480 | |||
| a4218c0c33 | |||
| c67fa39e30 | |||
| 9f7dc8f995 | |||
| d1dd1ad4da | |||
| c7fdea856d | |||
| a7307dbf3a | |||
| 55ff644a8a | |||
| 3d90e03ca9 | |||
| 069e87f9ba | |||
| f3c5d124b5 | |||
| d821e19334 | |||
| d15b4efc97 | |||
| a3ad49a441 | |||
| fb95e465a3 | |||
| ab0a03b313 | |||
| f396ff7b66 | |||
| 52cb47e5c9 | |||
| 003b44c62e | |||
| 92caef5cb7 | |||
| db304631f8 | |||
| eae1820fda | |||
| f70deb32a2 | |||
| 69eea1e895 | |||
| bf4346b4ff | |||
| 7cde6a2068 | |||
| 84b424b02e | |||
| 04b73596ea | |||
| 3916276de8 | |||
| 077d46f319 | |||
| a0fd6d9651 | |||
| 8d505eb848 | |||
| 3f364f47e9 | |||
| b92139f207 | |||
| c7e9a0a493 | |||
| 717dec4e54 | |||
| d3cb20cdae | |||
| 518da075de | |||
| fb31157fe9 | |||
| a5f574062d | |||
| afccb6fe0a | |||
| f916b9fa19 | |||
| d775ba5b3e | |||
| 3372daca84 | |||
| b72ddd7c19 | |||
| f980e2e76d | |||
| ada6d5f1f1 | |||
| 7c6416ac38 | |||
| e833488425 | |||
| 0b8863aaa9 | |||
| 8d30c40fe2 | |||
| d2f2c37531 | |||
| b23a1636b0 | |||
| a73a74d1fc | |||
| d297f87115 | |||
| 88537c1119 | |||
| 141b34391d | |||
| 8b4b440b22 | |||
| 0cccf3c9dd | |||
| e532f67c85 | |||
| 7a2b90055a | |||
| ab2d7bfe50 | |||
| 1e2810b85c | |||
| 164887f8a4 | |||
| b4d3e65a3d | |||
| 3b238c3c8f | |||
| 93111b93c5 | |||
| 6a63c13cd8 | |||
| 3518f7fede | |||
| 79fc2871c9 | |||
| 2d21ce9303 | |||
| 28e63a1029 | |||
| cbfe46201e | |||
| 1b0d39c5b0 | |||
| 446a8f14cb | |||
| 57d448c003 | |||
| eabc73ff49 | |||
| f724421ce7 | |||
| 9134195eb1 | |||
| ee6971284c | |||
| 098fab6aca | |||
| bc2b2bf23b | |||
| eb5bf55aad | |||
| 17a0dddf61 | |||
| f6bd38e3dc | |||
| 12db4f5178 | |||
| f01502ff32 | |||
| 54a47b03c2 | |||
| 537171d788 | |||
| f665203543 | |||
| dfd4b0e89e | |||
| 45c10a8593 | |||
| d929c30882 | |||
| 0ca3066cfc | |||
| 1d30ea2708 | |||
| 6ae21e9e24 | |||
| 5843b3dcc5 | |||
| 1cd367332b | |||
| 9515f5fd7a | |||
| e22f464300 | |||
| 3d0c505178 | |||
| a1f8377dd4 | |||
| 588556c2a6 | |||
| af078aaae0 | |||
| 9dccbb95e8 | |||
| 226f08f62d | |||
| 85159cbc44 | |||
| 201fce0125 | |||
| 3b8d4f3f74 | |||
| 852d109468 | |||
| c5eb63ae7f | |||
| b0ab361ead | |||
| 7b2e1caa47 | |||
| 7957176e59 | |||
| bd7c83b18c | |||
| 27a0e095a3 | |||
| e19315819d | |||
| 002afe3690 | |||
| 9e31bc65db | |||
| 898410b225 | |||
| fe28a91d5c | |||
| be58c00bc7 | |||
| 91b07fe797 | |||
| bac7f8d55c | |||
| bb660d02f5 | |||
| e3d9349d4b | |||
| 78642bcbb2 | |||
| 48e3bf210a | |||
| e9d5fe35fb | |||
| 66f16d4a2d | |||
| 187347e64b | |||
| 5016327bc2 | |||
| ed460761ff | |||
| c49b1e03f2 | |||
| 28d15d0ed5 | |||
| 54db023520 | |||
| 713c1a3470 | |||
| 5bafb88377 | |||
| 95f3836edd | |||
| 0195553a62 | |||
| 5c7554d6cb | |||
| ec32b9237e | |||
| 3edd40de0d | |||
| 88418b0850 | |||
| 1e59cfd2ea | |||
| 42f2a6ef62 | |||
| 3e3bc0e857 | |||
| 290c5ff896 | |||
| 4c0d44a99d | |||
| ef4adfe003 | |||
| 30dfea57b9 | |||
| a0d7f221c0 | |||
| ee916d0022 | |||
| 156d832d2d | |||
| abe3d42004 | |||
| 3f38742dbe | |||
| 2cb62d5f34 | |||
| 256c30e7cd | |||
| c92f60e0f3 | |||
| 9461cc2121 | |||
| 8a744eb55a | |||
| 73188c2471 | |||
| 6e8de37135 | |||
| bb010664ca | |||
| ffc55efe1c | |||
| 8b42f4ac28 | |||
| 4c71a3bb92 | |||
| d88d5c4921 | |||
| 5c62ae316a | |||
| ed58681800 | |||
| 90d2d42478 | |||
| c88cf831fc | |||
| f6aed7deda | |||
| ce204ce413 | |||
| 1ef3e367eb | |||
| 7cd988b777 | |||
| aac88cdd29 | |||
| 664ae5b5ce | |||
| d268e581bd | |||
| ecc8dad2e2 | |||
| df025f0409 | |||
| 5e4412879d | |||
| ce232e0512 | |||
| 5d54449b21 | |||
| 04f003c9f0 | |||
| 9b55632c86 | |||
| bd65679572 | |||
| f93877d723 | |||
| 2b8b499e79 | |||
| 69410fd7c2 | |||
| 176014b706 | |||
| 92984a7bae | |||
| a5d433b516 | |||
| e30094e8fc | |||
| f1b416bba5 | |||
| ec0b8dbcf7 | |||
| 5bfa7bf651 | |||
| e204901d18 | |||
| 482d778bca | |||
| c4ad8f6c12 | |||
| aa763b0f81 | |||
| 58a825976d | |||
| e4e9e89451 | |||
| 2f2e56ff2e | |||
| 2b29b5c86f | |||
| af1cb7c17b | |||
| c5aa382527 | |||
| 78f81eeccd | |||
| 096763ad40 | |||
| 6354911c54 | |||
| a8bb56a109 | |||
| 5047fee431 | |||
| b63c7ab0fe | |||
| c0c86ef601 | |||
| 69c765d44a | |||
| 617ba859fb | |||
| 62db171ed6 | |||
| 66b2f59ca0 | |||
| 6dbf2fda01 | |||
| 234f254f4f | |||
| 3210fc0d20 | |||
| ac68e26c70 | |||
| ce0f581938 | |||
| fc48ff7d9f | |||
| af39d40847 | |||
| fb23766ed3 | |||
| bcb3147d1e | |||
| 940a43747b | |||
| 16c74d10db | |||
| a99c3e3894 | |||
| e621647768 | |||
| 5992156356 | |||
| bed0c5fb8d | |||
| 0362a1b4ea | |||
| cf7c94f9d8 | |||
| c044ecfba2 | |||
| 23a79a7ac5 | |||
| 795dd3f235 | |||
| 35d138175e | |||
| 4c1690dd28 | |||
| 407d5c1d25 | |||
| f46681fdbc | |||
| 95e0309c63 | |||
| 819944cccf | |||
| c595450310 | |||
| 4af61c8cb9 | |||
| 9f391527c2 | |||
| cd168da760 | |||
| f4282cb608 | |||
| 073134d6d3 | |||
| 4baefa61ac | |||
| 0d6d81fb69 | |||
| c96a3ade6b | |||
| 81c9dd84b2 | |||
| fe67461f88 | |||
| aae60e2037 | |||
| 97d5ec6b33 | |||
| 459bf2d8cd | |||
| 43f0f1cbfc | |||
| a3fd6881df | |||
| b27a532bce | |||
| 52f85669f8 | |||
| a891160f98 | |||
| 130bc8a51c | |||
| 4224418e6f | |||
| 4018f95723 | |||
| e6c7a3eae4 | |||
| 2e27efdfbf | |||
| 6efa10643e | |||
| 71e5803695 | |||
| 1107f0e534 | |||
| 0b22d0aa1f | |||
| 353cd16021 | |||
| ac6d1b570d | |||
| 319ea2d01d | |||
| 6fc64937fb | |||
| 323f24a470 | |||
| d98bcc15b8 | |||
| fdd91485fc | |||
| d510ba30f6 | |||
| 4bb0c9b9a3 | |||
| b3e67e5ef6 | |||
| dec890104b | |||
| 5d8c435c5a | |||
| 3cf371242a | |||
| aab7b508cc | |||
| 36def8f96a | |||
| 3c0a654f93 | |||
| 77b4bc9ad4 | |||
| 9f39f1cc2f | |||
| f326be77cd | |||
| 7eba7dbaaa | |||
| dc4434db84 | |||
| cf91c2484f | |||
| f51b193876 | |||
| 0846d1f360 | |||
| dd56617c4c | |||
| 03ce847196 | |||
| 1a7a33041c | |||
| 6da8b11301 |
@@ -15,6 +15,7 @@ venv/
|
|||||||
.eggs/
|
.eggs/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.egg
|
*.egg
|
||||||
|
.uv
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -32,6 +33,9 @@ htmlcov/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Local Postgres data
|
||||||
|
pgdata/
|
||||||
|
|
||||||
# Captured files (don't include in image)
|
# Captured files (don't include in image)
|
||||||
*.cap
|
*.cap
|
||||||
*.pcap
|
*.pcap
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
buy_me_a_coffee: smittix
|
||||||
@@ -14,6 +14,14 @@ uv.lock
|
|||||||
*.log
|
*.log
|
||||||
pager_messages.log
|
pager_messages.log
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
downloads/
|
||||||
|
pgdata/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
downloads/
|
||||||
|
pgdata/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -34,3 +42,15 @@ build/
|
|||||||
uv.lock
|
uv.lock
|
||||||
*.db
|
*.db
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
intercept.db
|
||||||
|
|
||||||
|
# Instance folder (contains database with user data)
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# Agent configs with real credentials (keep template only)
|
||||||
|
intercept_agent_*.cfg
|
||||||
|
!intercept_agent.cfg
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
/tmp/
|
||||||
|
*.tmp
|
||||||
|
|||||||
@@ -2,6 +2,136 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.12.0] - 2026-01-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **ISS SSTV Decoder Mode** - Receive Slow Scan Television transmissions from the ISS
|
||||||
|
- Real-time ISS tracking globe with accurate position via N2YO API
|
||||||
|
- Leaflet world map showing ISS ground track and current position
|
||||||
|
- Location settings for ISS pass predictions
|
||||||
|
- Integration with satellite tracking TLE data
|
||||||
|
- **GitHub Update Notifications** - Automatic new version alerts
|
||||||
|
- Checks for updates on app startup
|
||||||
|
- Unobtrusive notification when new releases are available
|
||||||
|
- Configurable check interval via settings
|
||||||
|
- **Meshtastic Enhancements**
|
||||||
|
- QR code support for easy device sharing
|
||||||
|
- Telemetry display with battery, voltage, and environmental data
|
||||||
|
- Traceroute visualization for mesh network topology
|
||||||
|
- Improved node synchronization between map and top bar
|
||||||
|
- **UI Improvements**
|
||||||
|
- New Space category for satellite and ISS-related modes
|
||||||
|
- Pulsating ring effect for tracked aircraft/vessels
|
||||||
|
- Map marker highlighting for selected aircraft in ADS-B
|
||||||
|
- Consolidated settings and dependencies into single modal
|
||||||
|
- **Auto-Update TLE Data** - Satellite tracking data updates automatically on app startup
|
||||||
|
- **GPS Auto-Connect** - AIS dashboard now connects to gpsd automatically
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Utility Meters** - Added device grouping by ID with consumption trends
|
||||||
|
- **Utility Meters** - Device intelligence and manufacturer information display
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SoapySDR** - Module detection on macOS with Homebrew
|
||||||
|
- **dump1090** - Build failures in Docker containers
|
||||||
|
- **dump1090** - Build failures on Kali Linux and newer GCC versions
|
||||||
|
- **Flask** - Ensure Flask 3.0+ compatibility in setup script
|
||||||
|
- **psycopg2** - Now optional for Flask/Werkzeug compatibility
|
||||||
|
- **Bias-T** - Setting now properly passed to ADS-B and AIS dashboards
|
||||||
|
- **Dark Mode Maps** - Removed CSS filter that was inverting dark tiles
|
||||||
|
- **Map Tiles** - Fixed CARTO tile URLs and added cache-busting
|
||||||
|
- **Meshtastic** - Traceroute button and dark mode map fixes
|
||||||
|
- **ADS-B Dashboard** - Height adjustment to prevent bottom controls cutoff
|
||||||
|
- **Audio Visualizer** - Now works without spectrum canvas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.11.0] - 2026-01-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Meshtastic Mesh Network Integration** - LoRa mesh communication support
|
||||||
|
- Connect to Meshtastic devices (Heltec, T-Beam, RAK) via USB/Serial
|
||||||
|
- Real-time message streaming via SSE
|
||||||
|
- Channel configuration with encryption key support
|
||||||
|
- Node information display with signal metrics (RSSI, SNR)
|
||||||
|
- Message history with up to 500 messages
|
||||||
|
- **Ubertooth One BLE Scanner** - Advanced Bluetooth scanning
|
||||||
|
- Passive BLE packet capture across all 40 BLE channels
|
||||||
|
- Raw advertising payload access
|
||||||
|
- Integration with existing Bluetooth scanning modes
|
||||||
|
- Automatic detection of Ubertooth hardware
|
||||||
|
- **Offline Mode** - Run iNTERCEPT without internet connectivity
|
||||||
|
- Bundled Leaflet 1.9.4 (JS, CSS, marker images)
|
||||||
|
- Bundled Chart.js 4.4.1
|
||||||
|
- Bundled Inter and JetBrains Mono fonts (woff2)
|
||||||
|
- Local asset status checking and validation
|
||||||
|
- **Settings Modal** - New configuration interface accessible from navigation
|
||||||
|
- Offline tab: Toggle offline mode, configure asset sources
|
||||||
|
- Display tab: Theme and animation preferences
|
||||||
|
- About tab: Version info and links
|
||||||
|
- **Multiple Map Tile Providers** - Choose from:
|
||||||
|
- OpenStreetMap (default)
|
||||||
|
- CartoDB Dark
|
||||||
|
- CartoDB Positron (light)
|
||||||
|
- ESRI World Imagery
|
||||||
|
- Custom tile server URL
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard Templates** - Conditional asset loading based on offline settings
|
||||||
|
- **Bluetooth Scanner** - Added Ubertooth backend alongside BlueZ/DBus
|
||||||
|
- **Dependencies** - Added meshtastic SDK to requirements.txt
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Added `routes/meshtastic.py` for Meshtastic API endpoints
|
||||||
|
- Added `utils/meshtastic.py` for device management
|
||||||
|
- Added `utils/bluetooth/ubertooth_scanner.py` for Ubertooth support
|
||||||
|
- Added `routes/offline.py` for offline mode API
|
||||||
|
- Added `static/js/core/settings-manager.js` for client-side settings
|
||||||
|
- Added `static/css/settings.css` for settings modal styles
|
||||||
|
- Added `static/css/modes/meshtastic.css` for Meshtastic UI
|
||||||
|
- Added `static/js/modes/meshtastic.js` for Meshtastic frontend
|
||||||
|
- Added `templates/partials/modes/meshtastic.html` for Meshtastic mode
|
||||||
|
- Added `templates/partials/settings-modal.html` for settings UI
|
||||||
|
- Added `static/vendor/` directory structure for bundled assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.10.0] - 2026-01-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
|
||||||
|
- Full-screen dashboard with interactive maritime map
|
||||||
|
- Vessel details: name, MMSI, callsign, destination, ETA
|
||||||
|
- Navigation data: speed, course, heading, rate of turn
|
||||||
|
- Ship type classification and dimensions
|
||||||
|
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
|
||||||
|
- **VHF DSC Channel 70 Monitoring** - Digital Selective Calling for maritime distress
|
||||||
|
- Real-time decoding of DSC messages (Distress, Urgency, Safety, Routine)
|
||||||
|
- MMSI country identification via Maritime Identification Digits (MID) lookup
|
||||||
|
- Position extraction and map markers for distress alerts
|
||||||
|
- Prominent visual overlay for DISTRESS and URGENCY alerts
|
||||||
|
- Permanent database storage for critical alerts with acknowledgement workflow
|
||||||
|
- **Spy Stations Database** - Number stations and diplomatic HF networks
|
||||||
|
- Comprehensive database from priyom.org
|
||||||
|
- Station profiles with frequencies, schedules, operators
|
||||||
|
- Filter by type (number/diplomatic), country, and mode
|
||||||
|
- Tune integration with Listening Post
|
||||||
|
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
|
||||||
|
- **SDR Device Conflict Detection** - Prevents collisions between AIS and DSC
|
||||||
|
- **DSC Alert Summary** - Dashboard counts for unacknowledged distress/urgency alerts
|
||||||
|
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
|
||||||
|
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
|
||||||
|
- **Vessels Dashboard** - Now includes VHF DSC message panel alongside AIS tracking
|
||||||
|
- **Dependencies** - Added scipy and numpy for DSC signal processing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **DSC Position Decoder** - Corrected octal literal in quadrant check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.9.5] - 2026-01-14
|
## [2.9.5] - 2026-01-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Setup and Running
|
||||||
|
```bash
|
||||||
|
# Initial setup (installs dependencies and configures SDR tools)
|
||||||
|
./setup.sh
|
||||||
|
|
||||||
|
# Run the application (requires sudo for SDR/network access)
|
||||||
|
sudo -E venv/bin/python intercept.py
|
||||||
|
|
||||||
|
# Or activate venv first
|
||||||
|
source venv/bin/activate
|
||||||
|
sudo -E python intercept.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_bluetooth.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=routes --cov=utils
|
||||||
|
|
||||||
|
# Run a specific test
|
||||||
|
pytest tests/test_bluetooth.py::test_function_name -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting and Formatting
|
||||||
|
```bash
|
||||||
|
# Lint with ruff
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# Auto-fix linting issues
|
||||||
|
ruff check --fix .
|
||||||
|
|
||||||
|
# Format with black
|
||||||
|
black .
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
- `intercept.py` - Main entry point script
|
||||||
|
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
||||||
|
|
||||||
|
### Route Blueprints (routes/)
|
||||||
|
Each signal type has its own Flask blueprint:
|
||||||
|
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
|
||||||
|
- `sensor.py` - 433MHz IoT sensors via rtl_433
|
||||||
|
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
|
||||||
|
- `acars.py` - Aircraft datalink messages via acarsdec
|
||||||
|
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||||
|
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||||
|
- `satellite.py` - Pass prediction using TLE data
|
||||||
|
- `aprs.py` - Amateur packet radio via direwolf
|
||||||
|
- `rtlamr.py` - Utility meter reading
|
||||||
|
|
||||||
|
### Core Utilities (utils/)
|
||||||
|
|
||||||
|
**SDR Abstraction Layer** (`utils/sdr/`):
|
||||||
|
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
|
||||||
|
- Each type has a `CommandBuilder` for generating CLI commands
|
||||||
|
|
||||||
|
**Bluetooth Module** (`utils/bluetooth/`):
|
||||||
|
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
|
||||||
|
- `aggregator.py` - Merges observations across time
|
||||||
|
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
|
||||||
|
- `heuristics.py` - Behavioral analysis for device classification
|
||||||
|
|
||||||
|
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
|
||||||
|
- `baseline.py` - Snapshot "normal" RF environment
|
||||||
|
- `detector.py` - Compare current scan to baseline, flag anomalies
|
||||||
|
- `device_identity.py` - Track devices despite MAC randomization
|
||||||
|
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
|
||||||
|
|
||||||
|
**WiFi Utilities** (`utils/wifi/`):
|
||||||
|
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||||
|
- `channel_analyzer.py` - Frequency band analysis
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
||||||
|
|
||||||
|
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||||
|
|
||||||
|
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
|
||||||
|
|
||||||
|
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
|
||||||
|
|
||||||
|
### External Tool Integrations
|
||||||
|
|
||||||
|
| Tool | Purpose | Integration |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
|
||||||
|
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
|
||||||
|
| rtl_433 | 433MHz sensors | JSON output parsing |
|
||||||
|
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
|
||||||
|
| acarsdec | ACARS messages | Output parsing |
|
||||||
|
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||||
|
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- `config.py` - Environment variable support with `INTERCEPT_` prefix
|
||||||
|
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
|
||||||
@@ -31,6 +31,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# GPS support
|
# GPS support
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
# Utilities
|
# Utilities
|
||||||
|
# APRS
|
||||||
|
direwolf \
|
||||||
|
# WiFi Extra
|
||||||
|
hcxdumptool \
|
||||||
|
hcxtools \
|
||||||
|
# SDR Hardware & SoapySDR
|
||||||
|
soapysdr-tools \
|
||||||
|
soapysdr-module-rtlsdr \
|
||||||
|
soapysdr-module-hackrf \
|
||||||
|
soapysdr-module-lms7 \
|
||||||
|
limesuite \
|
||||||
|
hackrf \
|
||||||
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -43,14 +56,50 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cmake \
|
cmake \
|
||||||
libncurses-dev \
|
libncurses-dev \
|
||||||
libsndfile1-dev \
|
libsndfile1-dev \
|
||||||
|
libsoapysdr-dev \
|
||||||
|
libhackrf-dev \
|
||||||
|
liblimesuite-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libzmq3-dev \
|
||||||
# Build dump1090
|
# Build dump1090
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
&& cd dump1090 \
|
&& cd dump1090 \
|
||||||
&& make \
|
&& sed -i 's/-Werror//g' Makefile \
|
||||||
|
&& make BLADERF=no RTLSDR=yes \
|
||||||
&& cp dump1090 /usr/bin/dump1090-fa \
|
&& cp dump1090 /usr/bin/dump1090-fa \
|
||||||
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
||||||
&& rm -rf /tmp/dump1090 \
|
&& rm -rf /tmp/dump1090 \
|
||||||
|
# Build AIS-catcher
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
||||||
|
&& cd AIS-catcher \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp AIS-catcher /usr/bin/AIS-catcher \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/AIS-catcher \
|
||||||
|
# Build readsb
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
||||||
|
&& cd readsb \
|
||||||
|
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
||||||
|
&& cp readsb /usr/bin/readsb \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/readsb \
|
||||||
|
# Build rx_tools
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/rxseger/rx_tools.git \
|
||||||
|
&& cd rx_tools \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/rx_tools \
|
||||||
# Build acarsdec
|
# Build acarsdec
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||||
@@ -62,11 +111,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /tmp/acarsdec \
|
&& rm -rf /tmp/acarsdec \
|
||||||
# Cleanup build tools to reduce image size
|
# Cleanup build tools to reduce image size
|
||||||
&& apt-get remove -y \
|
&& apt-get remove -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
git \
|
git \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
cmake \
|
cmake \
|
||||||
libncurses-dev \
|
libncurses-dev \
|
||||||
|
libsndfile1-dev \
|
||||||
|
libsoapysdr-dev \
|
||||||
|
libhackrf-dev \
|
||||||
|
liblimesuite-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libzmq3-dev \
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,20 @@
|
|||||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Support the developer of this open-source project
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Signal Intelligence Platform</strong><br>
|
<strong>Signal Intelligence Platform</strong><br>
|
||||||
A web-based interface for software-defined radio tools.
|
A web-based interface for software-defined radio tools.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -22,11 +29,17 @@
|
|||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Listening Post** - Frequency scanner with audio monitoring
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **Satellite Tracking** - Pass prediction using TLE data
|
||||||
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,9 +65,63 @@ docker compose up -d
|
|||||||
|
|
||||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||||
|
|
||||||
|
### ADS-B History (Optional)
|
||||||
|
|
||||||
|
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start with ADS-B history and Postgres
|
||||||
|
docker compose --profile history up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the following environment variables (for example in a `.env` file):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other ADS-B Settings
|
||||||
|
|
||||||
|
Set these as environment variables for either local installs or Docker:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||||
|
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||||
|
|
||||||
|
**Local install example**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker example (.env)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||||
|
```
|
||||||
|
|
||||||
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **/adsb/history** for the reporting dashboard.
|
||||||
|
|
||||||
### Open the Interface
|
### Open the Interface
|
||||||
|
|
||||||
After starting, open **http://localhost:5050** in your browser.
|
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||||
|
|
||||||
|
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,14 +149,16 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
|||||||
## Discord Server
|
## Discord Server
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||||
|
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
|
||||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||||
@@ -122,10 +191,16 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||||
[dump1090](https://github.com/flightaware/dump1090) |
|
[dump1090](https://github.com/flightaware/dump1090) |
|
||||||
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
[Celestrak](https://celestrak.org/)
|
[Celestrak](https://celestrak.org/) |
|
||||||
|
[Priyom.org](https://priyom.org/)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-01-11_fae1348c",
|
"version": "2026-02-01_ba81b697",
|
||||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
import site
|
import site
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
# Ensure user site-packages is available (may be disabled when running as root/sudo)
|
# Ensure user site-packages is available (may be disabled when running as root/sudo)
|
||||||
if not site.ENABLE_USER_SITE:
|
if not site.ENABLE_USER_SITE:
|
||||||
user_site = site.getusersitepackages()
|
user_site = site.getusersitepackages()
|
||||||
@@ -23,9 +25,9 @@ import subprocess
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
from utils.process import cleanup_stale_processes
|
from utils.process import cleanup_stale_processes
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
@@ -34,20 +36,41 @@ from utils.constants import (
|
|||||||
MAX_AIRCRAFT_AGE_SECONDS,
|
MAX_AIRCRAFT_AGE_SECONDS,
|
||||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||||
MAX_BT_DEVICE_AGE_SECONDS,
|
MAX_BT_DEVICE_AGE_SECONDS,
|
||||||
|
MAX_VESSEL_AGE_SECONDS,
|
||||||
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||||
|
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
|
import logging
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
# Track application start time for uptime calculation
|
# Track application start time for uptime calculation
|
||||||
import time as _time
|
import time as _time
|
||||||
_app_start_time = _time.time()
|
_app_start_time = _time.time()
|
||||||
|
logger = logging.getLogger('intercept.database')
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||||
|
|
||||||
|
# Set up rate limiting
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address, # Identifies the user by their IP
|
||||||
|
app=app,
|
||||||
|
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
||||||
|
)
|
||||||
|
|
||||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ERROR HANDLERS
|
||||||
|
# ============================================
|
||||||
|
@app.errorhandler(429)
|
||||||
|
def ratelimit_handler(e):
|
||||||
|
logger.warning(f"Rate limit exceeded for IP: {request.remote_addr}")
|
||||||
|
flash("Too many login attempts. Please wait one minute before trying again.", "error")
|
||||||
|
return render_template('login.html', version=VERSION), 429
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# SECURITY HEADERS
|
# SECURITY HEADERS
|
||||||
@@ -69,6 +92,25 @@ def add_security_headers(response):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CONTEXT PROCESSORS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_offline_settings():
|
||||||
|
"""Inject offline settings into all templates."""
|
||||||
|
from utils.database import get_setting
|
||||||
|
return {
|
||||||
|
'offline_settings': {
|
||||||
|
'enabled': get_setting('offline.enabled', False),
|
||||||
|
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||||
|
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||||
|
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
||||||
|
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL PROCESS MANAGEMENT
|
# GLOBAL PROCESS MANAGEMENT
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -114,10 +156,31 @@ aprs_rtl_process = None
|
|||||||
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
aprs_lock = threading.Lock()
|
aprs_lock = threading.Lock()
|
||||||
|
|
||||||
|
# RTLAMR utility meter reading
|
||||||
|
rtlamr_process = None
|
||||||
|
rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
rtlamr_lock = threading.Lock()
|
||||||
|
|
||||||
|
# AIS vessel tracking
|
||||||
|
ais_process = None
|
||||||
|
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
ais_lock = threading.Lock()
|
||||||
|
|
||||||
|
# DSC (Digital Selective Calling)
|
||||||
|
dsc_process = None
|
||||||
|
dsc_rtl_process = None
|
||||||
|
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
dsc_lock = threading.Lock()
|
||||||
|
|
||||||
# TSCM (Technical Surveillance Countermeasures)
|
# TSCM (Technical Surveillance Countermeasures)
|
||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Deauth Attack Detection
|
||||||
|
deauth_detector = None
|
||||||
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
deauth_detector_lock = threading.Lock()
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL STATE DICTIONARIES
|
# GLOBAL STATE DICTIONARIES
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -141,6 +204,15 @@ bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested
|
|||||||
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
||||||
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
||||||
|
|
||||||
|
# Vessel (AIS) state - using DataStore for automatic cleanup
|
||||||
|
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
|
||||||
|
|
||||||
|
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
||||||
|
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
||||||
|
|
||||||
|
# Deauth alerts - using DataStore for automatic cleanup
|
||||||
|
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
||||||
|
|
||||||
# Satellite state
|
# Satellite state
|
||||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||||
|
|
||||||
@@ -150,21 +222,131 @@ cleanup_manager.register(wifi_clients)
|
|||||||
cleanup_manager.register(bt_devices)
|
cleanup_manager.register(bt_devices)
|
||||||
cleanup_manager.register(bt_beacons)
|
cleanup_manager.register(bt_beacons)
|
||||||
cleanup_manager.register(adsb_aircraft)
|
cleanup_manager.register(adsb_aircraft)
|
||||||
|
cleanup_manager.register(ais_vessels)
|
||||||
|
cleanup_manager.register(dsc_messages)
|
||||||
|
cleanup_manager.register(deauth_alerts)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SDR DEVICE REGISTRY
|
||||||
|
# ============================================
|
||||||
|
# Tracks which mode is using which SDR device to prevent conflicts
|
||||||
|
# Key: device_index (int), Value: mode_name (str)
|
||||||
|
sdr_device_registry: dict[int, str] = {}
|
||||||
|
sdr_device_registry_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||||
|
"""Claim an SDR device for a mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: The SDR device index to claim
|
||||||
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Error message if device is in use, None if successfully claimed
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
if device_index in sdr_device_registry:
|
||||||
|
in_use_by = sdr_device_registry[device_index]
|
||||||
|
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||||
|
sdr_device_registry[device_index] = mode_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def release_sdr_device(device_index: int) -> None:
|
||||||
|
"""Release an SDR device from the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: The SDR device index to release
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
sdr_device_registry.pop(device_index, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sdr_device_status() -> dict[int, str]:
|
||||||
|
"""Get current SDR device allocations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping device indices to mode names
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
return dict(sdr_device_registry)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# MAIN ROUTES
|
# MAIN ROUTES
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def require_login():
|
||||||
|
# Routes that don't require login (to avoid infinite redirect loop)
|
||||||
|
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||||
|
|
||||||
|
# Allow audio streaming endpoints without session auth
|
||||||
|
if request.path.startswith('/listening/audio/'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Controller API endpoints use API key auth, not session auth
|
||||||
|
# Allow agent push/pull endpoints without session login
|
||||||
|
if request.path.startswith('/controller/'):
|
||||||
|
return None # Skip session check, controller routes handle their own auth
|
||||||
|
|
||||||
|
# If user is not logged in and the current route is not allowed...
|
||||||
|
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
session.pop('logged_in', None)
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
@limiter.limit("5 per minute") # Limit to 5 login attempts per minute per IP
|
||||||
|
def login():
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username')
|
||||||
|
password = request.form.get('password')
|
||||||
|
|
||||||
|
# Connect to DB and find user
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'SELECT password_hash, role FROM users WHERE username = ?',
|
||||||
|
(username,)
|
||||||
|
)
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
# Verify user exists and password is correct
|
||||||
|
if user and check_password_hash(user['password_hash'], password):
|
||||||
|
# Store data in session
|
||||||
|
session['logged_in'] = True
|
||||||
|
session['username'] = username
|
||||||
|
session['role'] = user['role']
|
||||||
|
|
||||||
|
logger.info(f"User '{username}' logged in successfully.")
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed login attempt for username: {username}")
|
||||||
|
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
||||||
|
|
||||||
|
return render_template('login.html', version=VERSION)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index() -> str:
|
def index() -> str:
|
||||||
tools = {
|
tools = {
|
||||||
'rtl_fm': check_tool('rtl_fm'),
|
'rtl_fm': check_tool('rtl_fm'),
|
||||||
'multimon': check_tool('multimon-ng'),
|
'multimon': check_tool('multimon-ng'),
|
||||||
'rtl_433': check_tool('rtl_433')
|
'rtl_433': check_tool('rtl_433'),
|
||||||
|
'rtlamr': check_tool('rtlamr')
|
||||||
}
|
}
|
||||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
tools=tools,
|
||||||
|
devices=devices,
|
||||||
|
version=VERSION,
|
||||||
|
changelog=CHANGELOG,
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/favicon.svg')
|
@app.route('/favicon.svg')
|
||||||
@@ -179,6 +361,22 @@ def get_devices() -> Response:
|
|||||||
return jsonify([d.to_dict() for d in devices])
|
return jsonify([d.to_dict() for d in devices])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/devices/status')
|
||||||
|
def get_devices_status() -> Response:
|
||||||
|
"""Get all SDR devices with usage status."""
|
||||||
|
devices = SDRFactory.detect_devices()
|
||||||
|
registry = get_sdr_device_status()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for device in devices:
|
||||||
|
d = device.to_dict()
|
||||||
|
d['in_use'] = device.index in registry
|
||||||
|
d['used_by'] = registry.get(device.index)
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices/debug')
|
@app.route('/devices/debug')
|
||||||
def get_devices_debug() -> Response:
|
def get_devices_debug() -> Response:
|
||||||
"""Get detailed SDR device detection diagnostics."""
|
"""Get detailed SDR device detection diagnostics."""
|
||||||
@@ -431,34 +629,41 @@ def health_check() -> Response:
|
|||||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||||
|
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||||
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
|
'vessel_count': len(ais_vessels),
|
||||||
'wifi_networks_count': len(wifi_networks),
|
'wifi_networks_count': len(wifi_networks),
|
||||||
'wifi_clients_count': len(wifi_clients),
|
'wifi_clients_count': len(wifi_clients),
|
||||||
'bt_devices_count': len(bt_devices),
|
'bt_devices_count': len(bt_devices),
|
||||||
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder and WiFi processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global aprs_process, aprs_rtl_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
|
|
||||||
# Import adsb module to reset its state
|
# Import adsb and ais modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
|
from routes import ais as ais_module
|
||||||
|
from utils.bluetooth import reset_bluetooth_scanner
|
||||||
|
|
||||||
killed = []
|
killed = []
|
||||||
processes_to_kill = [
|
processes_to_kill = [
|
||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'direwolf'
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
|
'hcitool', 'bluetoothctl'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -483,6 +688,11 @@ def kill_all() -> Response:
|
|||||||
adsb_process = None
|
adsb_process = None
|
||||||
adsb_module.adsb_using_service = False
|
adsb_module.adsb_using_service = False
|
||||||
|
|
||||||
|
# Reset AIS state
|
||||||
|
with ais_lock:
|
||||||
|
ais_process = None
|
||||||
|
ais_module.ais_running = False
|
||||||
|
|
||||||
# Reset ACARS state
|
# Reset ACARS state
|
||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
acars_process = None
|
||||||
@@ -492,6 +702,35 @@ def kill_all() -> Response:
|
|||||||
aprs_process = None
|
aprs_process = None
|
||||||
aprs_rtl_process = None
|
aprs_rtl_process = None
|
||||||
|
|
||||||
|
# Reset DSC state
|
||||||
|
with dsc_lock:
|
||||||
|
dsc_process = None
|
||||||
|
dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Reset Bluetooth state (legacy)
|
||||||
|
with bt_lock:
|
||||||
|
if bt_process:
|
||||||
|
try:
|
||||||
|
bt_process.terminate()
|
||||||
|
bt_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
bt_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
bt_process = None
|
||||||
|
|
||||||
|
# Reset Bluetooth v2 scanner
|
||||||
|
try:
|
||||||
|
reset_bluetooth_scanner()
|
||||||
|
killed.append('bluetooth_scanner')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear SDR device registry
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
sdr_device_registry.clear()
|
||||||
|
|
||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
@@ -584,6 +823,22 @@ def main() -> None:
|
|||||||
from routes import register_blueprints
|
from routes import register_blueprints
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Update TLE data in background thread (non-blocking)
|
||||||
|
def update_tle_background():
|
||||||
|
try:
|
||||||
|
from routes.satellite import refresh_tle_data
|
||||||
|
print("Updating satellite TLE data from CelesTrak...")
|
||||||
|
updated = refresh_tle_data()
|
||||||
|
if updated:
|
||||||
|
print(f"TLE data updated for: {', '.join(updated)}")
|
||||||
|
else:
|
||||||
|
print("TLE update: No satellites updated (may be offline)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"TLE update failed (will use cached data): {e}")
|
||||||
|
|
||||||
|
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
|
||||||
|
tle_thread.start()
|
||||||
|
|
||||||
# Initialize WebSocket for audio streaming
|
# Initialize WebSocket for audio streaming
|
||||||
try:
|
try:
|
||||||
from routes.audio_websocket import init_audio_websocket
|
from routes.audio_websocket import init_audio_websocket
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# DSC (Digital Selective Calling) decoder wrapper
|
||||||
|
# Invokes the Python DSC decoder module
|
||||||
|
|
||||||
|
# Get the directory where this script is located
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Set PYTHONPATH to include project root
|
||||||
|
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
|
||||||
|
|
||||||
|
# Run the decoder module
|
||||||
|
exec python3 -m utils.dsc.decoder "$@"
|
||||||
@@ -7,10 +7,66 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.9.5"
|
VERSION = "2.13.1"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.13.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Help modal system with keyboard shortcuts reference",
|
||||||
|
"Main Dashboard button in navigation bar",
|
||||||
|
"Settings modal accessible from all dashboards",
|
||||||
|
"Dashboard CSS improvements and consistency fixes",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.13.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"WiFi client display in AP detail drawer",
|
||||||
|
"Real-time client updates via SSE streaming",
|
||||||
|
"Probed SSID badges for connected clients",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.12.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Bug fixes and improvements",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.12.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"ISS SSTV decoder with real-time ISS tracking globe",
|
||||||
|
"GitHub update notifications for new releases",
|
||||||
|
"Meshtastic QR code support and telemetry display",
|
||||||
|
"New Space category with reorganized UI",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.11.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Meshtastic LoRa mesh network integration",
|
||||||
|
"Ubertooth One BLE scanning support",
|
||||||
|
"Offline mode with bundled assets",
|
||||||
|
"Settings modal with tile provider configuration",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.10.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"AIS vessel tracking with VHF DSC distress monitoring",
|
||||||
|
"Spy Stations database (number stations & diplomatic HF)",
|
||||||
|
"MMSI country identification and distress alert overlays",
|
||||||
|
"SDR device conflict detection for AIS/DSC",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.9.5",
|
"version": "2.9.5",
|
||||||
"date": "January 2026",
|
"date": "January 2026",
|
||||||
@@ -41,16 +97,6 @@ CHANGELOG = [
|
|||||||
"Risk scoring and threat classification",
|
"Risk scoring and threat classification",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"version": "2.7.0",
|
|
||||||
"date": "November 2025",
|
|
||||||
"highlights": [
|
|
||||||
"Multi-SDR hardware support via SoapySDR",
|
|
||||||
"LimeSDR, HackRF, Airspy, SDRplay support",
|
|
||||||
"Improved aircraft database with photo lookup",
|
|
||||||
"GPS auto-detection and integration",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -119,12 +165,33 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
|||||||
# ADS-B settings
|
# ADS-B settings
|
||||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||||
|
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
|
||||||
|
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
||||||
|
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||||
|
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||||
|
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||||
|
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||||
|
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||||
|
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
||||||
|
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
||||||
|
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||||
|
|
||||||
|
# Observer location settings
|
||||||
|
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
||||||
|
|
||||||
# Satellite settings
|
# Satellite settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||||
|
|
||||||
|
# Update checking
|
||||||
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||||
|
|
||||||
|
# Admin credentials
|
||||||
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure application logging."""
|
"""Configure application logging."""
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
# TLE data for satellite tracking (updated periodically)
|
# TLE data for satellite tracking (updated periodically)
|
||||||
|
# To update: click "Update TLE" in satellite dashboard or SSTV mode
|
||||||
|
# Data source: CelesTrak (celestrak.org)
|
||||||
TLE_SATELLITES = {
|
TLE_SATELLITES = {
|
||||||
'ISS': ('ISS (ZARYA)',
|
'ISS': ('ISS (ZARYA)',
|
||||||
'1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
|
'1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991',
|
||||||
'2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
|
'2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'),
|
||||||
|
'NOAA-15': ('NOAA 15',
|
||||||
|
'1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999',
|
||||||
|
'2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'),
|
||||||
|
'NOAA-18': ('NOAA 18',
|
||||||
|
'1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996',
|
||||||
|
'2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668'),
|
||||||
|
'NOAA-19': ('NOAA 19',
|
||||||
|
'1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998',
|
||||||
|
'2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447'),
|
||||||
'NOAA-20': ('NOAA 20 (JPSS-1)',
|
'NOAA-20': ('NOAA 20 (JPSS-1)',
|
||||||
'1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995',
|
||||||
'2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
'2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'),
|
||||||
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
||||||
'1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995',
|
||||||
'2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
'2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'),
|
||||||
'METEOR-M2': ('METEOR-M 2',
|
'METEOR-M2': ('METEOR-M 2',
|
||||||
'1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990',
|
||||||
'2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
|
'2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'),
|
||||||
'METEOR-M2-3': ('METEOR-M2 3',
|
'METEOR-M2-3': ('METEOR-M2 3',
|
||||||
'1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
||||||
'2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
|
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,10 +365,14 @@ def get_all_sweep_presets() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = None) -> dict | None:
|
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | None = None) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Check if a BLE device matches known tracker signatures.
|
Check if a BLE device matches known tracker signatures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Device name to check against patterns
|
||||||
|
manufacturer_data: Manufacturer data as bytes or hex string
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tracker info dict if match found, None otherwise
|
Tracker info dict if match found, None otherwise
|
||||||
"""
|
"""
|
||||||
@@ -379,11 +383,20 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None =
|
|||||||
if pattern in name_lower:
|
if pattern in name_lower:
|
||||||
return tracker_info
|
return tracker_info
|
||||||
|
|
||||||
if manufacturer_data and len(manufacturer_data) >= 2:
|
if manufacturer_data:
|
||||||
company_id = int.from_bytes(manufacturer_data[:2], 'little')
|
# Convert hex string to bytes if needed
|
||||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
mfr_bytes = manufacturer_data
|
||||||
if tracker_info.get('company_id') == company_id:
|
if isinstance(manufacturer_data, str):
|
||||||
return tracker_info
|
try:
|
||||||
|
mfr_bytes = bytes.fromhex(manufacturer_data)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(mfr_bytes) >= 2:
|
||||||
|
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
||||||
|
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
|
if tracker_info.get('company_id') == company_id:
|
||||||
|
return tracker_info
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker Compose configuration for easy deployment
|
# Docker Compose configuration for easy deployment
|
||||||
|
#
|
||||||
|
# Basic usage:
|
||||||
|
# docker compose up -d
|
||||||
|
#
|
||||||
|
# With ADS-B history (Postgres):
|
||||||
|
# docker compose --profile history up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
intercept:
|
intercept:
|
||||||
@@ -13,15 +19,27 @@ services:
|
|||||||
# USB device mapping (alternative to privileged mode)
|
# USB device mapping (alternative to privileged mode)
|
||||||
# devices:
|
# devices:
|
||||||
# - /dev/bus/usb:/dev/bus/usb
|
# - /dev/bus/usb:/dev/bus/usb
|
||||||
volumes:
|
# volumes:
|
||||||
# Persist data directory
|
# Persist data directory
|
||||||
- ./data:/app/data
|
# - ./data:/app/data
|
||||||
# Optional: mount logs directory
|
# Optional: mount logs directory
|
||||||
# - ./logs:/app/logs
|
# - ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# ADS-B history is disabled by default
|
||||||
|
# To enable, use: docker compose --profile history up -d
|
||||||
|
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
# - INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
# - INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
# - INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
|
# Shared observer location across modules
|
||||||
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
# Network mode for WiFi scanning (requires host network)
|
# Network mode for WiFi scanning (requires host network)
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -32,6 +50,58 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
# ADS-B history with Postgres persistence
|
||||||
|
# Enable with: docker compose --profile history up -d
|
||||||
|
intercept-history:
|
||||||
|
build: .
|
||||||
|
container_name: intercept
|
||||||
|
profiles:
|
||||||
|
- history
|
||||||
|
depends_on:
|
||||||
|
- adsb_db
|
||||||
|
ports:
|
||||||
|
- "5050:5050"
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
|
- INTERCEPT_PORT=5050
|
||||||
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
- INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
- INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
|
# Shared observer location across modules
|
||||||
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
adsb_db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: intercept-adsb-db
|
||||||
|
profiles:
|
||||||
|
- history
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=intercept_adsb
|
||||||
|
- POSTGRES_USER=intercept
|
||||||
|
- POSTGRES_PASSWORD=intercept
|
||||||
|
volumes:
|
||||||
|
- ./pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
# Optional: Add volume for persistent SQLite database
|
# Optional: Add volume for persistent SQLite database
|
||||||
# volumes:
|
# volumes:
|
||||||
# intercept-data:
|
# intercept-data:
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# Intercept Distributed Agent System
|
||||||
|
|
||||||
|
This document describes the distributed agent architecture that allows multiple remote sensor nodes to feed data into a central Intercept controller.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The agent system uses a hub-and-spoke architecture where:
|
||||||
|
- **Controller**: The main Intercept instance that aggregates data from multiple agents
|
||||||
|
- **Agents**: Lightweight sensor nodes running on remote devices with SDR hardware
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ INTERCEPT CONTROLLER │
|
||||||
|
│ (port 5050) │
|
||||||
|
│ │
|
||||||
|
│ - Web UI with agent selector │
|
||||||
|
│ - /controller/manage page │
|
||||||
|
│ - Multi-agent SSE stream │
|
||||||
|
│ - Push data storage │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
│ │ │
|
||||||
|
Push/Pull │ │ │ Push/Pull
|
||||||
|
│ │ │
|
||||||
|
┌────┴───┐ ┌────┴───┐ ┌────┴───┐
|
||||||
|
│ Agent │ │ Agent │ │ Agent │
|
||||||
|
│ :8020 │ │ :8020 │ │ :8020 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│[RTL-SDR] │[HackRF] │ │[LimeSDR]
|
||||||
|
└────────┘ └────────┘ └────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Start the Controller
|
||||||
|
|
||||||
|
The controller is the main Intercept application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd intercept
|
||||||
|
python app.py
|
||||||
|
# Runs on http://localhost:5050
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure an Agent
|
||||||
|
|
||||||
|
Create a config file on the remote machine:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# intercept_agent.cfg
|
||||||
|
[agent]
|
||||||
|
name = sensor-node-1
|
||||||
|
port = 8020
|
||||||
|
allowed_ips =
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
api_key = your-secret-key-here
|
||||||
|
push_enabled = true
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python intercept_agent.py --config intercept_agent.cfg
|
||||||
|
# Runs on http://localhost:8020
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Register the Agent
|
||||||
|
|
||||||
|
Go to `http://controller:5050/controller/manage` and add the agent:
|
||||||
|
- **Name**: sensor-node-1 (must match config)
|
||||||
|
- **Base URL**: http://agent-ip:8020
|
||||||
|
- **API Key**: your-secret-key-here (must match config)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
The system supports two data flow patterns:
|
||||||
|
|
||||||
|
#### Push (Agent → Controller)
|
||||||
|
|
||||||
|
Agents automatically push captured data to the controller:
|
||||||
|
|
||||||
|
1. Agent captures data (e.g., rtl_433 sensor readings)
|
||||||
|
2. Data is queued in the `ControllerPushClient`
|
||||||
|
3. Agent POSTs to `http://controller/controller/api/ingest`
|
||||||
|
4. Controller validates API key and stores in `push_payloads` table
|
||||||
|
5. Data is available via SSE stream at `/controller/stream/all`
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent Controller
|
||||||
|
│ │
|
||||||
|
│ POST /controller/api/ingest │
|
||||||
|
│ Header: X-API-Key: secret │
|
||||||
|
│ Body: {agent_name, scan_type, │
|
||||||
|
│ payload, timestamp} │
|
||||||
|
│ ──────────────────────────────► │
|
||||||
|
│ │
|
||||||
|
│ 200 OK │
|
||||||
|
│ ◄────────────────────────────── │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pull (Controller → Agent)
|
||||||
|
|
||||||
|
The controller can also pull data on-demand:
|
||||||
|
|
||||||
|
1. User selects agent in UI dropdown
|
||||||
|
2. User clicks "Start Listening"
|
||||||
|
3. Controller proxies request to agent
|
||||||
|
4. Agent starts the mode and returns status
|
||||||
|
5. Controller polls agent for data
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Controller Agent
|
||||||
|
│ │ │
|
||||||
|
│ POST /controller/ │ │
|
||||||
|
│ agents/1/sensor/start│ │
|
||||||
|
│ ─────────────────────► │ │
|
||||||
|
│ │ POST /sensor/start │
|
||||||
|
│ │ ────────────────────────► │
|
||||||
|
│ │ │
|
||||||
|
│ │ {status: started} │
|
||||||
|
│ │ ◄──────────────────────── │
|
||||||
|
│ {status: success} │ │
|
||||||
|
│ ◄───────────────────── │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
API key authentication secures the push mechanism:
|
||||||
|
|
||||||
|
1. Agent config specifies `api_key` in `[controller]` section
|
||||||
|
2. Agent sends `X-API-Key` header with each push request
|
||||||
|
3. Controller looks up agent by name in database
|
||||||
|
4. Controller compares provided key with stored key
|
||||||
|
5. Mismatched keys return 401 Unauthorized
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
Two tables support the agent system:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Registered agents
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
api_key TEXT,
|
||||||
|
capabilities TEXT, -- JSON: {pager: true, sensor: true, ...}
|
||||||
|
interfaces TEXT, -- JSON: {devices: [...]}
|
||||||
|
gps_coords TEXT, -- JSON: {lat, lon}
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
is_active BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pushed data from agents
|
||||||
|
CREATE TABLE push_payloads (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
agent_id INTEGER,
|
||||||
|
scan_type TEXT, -- pager, sensor, adsb, wifi, etc.
|
||||||
|
payload TEXT, -- JSON data
|
||||||
|
received_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent REST API
|
||||||
|
|
||||||
|
The agent exposes these endpoints:
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check (returns `{status: "healthy"}`) |
|
||||||
|
| `/capabilities` | GET | Available modes, devices, GPS status |
|
||||||
|
| `/status` | GET | Running modes, uptime, push status |
|
||||||
|
| `/{mode}/start` | POST | Start a mode (pager, sensor, adsb, etc.) |
|
||||||
|
| `/{mode}/stop` | POST | Stop a mode |
|
||||||
|
| `/{mode}/status` | GET | Mode-specific status |
|
||||||
|
| `/{mode}/data` | GET | Current data snapshot |
|
||||||
|
|
||||||
|
### Example: Start Sensor Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://agent:8020/sensor/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"frequency": 433.92, "device_index": 0}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"mode": "sensor",
|
||||||
|
"command": "/usr/local/bin/rtl_433 -d 0 -f 433.92M -F json",
|
||||||
|
"gps_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Get Capabilities
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://agent:8020/capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modes": {
|
||||||
|
"pager": true,
|
||||||
|
"sensor": true,
|
||||||
|
"adsb": true,
|
||||||
|
"wifi": true,
|
||||||
|
"bluetooth": true
|
||||||
|
},
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"name": "RTLSDRBlog, Blog V4",
|
||||||
|
"sdr_type": "rtlsdr",
|
||||||
|
"capabilities": {
|
||||||
|
"freq_min_mhz": 24.0,
|
||||||
|
"freq_max_mhz": 1766.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gps": true,
|
||||||
|
"gps_position": {
|
||||||
|
"lat": 33.543,
|
||||||
|
"lon": -82.194,
|
||||||
|
"altitude": 70.0
|
||||||
|
},
|
||||||
|
"tool_details": {
|
||||||
|
"sensor": {
|
||||||
|
"name": "433MHz Sensors",
|
||||||
|
"ready": true,
|
||||||
|
"tools": {
|
||||||
|
"rtl_433": {"installed": true, "required": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Modes
|
||||||
|
|
||||||
|
All modes are fully implemented in the agent with the following tools and data formats:
|
||||||
|
|
||||||
|
| Mode | Tool(s) | Data Format | Notes |
|
||||||
|
|------|---------|-------------|-------|
|
||||||
|
| `sensor` | rtl_433 | JSON readings | ISM band devices (433/868/915 MHz) |
|
||||||
|
| `pager` | rtl_fm + multimon-ng | POCSAG/FLEX messages | Address, function, message content |
|
||||||
|
| `adsb` | dump1090 | SBS-format aircraft | ICAO, callsign, position, altitude |
|
||||||
|
| `ais` | AIS-catcher | JSON vessels | MMSI, position, speed, vessel info |
|
||||||
|
| `acars` | acarsdec | JSON messages | Aircraft tail, label, message text |
|
||||||
|
| `aprs` | rtl_fm + direwolf | APRS packets | Callsign, position, path |
|
||||||
|
| `wifi` | airodump-ng | Networks + clients | BSSID, ESSID, signal, clients |
|
||||||
|
| `bluetooth` | bluetoothctl | Device list | MAC, name, RSSI |
|
||||||
|
| `rtlamr` | rtl_tcp + rtlamr | Meter readings | Meter ID, consumption data |
|
||||||
|
| `dsc` | rtl_fm (+ dsc-decoder) | DSC messages | MMSI, distress category, position |
|
||||||
|
| `tscm` | WiFi/BT analysis | Anomaly reports | New/rogue devices detected |
|
||||||
|
| `satellite` | skyfield (TLE) | Pass predictions | No SDR required |
|
||||||
|
| `listening_post` | rtl_fm scanner | Signal detections | Frequency, modulation |
|
||||||
|
|
||||||
|
### Mode-Specific Notes
|
||||||
|
|
||||||
|
**Listening Post**: Full FFT streaming isn't practical over HTTP. Instead, the agent provides:
|
||||||
|
- Signal detection events when activity is found
|
||||||
|
- Current scanning frequency
|
||||||
|
- Activity log of detected signals
|
||||||
|
|
||||||
|
**TSCM**: Analyzes WiFi and Bluetooth data for anomalies:
|
||||||
|
- Builds baseline of known devices
|
||||||
|
- Reports new/unknown devices as anomalies
|
||||||
|
- No SDR required (uses WiFi/BT data)
|
||||||
|
|
||||||
|
**Satellite**: Pure computational mode:
|
||||||
|
- Calculates pass predictions from TLE data
|
||||||
|
- Requires observer location (lat/lon)
|
||||||
|
- No SDR required
|
||||||
|
|
||||||
|
**Audio Modes**: Modes requiring real-time audio (airband, listening_post audio) are limited via agents. Use rtl_tcp for remote audio streaming instead.
|
||||||
|
|
||||||
|
## Controller API
|
||||||
|
|
||||||
|
### Agent Management
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/agents` | GET | List all agents |
|
||||||
|
| `/controller/agents` | POST | Register new agent |
|
||||||
|
| `/controller/agents/{id}` | GET | Get agent details |
|
||||||
|
| `/controller/agents/{id}` | DELETE | Remove agent |
|
||||||
|
| `/controller/agents/{id}?refresh=true` | GET | Refresh agent capabilities |
|
||||||
|
|
||||||
|
### Proxy Operations
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/agents/{id}/{mode}/start` | POST | Start mode on agent |
|
||||||
|
| `/controller/agents/{id}/{mode}/stop` | POST | Stop mode on agent |
|
||||||
|
| `/controller/agents/{id}/{mode}/data` | GET | Get data from agent |
|
||||||
|
|
||||||
|
### Push Ingestion
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/api/ingest` | POST | Receive pushed data from agents |
|
||||||
|
|
||||||
|
### SSE Streams
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/controller/stream/all` | Combined stream from all agents |
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Agent Selector
|
||||||
|
|
||||||
|
The main UI includes an agent dropdown in supported modes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<select id="agentSelect">
|
||||||
|
<option value="local">Local (This Device)</option>
|
||||||
|
<option value="1">● sensor-node-1</option>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
When an agent is selected:
|
||||||
|
1. Device list updates to show agent's SDR devices
|
||||||
|
2. Start/Stop commands route through controller proxy
|
||||||
|
3. Data displays with agent name badge
|
||||||
|
|
||||||
|
### Multi-Agent Mode
|
||||||
|
|
||||||
|
Enable "Show All Agents" checkbox to:
|
||||||
|
- Connect to `/controller/stream/all` SSE
|
||||||
|
- Display combined data from all agents
|
||||||
|
- Show agent name badge on each data item
|
||||||
|
|
||||||
|
## GPS Integration
|
||||||
|
|
||||||
|
Agents can include GPS coordinates with captured data:
|
||||||
|
|
||||||
|
1. Agent connects to local `gpsd` daemon
|
||||||
|
2. GPS position included in `/capabilities` and `/status`
|
||||||
|
3. Each data snapshot includes `agent_gps` field
|
||||||
|
4. Controller can use GPS for trilateration (multiple agents)
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Agent Config (`intercept_agent.cfg`)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[agent]
|
||||||
|
# Agent identity (must be unique across all agents)
|
||||||
|
name = sensor-node-1
|
||||||
|
|
||||||
|
# Port to listen on
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
# Restrict connections to specific IPs (comma-separated, empty = all)
|
||||||
|
allowed_ips =
|
||||||
|
|
||||||
|
# Enable CORS headers
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
# Controller URL (required for push)
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
|
||||||
|
# API key for authentication
|
||||||
|
api_key = your-secret-key
|
||||||
|
|
||||||
|
# Enable automatic data push
|
||||||
|
push_enabled = true
|
||||||
|
|
||||||
|
# Push interval in seconds
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
# Enable/disable specific modes
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
ais = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Agent not appearing in controller
|
||||||
|
|
||||||
|
1. Check agent is running: `curl http://agent:8020/health`
|
||||||
|
2. Verify agent is registered in `/controller/manage`
|
||||||
|
3. Check API key matches between agent config and controller registration
|
||||||
|
4. Check network connectivity between agent and controller
|
||||||
|
|
||||||
|
### Push data not arriving
|
||||||
|
|
||||||
|
1. Check agent status: `curl http://agent:8020/status`
|
||||||
|
- Verify `push_enabled: true` and `push_connected: true`
|
||||||
|
2. Check controller logs for authentication errors
|
||||||
|
3. Verify API key matches
|
||||||
|
4. Check if mode is running and producing data
|
||||||
|
|
||||||
|
### Mode won't start on agent
|
||||||
|
|
||||||
|
1. Check capabilities: `curl http://agent:8020/capabilities`
|
||||||
|
2. Verify required tools are installed (check `tool_details`)
|
||||||
|
3. Check if SDR device is available (not in use by another process)
|
||||||
|
|
||||||
|
### No data from sensor mode
|
||||||
|
|
||||||
|
1. Verify rtl_433 is running: `ps aux | grep rtl_433`
|
||||||
|
2. Check sensor status: `curl http://agent:8020/sensor/status`
|
||||||
|
3. Note: Empty data is normal if no 433MHz devices are transmitting nearby
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **API Keys**: Always use strong, unique API keys for each agent
|
||||||
|
2. **Network**: Consider running agents on a private network or VPN
|
||||||
|
3. **HTTPS**: For production, use HTTPS between agents and controller
|
||||||
|
4. **Firewall**: Restrict agent ports to controller IP only
|
||||||
|
5. **allowed_ips**: Use this config option to restrict agent connections
|
||||||
|
|
||||||
|
## Dashboard Integration
|
||||||
|
|
||||||
|
Agent support has been integrated into the following specialized dashboards:
|
||||||
|
|
||||||
|
### ADS-B Dashboard (`/adsb/dashboard`)
|
||||||
|
- Agent selector in header bar
|
||||||
|
- Routes tracking start/stop through agent proxy when remote agent selected
|
||||||
|
- Connects to multi-agent stream for data from remote agents
|
||||||
|
- Displays agent badge on aircraft from remote sources
|
||||||
|
- Updates observer location from agent's GPS coordinates
|
||||||
|
|
||||||
|
### AIS Dashboard (`/ais/dashboard`)
|
||||||
|
- Agent selector in header bar
|
||||||
|
- Routes AIS and DSC mode operations through agent proxy
|
||||||
|
- Connects to multi-agent stream for vessel data
|
||||||
|
- Displays agent badge on vessels from remote sources
|
||||||
|
- Updates observer location from agent's GPS coordinates
|
||||||
|
|
||||||
|
### Main Dashboard (`/`)
|
||||||
|
- Agent selector in sidebar
|
||||||
|
- Supports sensor, pager, WiFi, Bluetooth modes via agents
|
||||||
|
- SDR conflict detection with device-aware warnings
|
||||||
|
- Real-time sync with agent's running mode state
|
||||||
|
|
||||||
|
### Multi-SDR Agent Support
|
||||||
|
|
||||||
|
For agents with multiple SDR devices, the system now tracks which device each mode is using:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"running_modes": ["sensor", "adsb"],
|
||||||
|
"running_modes_detail": {
|
||||||
|
"sensor": {"device": 0, "started_at": "2024-01-15T10:30:00Z"},
|
||||||
|
"adsb": {"device": 1, "started_at": "2024-01-15T10:35:00Z"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
- Smart conflict detection (only warns if same device is in use)
|
||||||
|
- Display of which device each mode is using
|
||||||
|
- Parallel operation of multiple SDR modes on multi-SDR agents
|
||||||
|
|
||||||
|
### Agent Mode Warnings
|
||||||
|
|
||||||
|
When an agent has SDR modes running, the UI displays:
|
||||||
|
- Warning banner showing active modes with device numbers
|
||||||
|
- Stop buttons for each running mode
|
||||||
|
- Refresh button to re-sync with agent state
|
||||||
|
|
||||||
|
### Pages Without Agent Support
|
||||||
|
|
||||||
|
The following pages don't require SDR-based agent support:
|
||||||
|
- **Satellite Dashboard** (`/satellite/dashboard`) - Uses TLE orbital calculations, no SDR
|
||||||
|
- **History pages** - Display stored data, not live SDR streams
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `intercept_agent.py` | Standalone agent server |
|
||||||
|
| `intercept_agent.cfg` | Agent configuration template |
|
||||||
|
| `routes/controller.py` | Controller API blueprint |
|
||||||
|
| `utils/agent_client.py` | HTTP client for agents |
|
||||||
|
| `utils/database.py` | Agent CRUD operations |
|
||||||
|
| `static/js/core/agents.js` | Frontend agent management |
|
||||||
|
| `templates/agents.html` | Agent management page |
|
||||||
|
| `templates/adsb_dashboard.html` | ADS-B page with agent integration |
|
||||||
|
| `templates/ais_dashboard.html` | AIS page with agent integration |
|
||||||
@@ -16,6 +16,28 @@ Complete feature list for all modules.
|
|||||||
- **Doorbells, remotes, and IoT devices**
|
- **Doorbells, remotes, and IoT devices**
|
||||||
- **Smart meters** and utility monitors
|
- **Smart meters** and utility monitors
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||||
|
- **Full-screen dashboard** - dedicated popout with interactive map
|
||||||
|
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||||
|
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
||||||
|
- **Navigation data** - speed, course, heading, rate of turn
|
||||||
|
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
||||||
|
- **Vessel dimensions** - length, width, draught
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
|
## Spy Stations (Number Stations)
|
||||||
|
|
||||||
|
- **Comprehensive database** of active number stations and diplomatic networks
|
||||||
|
- **Station profiles** - frequencies, schedules, operators, descriptions
|
||||||
|
- **Filter by type** - number stations vs diplomatic networks
|
||||||
|
- **Filter by country** - Russia, Cuba, Israel, Poland, North Korea, etc.
|
||||||
|
- **Filter by mode** - USB, AM, CW, OFDM
|
||||||
|
- **Tune integration** - click to tune Listening Post to station frequency
|
||||||
|
- **Source links** - references to priyom.org for detailed information
|
||||||
|
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
|
||||||
|
|
||||||
## ADS-B Aircraft Tracking
|
## ADS-B Aircraft Tracking
|
||||||
|
|
||||||
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
||||||
@@ -26,6 +48,8 @@ Complete feature list for all modules.
|
|||||||
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
||||||
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
||||||
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
||||||
|
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
|
||||||
|
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
|
||||||
- **Observer location** - manual input or GPS geolocation
|
- **Observer location** - manual input or GPS geolocation
|
||||||
- **Audio alerts** - notifications for military and emergency aircraft
|
- **Audio alerts** - notifications for military and emergency aircraft
|
||||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||||
@@ -35,6 +59,31 @@ Complete feature list for all modules.
|
|||||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
- **Real-time vessel tracking** via AIS-catcher or rtl_ais
|
||||||
|
- **Full-screen dashboard** - dedicated popout with maritime map
|
||||||
|
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||||
|
- **Vessel trails** - optional track history visualization
|
||||||
|
- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
|
||||||
|
- **Country identification** - flag lookup via Maritime Identification Digits (MID)
|
||||||
|
|
||||||
|
### VHF DSC Channel 70 Monitoring
|
||||||
|
|
||||||
|
Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
|
||||||
|
|
||||||
|
- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
|
||||||
|
- **MMSI country lookup** - 180+ Maritime Identification Digit codes
|
||||||
|
- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
|
||||||
|
- **Position extraction** - Automatic lat/lon parsing from distress messages
|
||||||
|
- **Map markers** - Distress positions plotted with pulsing alert markers
|
||||||
|
- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
|
||||||
|
- **Audio alerts** - Notification sound for critical messages
|
||||||
|
- **Alert persistence** - Critical alerts stored permanently in database
|
||||||
|
- **Acknowledgement workflow** - Track response status with notes
|
||||||
|
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||||
|
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
@@ -116,6 +165,78 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
|||||||
- No cryptographic de-randomization
|
- No cryptographic de-randomization
|
||||||
- Passive screening only (no active probing by default)
|
- Passive screening only (no active probing by default)
|
||||||
|
|
||||||
|
## Meshtastic Mesh Networks
|
||||||
|
|
||||||
|
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||||
|
|
||||||
|
### Device Support
|
||||||
|
- **Heltec** - LoRa32 series
|
||||||
|
- **T-Beam** - TTGO T-Beam with GPS
|
||||||
|
- **RAK** - WisBlock series
|
||||||
|
- Any Meshtastic-compatible device via USB/Serial
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Real-time messaging** - Stream messages as they arrive
|
||||||
|
- **Channel configuration** - Set encryption keys and channel names
|
||||||
|
- **Node information** - View connected nodes with signal metrics
|
||||||
|
- **Message history** - Up to 500 messages retained
|
||||||
|
- **Signal quality** - RSSI and SNR for each message
|
||||||
|
- **Hop tracking** - See message hop count
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Physical Meshtastic device connected via USB
|
||||||
|
- Meshtastic Python SDK (`pip install meshtastic`)
|
||||||
|
|
||||||
|
## Ubertooth One BLE Scanning
|
||||||
|
|
||||||
|
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
- **40-channel scanning** - Capture BLE advertisements across all channels
|
||||||
|
- **Raw payload access** - Full advertising data for analysis
|
||||||
|
- **Passive sniffing** - No active scanning required
|
||||||
|
- **MAC address extraction** - Public and random address types
|
||||||
|
- **RSSI measurement** - Signal strength for proximity estimation
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- Works alongside standard BlueZ/DBus Bluetooth scanning
|
||||||
|
- Automatically detected when ubertooth-btle is available
|
||||||
|
- Falls back to standard adapter if Ubertooth not present
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Ubertooth One hardware
|
||||||
|
- ubertooth-btle command-line tool installed
|
||||||
|
- libubertooth library
|
||||||
|
|
||||||
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Hub-and-spoke model** - Central controller with multiple remote agents
|
||||||
|
- **Push and Pull modes** - Agents can push data automatically or respond to on-demand requests
|
||||||
|
- **API key authentication** - Secure communication between agents and controller
|
||||||
|
|
||||||
|
### Agent Features
|
||||||
|
- **Standalone deployment** - Run on Raspberry Pi, mini PCs, or any Linux device with SDR
|
||||||
|
- **All modes supported** - Pager, sensor, ADS-B, AIS, WiFi, Bluetooth, and more
|
||||||
|
- **GPS integration** - Automatic location tagging from USB GPS receivers
|
||||||
|
- **Multi-SDR support** - Run multiple modes simultaneously on agents with multiple SDRs
|
||||||
|
- **Capability discovery** - Controller auto-detects available modes and devices
|
||||||
|
|
||||||
|
### Controller Features
|
||||||
|
- **Agent management UI** - Register, test, and remove agents from `/controller/manage`
|
||||||
|
- **Real-time status** - Health monitoring with online/offline indicators
|
||||||
|
- **Unified data stream** - Aggregate data from all agents via SSE
|
||||||
|
- **Dashboard integration** - Agent selector in ADS-B, AIS, and main dashboards
|
||||||
|
- **Device conflict detection** - Smart warnings when SDR is in use
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
- **Wide-area monitoring** - Cover larger geographic areas with distributed sensors
|
||||||
|
- **Remote installations** - Deploy sensors in locations without direct access
|
||||||
|
- **Redundancy** - Multiple nodes for reliable coverage
|
||||||
|
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||||
|
|
||||||
## User Interface
|
## User Interface
|
||||||
|
|
||||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||||
@@ -137,6 +258,42 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
|||||||
| ? | Open help (when not typing) |
|
| ? | Open help (when not typing) |
|
||||||
| Escape | Close help/modals |
|
| Escape | Close help/modals |
|
||||||
|
|
||||||
|
## Offline Mode
|
||||||
|
|
||||||
|
Run iNTERCEPT without internet connectivity by using bundled local assets.
|
||||||
|
|
||||||
|
### Bundled Assets
|
||||||
|
- **Leaflet 1.9.4** - Map library with marker images
|
||||||
|
- **Chart.js 4.4.1** - Signal strength graphs
|
||||||
|
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
|
||||||
|
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
|
||||||
|
|
||||||
|
### Settings Modal
|
||||||
|
Access via the gear icon in the navigation bar:
|
||||||
|
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
|
||||||
|
- **Display Tab** - Theme and animation preferences
|
||||||
|
- **About Tab** - Version info and links
|
||||||
|
|
||||||
|
### Map Tile Providers
|
||||||
|
Choose from multiple tile sources for maps:
|
||||||
|
- **OpenStreetMap** - Default, general purpose
|
||||||
|
- **CartoDB Dark** - Dark themed, matches UI
|
||||||
|
- **CartoDB Positron** - Light themed
|
||||||
|
- **ESRI World Imagery** - Satellite imagery
|
||||||
|
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
|
||||||
|
|
||||||
|
### Local Asset Status
|
||||||
|
The settings modal shows availability status for each bundled asset:
|
||||||
|
- Green "Available" badge when asset is present
|
||||||
|
- Red "Missing" badge when asset is not found
|
||||||
|
- Click "Check Assets" to refresh status
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
- **Air-gapped environments** - Run on isolated networks
|
||||||
|
- **Field deployments** - Operate without reliable internet
|
||||||
|
- **Local tile servers** - Use pre-cached map tiles for specific regions
|
||||||
|
- **Reduced latency** - Faster loading with local assets
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
- **Web-based interface** - no desktop app needed
|
- **Web-based interface** - no desktop app needed
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
# iNTERCEPT UI Guide
|
||||||
|
|
||||||
|
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Design Tokens](#design-tokens)
|
||||||
|
2. [Base Templates](#base-templates)
|
||||||
|
3. [Navigation](#navigation)
|
||||||
|
4. [Components](#components)
|
||||||
|
5. [Adding a New Module Page](#adding-a-new-module-page)
|
||||||
|
6. [Adding a New Dashboard](#adding-a-new-dashboard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Backgrounds (layered depth) */
|
||||||
|
--bg-primary: #0a0c10; /* Darkest - page background */
|
||||||
|
--bg-secondary: #0f1218; /* Panels, sidebars */
|
||||||
|
--bg-tertiary: #151a23; /* Cards, elevated elements */
|
||||||
|
--bg-card: #121620; /* Card backgrounds */
|
||||||
|
--bg-elevated: #1a202c; /* Hover states, modals */
|
||||||
|
|
||||||
|
/* Accent Colors */
|
||||||
|
--accent-cyan: #4a9eff; /* Primary action color */
|
||||||
|
--accent-green: #22c55e; /* Success, online status */
|
||||||
|
--accent-red: #ef4444; /* Error, danger, stop */
|
||||||
|
--accent-orange: #f59e0b; /* Warning */
|
||||||
|
--accent-amber: #d4a853; /* Secondary highlight */
|
||||||
|
|
||||||
|
/* Text Hierarchy */
|
||||||
|
--text-primary: #e8eaed; /* Main content */
|
||||||
|
--text-secondary: #9ca3af; /* Secondary content */
|
||||||
|
--text-dim: #4b5563; /* Disabled, placeholder */
|
||||||
|
--text-muted: #374151; /* Barely visible */
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--status-online: #22c55e;
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-error: #ef4444;
|
||||||
|
--status-offline: #6b7280;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Font Families */
|
||||||
|
--font-sans: 'Inter', -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
--text-4xl: 30px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
```css
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Light Theme
|
||||||
|
|
||||||
|
The design system supports light/dark themes via `data-theme` attribute:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-theme="dark"> <!-- or "light" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Toggle with JavaScript:
|
||||||
|
```javascript
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base Templates
|
||||||
|
|
||||||
|
### `templates/layout/base.html`
|
||||||
|
|
||||||
|
The main base template for standard pages. Use for pages with sidebar + content layout.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}My Page Title{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% set active_mode = 'mymode' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
<div class="app-sidebar">
|
||||||
|
<!-- Sidebar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-container">
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<!-- Page content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Page-specific JavaScript
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `templates/layout/base_dashboard.html`
|
||||||
|
|
||||||
|
Extended base for full-screen dashboards (maps, visualizations).
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY DASHBOARD{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block stats_strip %}
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats bar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<div class="dashboard-map-container">
|
||||||
|
<!-- Main visualization -->
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-sidebar">
|
||||||
|
<!-- Sidebar panels -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Including Navigation
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% set active_mode = 'pager' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valid `active_mode` Values
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `pager` | Pager decoding |
|
||||||
|
| `sensor` | 433MHz sensors |
|
||||||
|
| `rtlamr` | Utility meters |
|
||||||
|
| `adsb` | Aircraft tracking |
|
||||||
|
| `ais` | Vessel tracking |
|
||||||
|
| `aprs` | Amateur radio |
|
||||||
|
| `wifi` | WiFi scanning |
|
||||||
|
| `bluetooth` | Bluetooth scanning |
|
||||||
|
| `tscm` | Counter-surveillance |
|
||||||
|
| `satellite` | Satellite tracking |
|
||||||
|
| `sstv` | ISS SSTV |
|
||||||
|
| `listening` | Listening post |
|
||||||
|
| `spystations` | Spy stations |
|
||||||
|
| `meshtastic` | Mesh networking |
|
||||||
|
|
||||||
|
### Navigation Groups
|
||||||
|
|
||||||
|
The navigation is organized into groups:
|
||||||
|
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||||
|
- **Wireless**: WiFi, Bluetooth
|
||||||
|
- **Security**: TSCM
|
||||||
|
- **Space**: Satellite, ISS SSTV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Card / Panel
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
|
||||||
|
<p>Panel content here</p>
|
||||||
|
{% endcall %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```html
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>PANEL TITLE</span>
|
||||||
|
<div class="panel-indicator active"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<p>Content here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% include 'components/empty_state.html' with context %}
|
||||||
|
{# Or with variables: #}
|
||||||
|
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
|
||||||
|
{% include 'components/empty_state.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# Inline spinner #}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
|
||||||
|
{# With text #}
|
||||||
|
{% with text='Loading data...', size='lg' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{# Full overlay #}
|
||||||
|
{% with overlay=true, text='Please wait...' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Badge
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% with status='online', text='Connected', id='connectionStatus' %}
|
||||||
|
{% include 'components/status_badge.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status values: `online`, `offline`, `warning`, `error`, `inactive`
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Primary action -->
|
||||||
|
<button class="btn btn-primary">Start Tracking</button>
|
||||||
|
|
||||||
|
<!-- Secondary action -->
|
||||||
|
<button class="btn btn-secondary">Cancel</button>
|
||||||
|
|
||||||
|
<!-- Danger action -->
|
||||||
|
<button class="btn btn-danger">Stop</button>
|
||||||
|
|
||||||
|
<!-- Ghost/subtle -->
|
||||||
|
<button class="btn btn-ghost">Settings</button>
|
||||||
|
|
||||||
|
<!-- Sizes -->
|
||||||
|
<button class="btn btn-primary btn-sm">Small</button>
|
||||||
|
<button class="btn btn-primary btn-lg">Large</button>
|
||||||
|
|
||||||
|
<!-- Icon button -->
|
||||||
|
<button class="btn btn-icon btn-secondary">
|
||||||
|
<span class="icon">...</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="badge">Default</span>
|
||||||
|
<span class="badge badge-primary">Primary</span>
|
||||||
|
<span class="badge badge-success">Online</span>
|
||||||
|
<span class="badge badge-warning">Warning</span>
|
||||||
|
<span class="badge badge-danger">Error</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Groups
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="frequency">Frequency (MHz)</label>
|
||||||
|
<input type="text" id="frequency" value="153.350">
|
||||||
|
<span class="form-help">Enter frequency in MHz</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="gain">Gain</label>
|
||||||
|
<select id="gain">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="30">30 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="alerts">
|
||||||
|
<span>Enable alerts</span>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats Strip
|
||||||
|
|
||||||
|
Used in dashboards for horizontal statistics display:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="stats-strip">
|
||||||
|
<div class="stats-strip-inner">
|
||||||
|
<div class="strip-stat">
|
||||||
|
<span class="strip-value" id="count">0</span>
|
||||||
|
<span class="strip-label">COUNT</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<div class="strip-status">
|
||||||
|
<div class="status-dot active" id="statusDot"></div>
|
||||||
|
<span id="statusText">TRACKING</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Module Page
|
||||||
|
|
||||||
|
### 1. Create the Route
|
||||||
|
|
||||||
|
In `routes/mymodule.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
|
||||||
|
|
||||||
|
@mymodule_bp.route('/dashboard')
|
||||||
|
def dashboard():
|
||||||
|
return render_template('mymodule_dashboard.html',
|
||||||
|
offline_settings=get_offline_settings())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register the Blueprint
|
||||||
|
|
||||||
|
In `routes/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from routes.mymodule import mymodule_bp
|
||||||
|
app.register_blueprint(mymodule_bp)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create the Template
|
||||||
|
|
||||||
|
Option A: Simple page extending base.html
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block title %}My Module{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Your content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B: Full-screen dashboard
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY MODULE{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<!-- Your dashboard content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add to Navigation
|
||||||
|
|
||||||
|
In `templates/partials/nav.html`, add your module to the appropriate group:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
onclick="switchMode('mymodule')">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if it's a dashboard link:
|
||||||
|
```html
|
||||||
|
<a href="/mymodule/dashboard"
|
||||||
|
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Stylesheet
|
||||||
|
|
||||||
|
In `static/css/mymodule.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Module Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
/* Your styles using design tokens */
|
||||||
|
.mymodule-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Dashboard
|
||||||
|
|
||||||
|
For full-screen dashboards like ADSB, AIS, or Satellite:
|
||||||
|
|
||||||
|
### 1. Create the Template
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MY DASHBOARD // iNTERCEPT</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
|
||||||
|
<!-- Design tokens (required) -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- External libraries if needed -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Dashboard styles -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Background effects -->
|
||||||
|
<div class="radar-bg"></div>
|
||||||
|
<div class="scanline"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<a href="/" style="color: inherit; text-decoration: none;">
|
||||||
|
MY DASHBOARD
|
||||||
|
<span>// iNTERCEPT</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
||||||
|
<a href="/" class="back-link">Main Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Unified Navigation -->
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
|
||||||
|
<!-- Stats Strip -->
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard Content -->
|
||||||
|
<main class="dashboard">
|
||||||
|
<!-- Your dashboard layout -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dashboard JavaScript
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the Stylesheet
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Dashboard Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Dashboard-specific aliases */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
--bg-card: var(--bg-tertiary);
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your dashboard styles */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### DO
|
||||||
|
|
||||||
|
- Use design tokens for all colors, spacing, and typography
|
||||||
|
- Include the nav partial on all pages for consistent navigation
|
||||||
|
- Set `active_mode` before including the nav partial
|
||||||
|
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
|
||||||
|
- Support both light and dark themes
|
||||||
|
- Test on mobile viewports
|
||||||
|
|
||||||
|
### DON'T
|
||||||
|
|
||||||
|
- Hardcode color values - use CSS variables
|
||||||
|
- Create new color variations without adding to tokens
|
||||||
|
- Duplicate navigation markup - use the partial
|
||||||
|
- Skip the favicon and design tokens imports
|
||||||
|
- Use inline styles for layout (use utility classes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/
|
||||||
|
├── layout/
|
||||||
|
│ ├── base.html # Standard page base
|
||||||
|
│ └── base_dashboard.html # Dashboard page base
|
||||||
|
├── partials/
|
||||||
|
│ ├── nav.html # Unified navigation
|
||||||
|
│ ├── page_header.html # Page title component
|
||||||
|
│ └── settings-modal.html # Settings modal
|
||||||
|
├── components/
|
||||||
|
│ ├── card.html # Panel/card component
|
||||||
|
│ ├── empty_state.html # Empty state placeholder
|
||||||
|
│ ├── loading.html # Loading spinner
|
||||||
|
│ ├── stats_strip.html # Stats bar component
|
||||||
|
│ └── status_badge.html # Status indicator
|
||||||
|
├── index.html # Main dashboard
|
||||||
|
├── adsb_dashboard.html # Aircraft tracking
|
||||||
|
├── ais_dashboard.html # Vessel tracking
|
||||||
|
└── satellite_dashboard.html # Satellite tracking
|
||||||
|
|
||||||
|
static/css/
|
||||||
|
├── core/
|
||||||
|
│ ├── variables.css # Design tokens
|
||||||
|
│ ├── base.css # Reset & typography
|
||||||
|
│ ├── components.css # Component styles
|
||||||
|
│ └── layout.css # Layout styles
|
||||||
|
├── index.css # Main dashboard styles
|
||||||
|
├── adsb_dashboard.css # Aircraft dashboard
|
||||||
|
├── ais_dashboard.css # Vessel dashboard
|
||||||
|
├── satellite_dashboard.css # Satellite dashboard
|
||||||
|
└── responsive.css # Responsive breakpoints
|
||||||
|
```
|
||||||
@@ -65,6 +65,8 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- **Manual Entry** - Type coordinates directly
|
- **Manual Entry** - Type coordinates directly
|
||||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||||
|
- **Shared Location** - By default, the observer location is shared across modules
|
||||||
|
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
||||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||||
6. **Click Aircraft** - Click markers for detailed information
|
6. **Click Aircraft** - Click markers for detailed information
|
||||||
@@ -72,6 +74,9 @@ INTERCEPT automatically detects known trackers:
|
|||||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||||
|
|
||||||
|
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
|
||||||
|
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
||||||
|
|
||||||
### Emergency Squawks
|
### Emergency Squawks
|
||||||
|
|
||||||
The system highlights aircraft transmitting emergency squawks:
|
The system highlights aircraft transmitting emergency squawks:
|
||||||
@@ -79,6 +84,66 @@ The system highlights aircraft transmitting emergency squawks:
|
|||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
|
## ADS-B History (Optional)
|
||||||
|
|
||||||
|
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||||
|
|
||||||
|
### Enable History
|
||||||
|
|
||||||
|
Set the following environment variables (Docker recommended):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||||
|
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||||
|
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||||
|
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||||
|
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||||
|
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||||
|
|
||||||
|
### Other ADS-B Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||||
|
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||||
|
|
||||||
|
**Local install example**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker example (.env)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Setup
|
||||||
|
|
||||||
|
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile history up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the History Dashboard
|
||||||
|
|
||||||
|
1. Open **/adsb/history**
|
||||||
|
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||||
|
3. View aircraft history and timelines
|
||||||
|
4. Stop tracking when desired (session history is recorded)
|
||||||
|
|
||||||
## Satellite Mode
|
## Satellite Mode
|
||||||
|
|
||||||
1. **Set Location** - Choose location source:
|
1. **Set Location** - Choose location source:
|
||||||
@@ -98,6 +163,58 @@ The system highlights aircraft transmitting emergency squawks:
|
|||||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||||
4. Select satellites to add
|
4. Select satellites to add
|
||||||
|
|
||||||
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|
||||||
|
### Setting Up an Agent
|
||||||
|
|
||||||
|
1. **Install INTERCEPT** on the remote machine
|
||||||
|
2. **Create config file** (`intercept_agent.cfg`):
|
||||||
|
```ini
|
||||||
|
[agent]
|
||||||
|
name = sensor-node-1
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
api_key = your-secret-key
|
||||||
|
push_enabled = true
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
```
|
||||||
|
3. **Start the agent**:
|
||||||
|
```bash
|
||||||
|
python intercept_agent.py --config intercept_agent.cfg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Agents in the Controller
|
||||||
|
|
||||||
|
1. Navigate to `/controller/manage` in the main INTERCEPT instance
|
||||||
|
2. Enter agent details:
|
||||||
|
- **Name**: Must match config file (e.g., `sensor-node-1`)
|
||||||
|
- **Base URL**: Agent address (e.g., `http://192.168.1.50:8020`)
|
||||||
|
- **API Key**: Must match config file
|
||||||
|
3. Click "Register Agent"
|
||||||
|
4. Use "Test" to verify connectivity
|
||||||
|
|
||||||
|
### Using Remote Agents
|
||||||
|
|
||||||
|
Once registered, agents appear in mode dropdowns:
|
||||||
|
|
||||||
|
1. **Select agent** from the dropdown in supported modes
|
||||||
|
2. **Start mode** - Commands are proxied to the remote agent
|
||||||
|
3. **View data** - Data streams back to your browser via SSE
|
||||||
|
|
||||||
|
### Multi-Agent Streaming
|
||||||
|
|
||||||
|
Enable "Show All Agents" to aggregate data from all registered agents simultaneously.
|
||||||
|
|
||||||
|
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
INTERCEPT can be configured via environment variables:
|
INTERCEPT can be configured via environment variables:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
title: iNTERCEPT
|
||||||
|
description: Signal Intelligence Platform - A web-based interface for software-defined radio tools
|
||||||
|
url: https://smittix.github.io
|
||||||
|
baseurl: /intercept
|
||||||
|
|
||||||
|
# Build settings
|
||||||
|
include:
|
||||||
|
- _headers
|
||||||
|
|
||||||
|
# Exclude files from build
|
||||||
|
exclude:
|
||||||
|
- README.md
|
||||||
|
- SECURITY.md
|
||||||
|
- TROUBLESHOOTING.md
|
||||||
|
- USAGE.md
|
||||||
|
- FEATURES.md
|
||||||
|
- HARDWARE.md
|
||||||
|
- DISTRIBUTED_AGENTS.md
|
||||||
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 698 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 811 KiB |
@@ -0,0 +1,341 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>iNTERCEPT - Signal Intelligence Platform</title>
|
||||||
|
<meta name="description" content="A web-based interface for software-defined radio tools. Pager decoding, ADS-B tracking, WiFi scanning, and more.">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-container">
|
||||||
|
<a href="#" class="nav-logo">iNTERCEPT</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#screenshots">Screenshots</a>
|
||||||
|
<a href="#installation">Install</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
|
<a href="https://github.com/smittix/intercept" class="nav-btn" target="_blank">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-badge">Open Source SIGINT Platform</div>
|
||||||
|
<h1>iNTERCEPT</h1>
|
||||||
|
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<a href="#installation" class="btn btn-primary">Get Started</a>
|
||||||
|
<a href="https://github.com/smittix/intercept" class="btn btn-secondary" target="_blank">View on GitHub</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">15+</span>
|
||||||
|
<span class="stat-label">Modes</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">200+</span>
|
||||||
|
<span class="stat-label">Protocols</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">$25</span>
|
||||||
|
<span class="stat-label">Min Hardware</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-image">
|
||||||
|
<img src="images/dashboard.png" alt="iNTERCEPT Dashboard">
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="features" class="features">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Capabilities</h2>
|
||||||
|
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||||
|
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📟</div>
|
||||||
|
<h3>Pager Decoding</h3>
|
||||||
|
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">✈️</div>
|
||||||
|
<h3>Aircraft Tracking</h3>
|
||||||
|
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📡</div>
|
||||||
|
<h3>433MHz Sensors</h3>
|
||||||
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📻</div>
|
||||||
|
<h3>Listening Post</h3>
|
||||||
|
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🛰️</div>
|
||||||
|
<h3>Satellite Tracking</h3>
|
||||||
|
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📶</div>
|
||||||
|
<h3>WiFi Scanning</h3>
|
||||||
|
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔵</div>
|
||||||
|
<h3>Bluetooth Scanning</h3>
|
||||||
|
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🛡️</div>
|
||||||
|
<h3>TSCM</h3>
|
||||||
|
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Meter Reading</h3>
|
||||||
|
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🚢</div>
|
||||||
|
<h3>Vessel Tracking</h3>
|
||||||
|
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔢</div>
|
||||||
|
<h3>Spy Stations</h3>
|
||||||
|
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌐</div>
|
||||||
|
<h3>Remote Agents</h3>
|
||||||
|
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📴</div>
|
||||||
|
<h3>Offline Mode</h3>
|
||||||
|
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📡</div>
|
||||||
|
<h3>Meshtastic</h3>
|
||||||
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🖼️</div>
|
||||||
|
<h3>ISS SSTV</h3>
|
||||||
|
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screenshots" class="screenshots">
|
||||||
|
<div class="container">
|
||||||
|
<h2>See It In Action</h2>
|
||||||
|
<p class="section-subtitle">A clean, modern interface for complex RF operations</p>
|
||||||
|
|
||||||
|
<div class="screenshot-gallery">
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/dashboard.png" alt="Main Dashboard">
|
||||||
|
<span class="screenshot-label">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/tscm.png" alt="TSCM Counter-Surveillance">
|
||||||
|
<span class="screenshot-label">TSCM Counter-Surveillance</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/bluetooth.png" alt="Bluetooth Scanner">
|
||||||
|
<span class="screenshot-label">Bluetooth Scanner</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/wifi.png" alt="WiFi Scanner">
|
||||||
|
<span class="screenshot-label">WiFi Scanner</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/scanner.png" alt="Listening Post">
|
||||||
|
<span class="screenshot-label">Listening Post</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/sensors.png" alt="433MHz Sensor Monitor">
|
||||||
|
<span class="screenshot-label">433MHz Sensors</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/tscm-detail.png" alt="Device Detail Dialog">
|
||||||
|
<span class="screenshot-label">Device Analysis</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/remote-agents.png" alt="Remote Agents Management">
|
||||||
|
<span class="screenshot-label">Remote Agents</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||||
|
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="installation" class="installation">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Quick Start</h2>
|
||||||
|
<p class="section-subtitle">Get up and running in minutes</p>
|
||||||
|
|
||||||
|
<div class="platform-note">
|
||||||
|
<p><strong>Supported Platforms:</strong> Officially tested on Debian and Ubuntu. Partial support for macOS. Other distributions have not been fully tested.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="install-options">
|
||||||
|
<div class="install-card">
|
||||||
|
<h3>Standard Installation</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
|
cd intercept
|
||||||
|
./setup.sh
|
||||||
|
sudo -E venv/bin/python intercept.py</code></pre>
|
||||||
|
</div>
|
||||||
|
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="install-card">
|
||||||
|
<h3>Docker</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
|
cd intercept
|
||||||
|
docker compose up -d</code></pre>
|
||||||
|
</div>
|
||||||
|
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-install">
|
||||||
|
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
||||||
|
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hardware">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Hardware</h2>
|
||||||
|
<p class="section-subtitle">Minimal hardware, maximum capability</p>
|
||||||
|
|
||||||
|
<div class="hardware-grid">
|
||||||
|
<div class="hardware-card required">
|
||||||
|
<div class="hardware-tag">Required</div>
|
||||||
|
<h3>RTL-SDR</h3>
|
||||||
|
<p>Core SDR functionality for all radio features</p>
|
||||||
|
<span class="price">~$25-35</span>
|
||||||
|
</div>
|
||||||
|
<div class="hardware-card optional">
|
||||||
|
<div class="hardware-tag">Optional</div>
|
||||||
|
<h3>WiFi Adapter</h3>
|
||||||
|
<p>Monitor mode support for WiFi scanning</p>
|
||||||
|
<span class="price">~$20-40</span>
|
||||||
|
</div>
|
||||||
|
<div class="hardware-card optional">
|
||||||
|
<div class="hardware-tag">Optional</div>
|
||||||
|
<h3>GPS Receiver</h3>
|
||||||
|
<p>Real-time location for mapping features</p>
|
||||||
|
<span class="price">~$10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hardware-note">iNTERCEPT also supports HackRF, LimeSDR, Airspy, and SDRplay via SoapySDR</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Ready to start intercepting?</h2>
|
||||||
|
<p>Join the community and start exploring the RF spectrum</p>
|
||||||
|
<div class="cta-buttons">
|
||||||
|
<a href="https://github.com/smittix/intercept" class="btn btn-primary" target="_blank">Get iNTERCEPT</a>
|
||||||
|
<a href="https://discord.gg/EyeksEJmWE" class="btn btn-secondary" target="_blank">Join Discord</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-content">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<span class="footer-logo">iNTERCEPT</span>
|
||||||
|
<p>Signal Intelligence Platform</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||||
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
||||||
|
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Lightbox Modal -->
|
||||||
|
<div id="lightbox" class="lightbox">
|
||||||
|
<span class="lightbox-close">×</span>
|
||||||
|
<img class="lightbox-img" id="lightbox-img" src="" alt="">
|
||||||
|
<div class="lightbox-caption" id="lightbox-caption"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Lightbox functionality
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
const lightboxImg = document.getElementById('lightbox-img');
|
||||||
|
const lightboxCaption = document.getElementById('lightbox-caption');
|
||||||
|
const closeBtn = document.querySelector('.lightbox-close');
|
||||||
|
|
||||||
|
document.querySelectorAll('.screenshot-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const img = item.querySelector('img');
|
||||||
|
const label = item.querySelector('.screenshot-label');
|
||||||
|
lightbox.classList.add('active');
|
||||||
|
lightboxImg.src = img.src;
|
||||||
|
lightboxCaption.textContent = label.textContent;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
lightbox.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeLightbox);
|
||||||
|
lightbox.addEventListener('click', (e) => {
|
||||||
|
if (e.target === lightbox) closeLightbox();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeLightbox();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
/* INTERCEPT GitHub Pages - Dark Theme */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--bg-card-hover: #22222e;
|
||||||
|
--text-primary: #f0f0f5;
|
||||||
|
--text-secondary: #8888a0;
|
||||||
|
--text-muted: #5c5c70;
|
||||||
|
--accent: #00d4aa;
|
||||||
|
--accent-hover: #00f0c0;
|
||||||
|
--accent-glow: rgba(0, 212, 170, 0.2);
|
||||||
|
--border: #2a2a38;
|
||||||
|
--code-bg: #0d0d14;
|
||||||
|
--gradient-start: #00d4aa;
|
||||||
|
--gradient-end: #0088ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.navbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(10, 10, 15, 0.9);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary) !important;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 60px;
|
||||||
|
padding: 120px 24px 80px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-glow);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 4.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
max-width: 500px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
section {
|
||||||
|
padding: 100px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section h2 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features */
|
||||||
|
.features {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screenshots */
|
||||||
|
.screenshot-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-item {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 212, 170, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox */
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 30px;
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-img {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Installation */
|
||||||
|
.installation {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-card h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-note {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-note p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-note strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install code {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hardware */
|
||||||
|
.hardware-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card.required .hardware-tag {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card.optional .hardware-tag {
|
||||||
|
background: rgba(136, 136, 160, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card .price {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-note {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA */
|
||||||
|
.cta {
|
||||||
|
background: linear-gradient(135deg, rgba(0, 212, 170, 0.1), rgba(0, 136, 255, 0.1));
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 60px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
order: -1;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-gallery {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-gallery {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# INTERCEPT AGENT CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# This file configures the Intercept remote agent.
|
||||||
|
# Copy this file and customize for your deployment.
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
# Agent name (used to identify this node in the controller)
|
||||||
|
# Default: system hostname
|
||||||
|
name = sensor-node-1
|
||||||
|
|
||||||
|
# HTTP server port
|
||||||
|
# Default: 8020
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
# Comma-separated list of allowed client IPs (empty = allow all)
|
||||||
|
# Example: 192.168.1.100, 192.168.1.101, 10.0.0.0/8
|
||||||
|
allowed_ips =
|
||||||
|
|
||||||
|
# Enable CORS headers for browser-based clients
|
||||||
|
# Default: false
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
# Controller URL for push mode
|
||||||
|
# Example: http://192.168.1.100:5050
|
||||||
|
url =
|
||||||
|
|
||||||
|
# API key for controller authentication (shared secret)
|
||||||
|
api_key =
|
||||||
|
|
||||||
|
# Enable automatic push of scan data to controller
|
||||||
|
# Default: false
|
||||||
|
push_enabled = false
|
||||||
|
|
||||||
|
# Push interval in seconds (minimum time between pushes)
|
||||||
|
# Default: 5
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
# Enable/disable specific modes on this agent
|
||||||
|
# Set to false to disable a mode even if tools are available
|
||||||
|
# Default: all true
|
||||||
|
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
ais = true
|
||||||
|
acars = true
|
||||||
|
aprs = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
dsc = true
|
||||||
|
rtlamr = true
|
||||||
|
tscm = true
|
||||||
|
satellite = true
|
||||||
|
listening_post = true
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.9.5"
|
version = "2.13.1"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -26,9 +26,14 @@ classifiers = [
|
|||||||
"Topic :: System :: Networking :: Monitoring",
|
"Topic :: System :: Networking :: Monitoring",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=2.0.0",
|
"flask>=3.0.0",
|
||||||
"skyfield>=1.45",
|
"skyfield>=1.45",
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
|
"Werkzeug>=3.1.5",
|
||||||
|
"flask-limiter>=2.5.4",
|
||||||
|
"bleak>=0.21.0",
|
||||||
|
"flask-sock",
|
||||||
|
"requests>=2.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
flask>=2.0.0
|
flask>=3.0.0
|
||||||
|
flask-limiter>=2.5.4
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
|
Werkzeug>=3.1.5
|
||||||
|
|
||||||
|
# ADS-B history (optional - only needed for Postgres persistence)
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
|
||||||
# BLE scanning with manufacturer data detection (optional - for TSCM)
|
# BLE scanning with manufacturer data detection (optional - for TSCM)
|
||||||
bleak>=0.21.0
|
bleak>=0.21.0
|
||||||
@@ -8,9 +13,22 @@ bleak>=0.21.0
|
|||||||
# Satellite tracking (optional - only needed for satellite features)
|
# Satellite tracking (optional - only needed for satellite features)
|
||||||
skyfield>=1.45
|
skyfield>=1.45
|
||||||
|
|
||||||
|
# DSC decoding (optional - only needed for VHF DSC maritime distress)
|
||||||
|
scipy>=1.10.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
|
||||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
|
||||||
|
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||||
|
meshtastic>=2.0.0
|
||||||
|
|
||||||
|
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||||
|
scapy>=2.4.5
|
||||||
|
|
||||||
|
# QR code generation for Meshtastic channels (optional)
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
|
||||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||||
# pytest>=7.0.0
|
# pytest>=7.0.0
|
||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ def register_blueprints(app):
|
|||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
from .pager import pager_bp
|
from .pager import pager_bp
|
||||||
from .sensor import sensor_bp
|
from .sensor import sensor_bp
|
||||||
|
from .rtlamr import rtlamr_bp
|
||||||
from .wifi import wifi_bp
|
from .wifi import wifi_bp
|
||||||
|
from .wifi_v2 import wifi_v2_bp
|
||||||
from .bluetooth import bluetooth_bp
|
from .bluetooth import bluetooth_bp
|
||||||
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
|
from .ais import ais_bp
|
||||||
|
from .dsc import dsc_bp
|
||||||
from .acars import acars_bp
|
from .acars import acars_bp
|
||||||
from .aprs import aprs_bp
|
from .aprs import aprs_bp
|
||||||
from .satellite import satellite_bp
|
from .satellite import satellite_bp
|
||||||
@@ -14,13 +19,24 @@ def register_blueprints(app):
|
|||||||
from .settings import settings_bp
|
from .settings import settings_bp
|
||||||
from .correlation import correlation_bp
|
from .correlation import correlation_bp
|
||||||
from .listening_post import listening_post_bp
|
from .listening_post import listening_post_bp
|
||||||
|
from .meshtastic import meshtastic_bp
|
||||||
from .tscm import tscm_bp, init_tscm_state
|
from .tscm import tscm_bp, init_tscm_state
|
||||||
|
from .spy_stations import spy_stations_bp
|
||||||
|
from .controller import controller_bp
|
||||||
|
from .offline import offline_bp
|
||||||
|
from .updater import updater_bp
|
||||||
|
from .sstv import sstv_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
|
app.register_blueprint(rtlamr_bp)
|
||||||
app.register_blueprint(wifi_bp)
|
app.register_blueprint(wifi_bp)
|
||||||
|
app.register_blueprint(wifi_v2_bp) # New unified WiFi API
|
||||||
app.register_blueprint(bluetooth_bp)
|
app.register_blueprint(bluetooth_bp)
|
||||||
|
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
|
||||||
app.register_blueprint(adsb_bp)
|
app.register_blueprint(adsb_bp)
|
||||||
|
app.register_blueprint(ais_bp)
|
||||||
|
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||||
app.register_blueprint(acars_bp)
|
app.register_blueprint(acars_bp)
|
||||||
app.register_blueprint(aprs_bp)
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
@@ -28,7 +44,13 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(listening_post_bp)
|
||||||
|
app.register_blueprint(meshtastic_bp)
|
||||||
app.register_blueprint(tscm_bp)
|
app.register_blueprint(tscm_bp)
|
||||||
|
app.register_blueprint(spy_stations_bp)
|
||||||
|
app.register_blueprint(controller_bp) # Remote agent controller
|
||||||
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
|
app.register_blueprint(updater_bp) # GitHub update checking
|
||||||
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -43,12 +43,58 @@ DEFAULT_ACARS_FREQUENCIES = [
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
acars_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def find_acarsdec():
|
def find_acarsdec():
|
||||||
"""Find acarsdec binary."""
|
"""Find acarsdec binary."""
|
||||||
return shutil.which('acarsdec')
|
return shutil.which('acarsdec')
|
||||||
|
|
||||||
|
|
||||||
|
def get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
||||||
|
"""Detect which JSON output flag acarsdec supports.
|
||||||
|
|
||||||
|
Different forks use different flags:
|
||||||
|
- TLeconte v4.0+: uses -j for JSON stdout
|
||||||
|
- TLeconte v3.x: uses -o 4 for JSON stdout
|
||||||
|
- f00b4r0 fork (DragonOS): uses --output json:file:- for JSON stdout
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get help/version by running acarsdec with no args (shows usage)
|
||||||
|
result = subprocess.run(
|
||||||
|
[acarsdec_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Check for f00b4r0 fork signature: uses --output instead of -j/-o
|
||||||
|
# f00b4r0's help shows "--output" for output configuration
|
||||||
|
if '--output' in output or 'json:file:' in output.lower():
|
||||||
|
logger.debug("Detected f00b4r0 acarsdec fork (--output syntax)")
|
||||||
|
return '--output'
|
||||||
|
|
||||||
|
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
|
||||||
|
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
|
||||||
|
if version_match:
|
||||||
|
major = int(version_match.group(1))
|
||||||
|
# Version 4.0+ uses -j for JSON stdout
|
||||||
|
if major >= 4:
|
||||||
|
return '-j'
|
||||||
|
# Version 3.x uses -o for output mode
|
||||||
|
else:
|
||||||
|
return '-o'
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not detect acarsdec version: {e}")
|
||||||
|
|
||||||
|
# Default to -j (TLeconte modern standard)
|
||||||
|
return '-j'
|
||||||
|
|
||||||
|
|
||||||
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||||
"""Stream acarsdec JSON output to queue."""
|
"""Stream acarsdec JSON output to queue."""
|
||||||
global acars_message_count, acars_last_message_time
|
global acars_message_count, acars_last_message_time
|
||||||
@@ -132,7 +178,7 @@ def acars_status() -> Response:
|
|||||||
@acars_bp.route('/start', methods=['POST'])
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
def start_acars() -> Response:
|
def start_acars() -> Response:
|
||||||
"""Start ACARS decoder."""
|
"""Start ACARS decoder."""
|
||||||
global acars_message_count, acars_last_message_time
|
global acars_message_count, acars_last_message_time, acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
@@ -159,6 +205,18 @@ def start_acars() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
acars_active_device = device_int
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
if isinstance(frequencies, str):
|
if isinstance(frequencies, str):
|
||||||
@@ -176,12 +234,20 @@ def start_acars() -> Response:
|
|||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
# Build acarsdec command
|
# Build acarsdec command
|
||||||
# acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# Different forks have different syntax:
|
||||||
# Note: -o 4 is JSON stdout, gain/ppm must come BEFORE -r
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
cmd = [
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
acarsdec_path,
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
'-o', '4', # JSON output to stdout
|
# Note: gain/ppm must come BEFORE -r
|
||||||
]
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
|
cmd = [acarsdec_path]
|
||||||
|
if json_flag == '--output':
|
||||||
|
# f00b4r0 fork: --output json:file (no path = stdout)
|
||||||
|
cmd.extend(['--output', 'json:file'])
|
||||||
|
elif json_flag == '-j':
|
||||||
|
cmd.append('-j') # JSON output (TLeconte v4+)
|
||||||
|
else:
|
||||||
|
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||||
|
|
||||||
# Add gain if not auto (must be before -r)
|
# Add gain if not auto (must be before -r)
|
||||||
if gain and str(gain) != '0':
|
if gain and str(gain) != '0':
|
||||||
@@ -191,8 +257,14 @@ def start_acars() -> Response:
|
|||||||
if ppm and str(ppm) != '0':
|
if ppm and str(ppm) != '0':
|
||||||
cmd.extend(['-p', str(ppm)])
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Add device and frequencies (-r takes device, remaining args are frequencies)
|
# Add device and frequencies
|
||||||
cmd.extend(['-r', str(device)])
|
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
||||||
|
if json_flag == '--output':
|
||||||
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
|
cmd.extend(['-m', '256'])
|
||||||
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
|
else:
|
||||||
|
cmd.extend(['-r', str(device)])
|
||||||
cmd.extend(frequencies)
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
||||||
@@ -225,7 +297,10 @@ def start_acars() -> Response:
|
|||||||
time.sleep(PROCESS_START_WAIT)
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died
|
# Process died - release device
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
@@ -253,6 +328,10 @@ def start_acars() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -260,6 +339,8 @@ def start_acars() -> Response:
|
|||||||
@acars_bp.route('/stop', methods=['POST'])
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
def stop_acars() -> Response:
|
def stop_acars() -> Response:
|
||||||
"""Stop ACARS decoder."""
|
"""Stop ACARS decoder."""
|
||||||
|
global acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if not app_module.acars_process:
|
if not app_module.acars_process:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -277,6 +358,11 @@ def stop_acars() -> Response:
|
|||||||
|
|
||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,33 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, render_template
|
from flask import Blueprint, jsonify, request, Response, render_template
|
||||||
|
from flask import make_response
|
||||||
|
|
||||||
|
# psycopg2 is optional - only needed for PostgreSQL history persistence
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
PSYCOPG2_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
psycopg2 = None # type: ignore
|
||||||
|
RealDictCursor = None # type: ignore
|
||||||
|
PSYCOPG2_AVAILABLE = False
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
from config import (
|
||||||
|
ADSB_DB_HOST,
|
||||||
|
ADSB_DB_NAME,
|
||||||
|
ADSB_DB_PASSWORD,
|
||||||
|
ADSB_DB_PORT,
|
||||||
|
ADSB_DB_USER,
|
||||||
|
ADSB_AUTO_START,
|
||||||
|
ADSB_HISTORY_ENABLED,
|
||||||
|
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
from utils.logging import adsb_logger as logger
|
from utils.logging import adsb_logger as logger
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index, validate_gain,
|
validate_device_index, validate_gain,
|
||||||
@@ -36,6 +58,7 @@ from utils.constants import (
|
|||||||
DUMP1090_START_WAIT,
|
DUMP1090_START_WAIT,
|
||||||
)
|
)
|
||||||
from utils import aircraft_db
|
from utils import aircraft_db
|
||||||
|
from utils.adsb_history import adsb_history_writer, adsb_snapshot_writer, _ensure_adsb_schema
|
||||||
|
|
||||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||||
|
|
||||||
@@ -46,6 +69,8 @@ adsb_messages_received = 0
|
|||||||
adsb_last_message_time = None
|
adsb_last_message_time = None
|
||||||
adsb_bytes_received = 0
|
adsb_bytes_received = 0
|
||||||
adsb_lines_received = 0
|
adsb_lines_received = 0
|
||||||
|
adsb_active_device = None # Track which device index is being used
|
||||||
|
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||||
|
|
||||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||||
_looked_up_icaos: set[str] = set()
|
_looked_up_icaos: set[str] = set()
|
||||||
@@ -70,6 +95,200 @@ DUMP1090_PATHS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_part(parts: list[str], index: int) -> str | None:
|
||||||
|
if len(parts) <= index:
|
||||||
|
return None
|
||||||
|
value = parts[index].strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sbs_timestamp(date_str: str | None, time_str: str | None) -> datetime | None:
|
||||||
|
if not date_str or not time_str:
|
||||||
|
return None
|
||||||
|
combined = f"{date_str} {time_str}"
|
||||||
|
for fmt in ("%Y/%m/%d %H:%M:%S.%f", "%Y/%m/%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(combined, fmt)
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value: str | None) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(value: str | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_history_record(
|
||||||
|
parts: list[str],
|
||||||
|
msg_type: str,
|
||||||
|
icao: str,
|
||||||
|
msg_time: datetime | None,
|
||||||
|
logged_time: datetime | None,
|
||||||
|
service_addr: str,
|
||||||
|
raw_line: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'received_at': datetime.now(timezone.utc),
|
||||||
|
'msg_time': msg_time,
|
||||||
|
'logged_time': logged_time,
|
||||||
|
'icao': icao,
|
||||||
|
'msg_type': _parse_int(msg_type),
|
||||||
|
'callsign': _get_part(parts, 10),
|
||||||
|
'altitude': _parse_int(_get_part(parts, 11)),
|
||||||
|
'speed': _parse_int(_get_part(parts, 12)),
|
||||||
|
'heading': _parse_int(_get_part(parts, 13)),
|
||||||
|
'vertical_rate': _parse_int(_get_part(parts, 16)),
|
||||||
|
'lat': _parse_float(_get_part(parts, 14)),
|
||||||
|
'lon': _parse_float(_get_part(parts, 15)),
|
||||||
|
'squawk': _get_part(parts, 17),
|
||||||
|
'session_id': _get_part(parts, 2),
|
||||||
|
'aircraft_id': _get_part(parts, 3),
|
||||||
|
'flight_id': _get_part(parts, 5),
|
||||||
|
'raw_line': raw_line,
|
||||||
|
'source_host': service_addr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_history_schema_checked = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_history_connection():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=ADSB_DB_HOST,
|
||||||
|
port=ADSB_DB_PORT,
|
||||||
|
dbname=ADSB_DB_NAME,
|
||||||
|
user=ADSB_DB_USER,
|
||||||
|
password=ADSB_DB_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_history_schema() -> None:
|
||||||
|
global _history_schema_checked
|
||||||
|
if _history_schema_checked:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
_ensure_adsb_schema(conn)
|
||||||
|
_history_schema_checked = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B schema check failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value) if value is not None else default
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = default
|
||||||
|
if min_value is not None:
|
||||||
|
parsed = max(min_value, parsed)
|
||||||
|
if max_value is not None:
|
||||||
|
parsed = min(max_value, parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_session() -> dict[str, Any] | None:
|
||||||
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
|
return None
|
||||||
|
_ensure_history_schema()
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM adsb_sessions
|
||||||
|
WHERE ended_at IS NULL
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B session lookup failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_session_start(
|
||||||
|
*,
|
||||||
|
device_index: int | None,
|
||||||
|
sdr_type: str | None,
|
||||||
|
remote_host: str | None,
|
||||||
|
remote_port: int | None,
|
||||||
|
start_source: str | None,
|
||||||
|
started_by: str | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
|
return None
|
||||||
|
_ensure_history_schema()
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO adsb_sessions (
|
||||||
|
device_index,
|
||||||
|
sdr_type,
|
||||||
|
remote_host,
|
||||||
|
remote_port,
|
||||||
|
start_source,
|
||||||
|
started_by
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
device_index,
|
||||||
|
sdr_type,
|
||||||
|
remote_host,
|
||||||
|
remote_port,
|
||||||
|
start_source,
|
||||||
|
started_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B session start record failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) -> dict[str, Any] | None:
|
||||||
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
|
return None
|
||||||
|
_ensure_history_schema()
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE adsb_sessions
|
||||||
|
SET ended_at = NOW(),
|
||||||
|
stop_source = COALESCE(%s, stop_source),
|
||||||
|
stopped_by = COALESCE(%s, stopped_by)
|
||||||
|
WHERE ended_at IS NULL
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(stop_source, stopped_by),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B session stop record failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
def find_dump1090():
|
def find_dump1090():
|
||||||
"""Find dump1090 binary, checking PATH and common locations."""
|
"""Find dump1090 binary, checking PATH and common locations."""
|
||||||
# First try PATH
|
# First try PATH
|
||||||
@@ -100,7 +319,10 @@ def check_dump1090_service():
|
|||||||
|
|
||||||
def parse_sbs_stream(service_addr):
|
def parse_sbs_stream(service_addr):
|
||||||
"""Parse SBS format data from dump1090 SBS port."""
|
"""Parse SBS format data from dump1090 SBS port."""
|
||||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
|
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
||||||
|
|
||||||
|
adsb_history_writer.start()
|
||||||
|
adsb_snapshot_writer.start()
|
||||||
|
|
||||||
host, port = service_addr.split(':')
|
host, port = service_addr.split(':')
|
||||||
port = int(port)
|
port = int(port)
|
||||||
@@ -108,6 +330,7 @@ def parse_sbs_stream(service_addr):
|
|||||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
adsb_messages_received = 0
|
adsb_messages_received = 0
|
||||||
|
_sbs_error_logged = False
|
||||||
|
|
||||||
while adsb_using_service:
|
while adsb_using_service:
|
||||||
try:
|
try:
|
||||||
@@ -115,6 +338,7 @@ def parse_sbs_stream(service_addr):
|
|||||||
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||||
sock.connect((host, port))
|
sock.connect((host, port))
|
||||||
adsb_connected = True
|
adsb_connected = True
|
||||||
|
_sbs_error_logged = False # Reset so we log next error
|
||||||
logger.info("Connected to SBS stream")
|
logger.info("Connected to SBS stream")
|
||||||
|
|
||||||
buffer = ""
|
buffer = ""
|
||||||
@@ -154,6 +378,19 @@ def parse_sbs_stream(service_addr):
|
|||||||
if not icao:
|
if not icao:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
msg_time = _parse_sbs_timestamp(_get_part(parts, 6), _get_part(parts, 7))
|
||||||
|
logged_time = _parse_sbs_timestamp(_get_part(parts, 8), _get_part(parts, 9))
|
||||||
|
history_record = _build_history_record(
|
||||||
|
parts=parts,
|
||||||
|
msg_type=msg_type,
|
||||||
|
icao=icao,
|
||||||
|
msg_time=msg_time,
|
||||||
|
logged_time=logged_time,
|
||||||
|
service_addr=service_addr,
|
||||||
|
raw_line=line,
|
||||||
|
)
|
||||||
|
adsb_history_writer.enqueue(history_record)
|
||||||
|
|
||||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||||
|
|
||||||
# Look up aircraft type from database (once per ICAO)
|
# Look up aircraft type from database (once per ICAO)
|
||||||
@@ -227,9 +464,27 @@ def parse_sbs_stream(service_addr):
|
|||||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||||
for update_icao in pending_updates:
|
for update_icao in pending_updates:
|
||||||
if update_icao in app_module.adsb_aircraft:
|
if update_icao in app_module.adsb_aircraft:
|
||||||
|
snapshot = app_module.adsb_aircraft[update_icao]
|
||||||
app_module.adsb_queue.put({
|
app_module.adsb_queue.put({
|
||||||
'type': 'aircraft',
|
'type': 'aircraft',
|
||||||
**app_module.adsb_aircraft[update_icao]
|
**snapshot
|
||||||
|
})
|
||||||
|
adsb_snapshot_writer.enqueue({
|
||||||
|
'captured_at': datetime.now(timezone.utc),
|
||||||
|
'icao': update_icao,
|
||||||
|
'callsign': snapshot.get('callsign'),
|
||||||
|
'registration': snapshot.get('registration'),
|
||||||
|
'type_code': snapshot.get('type_code'),
|
||||||
|
'type_desc': snapshot.get('type_desc'),
|
||||||
|
'altitude': snapshot.get('altitude'),
|
||||||
|
'speed': snapshot.get('speed'),
|
||||||
|
'heading': snapshot.get('heading'),
|
||||||
|
'vertical_rate': snapshot.get('vertical_rate'),
|
||||||
|
'lat': snapshot.get('lat'),
|
||||||
|
'lon': snapshot.get('lon'),
|
||||||
|
'squawk': snapshot.get('squawk'),
|
||||||
|
'source_host': service_addr,
|
||||||
|
'snapshot': snapshot,
|
||||||
})
|
})
|
||||||
pending_updates.clear()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
@@ -241,7 +496,9 @@ def parse_sbs_stream(service_addr):
|
|||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
if not _sbs_error_logged:
|
||||||
|
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||||
|
_sbs_error_logged = True
|
||||||
time.sleep(SBS_RECONNECT_DELAY)
|
time.sleep(SBS_RECONNECT_DELAY)
|
||||||
|
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
@@ -286,6 +543,7 @@ def adsb_status():
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'tracking_active': adsb_using_service,
|
'tracking_active': adsb_using_service,
|
||||||
|
'active_device': adsb_active_device,
|
||||||
'connected_to_sbs': adsb_connected,
|
'connected_to_sbs': adsb_connected,
|
||||||
'messages_received': adsb_messages_received,
|
'messages_received': adsb_messages_received,
|
||||||
'bytes_received': adsb_bytes_received,
|
'bytes_received': adsb_bytes_received,
|
||||||
@@ -300,16 +558,41 @@ def adsb_status():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/session')
|
||||||
|
def adsb_session():
|
||||||
|
"""Get ADS-B session status and uptime."""
|
||||||
|
session = _get_active_session()
|
||||||
|
uptime_seconds = None
|
||||||
|
if session and session.get('started_at'):
|
||||||
|
started_at = session['started_at']
|
||||||
|
if isinstance(started_at, datetime):
|
||||||
|
uptime_seconds = int((datetime.now(timezone.utc) - started_at).total_seconds())
|
||||||
|
return jsonify({
|
||||||
|
'tracking_active': adsb_using_service,
|
||||||
|
'connected_to_sbs': adsb_connected,
|
||||||
|
'active_device': adsb_active_device,
|
||||||
|
'session': session,
|
||||||
|
'uptime_seconds': uptime_seconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/start', methods=['POST'])
|
@adsb_bp.route('/start', methods=['POST'])
|
||||||
def start_adsb():
|
def start_adsb():
|
||||||
"""Start ADS-B tracking."""
|
"""Start ADS-B tracking."""
|
||||||
global adsb_using_service
|
global adsb_using_service, adsb_active_device
|
||||||
|
|
||||||
with app_module.adsb_lock:
|
with app_module.adsb_lock:
|
||||||
if adsb_using_service:
|
if adsb_using_service:
|
||||||
return jsonify({'status': 'already_running', 'message': 'ADS-B tracking already active'}), 409
|
session = _get_active_session()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'message': 'ADS-B tracking already active',
|
||||||
|
'session': session
|
||||||
|
}), 409
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
start_source = data.get('source')
|
||||||
|
started_by = request.remote_addr
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
try:
|
try:
|
||||||
@@ -335,7 +618,19 @@ def start_adsb():
|
|||||||
adsb_using_service = True
|
adsb_using_service = True
|
||||||
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
return jsonify({'status': 'started', 'message': f'Connected to remote dump1090 at {remote_addr}'})
|
session = _record_session_start(
|
||||||
|
device_index=device,
|
||||||
|
sdr_type='remote',
|
||||||
|
remote_host=remote_sbs_host,
|
||||||
|
remote_port=remote_sbs_port,
|
||||||
|
start_source=start_source,
|
||||||
|
started_by=started_by,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': f'Connected to remote dump1090 at {remote_addr}',
|
||||||
|
'session': session
|
||||||
|
})
|
||||||
|
|
||||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||||
existing_service = check_dump1090_service()
|
existing_service = check_dump1090_service()
|
||||||
@@ -344,7 +639,19 @@ def start_adsb():
|
|||||||
adsb_using_service = True
|
adsb_using_service = True
|
||||||
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
return jsonify({'status': 'started', 'message': 'Connected to existing dump1090 service'})
|
session = _record_session_start(
|
||||||
|
device_index=device,
|
||||||
|
sdr_type='external',
|
||||||
|
remote_host='localhost',
|
||||||
|
remote_port=ADSB_SBS_PORT,
|
||||||
|
start_source=start_source,
|
||||||
|
started_by=started_by,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': 'Connected to existing dump1090 service',
|
||||||
|
'session': session
|
||||||
|
})
|
||||||
|
|
||||||
# Get SDR type from request
|
# Get SDR type from request
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
@@ -364,17 +671,30 @@ def start_adsb():
|
|||||||
if not dump1090_path:
|
if not dump1090_path:
|
||||||
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'})
|
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'})
|
||||||
|
|
||||||
# Kill any stale app-started process
|
# Kill any stale app-started process (use process group to ensure full cleanup)
|
||||||
if app_module.adsb_process:
|
if app_module.adsb_process:
|
||||||
try:
|
try:
|
||||||
app_module.adsb_process.terminate()
|
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||||
|
os.killpg(pgid, 15) # SIGTERM
|
||||||
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||||
try:
|
try:
|
||||||
app_module.adsb_process.kill()
|
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||||
except OSError:
|
os.killpg(pgid, 9) # SIGKILL
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
logger.info("Killed stale ADS-B process")
|
||||||
|
|
||||||
|
# Check if device is available before starting local dump1090
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'adsb')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Create device object and build command via abstraction layer
|
# Create device object and build command via abstraction layer
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
@@ -393,60 +713,124 @@ def start_adsb():
|
|||||||
cmd[0] = dump1090_path
|
cmd[0] = dump1090_path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
||||||
app_module.adsb_process = subprocess.Popen(
|
app_module.adsb_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True # Create new process group for clean shutdown
|
||||||
)
|
)
|
||||||
|
|
||||||
time.sleep(DUMP1090_START_WAIT)
|
time.sleep(DUMP1090_START_WAIT)
|
||||||
|
|
||||||
if app_module.adsb_process.poll() is not None:
|
if app_module.adsb_process.poll() is not None:
|
||||||
# Process exited - try to get error message
|
# Process exited - release device and get error message
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.adsb_process.stderr:
|
if app_module.adsb_process.stderr:
|
||||||
try:
|
try:
|
||||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
|
||||||
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
|
# Parse stderr to provide specific guidance
|
||||||
if stderr_output:
|
error_type = 'START_FAILED'
|
||||||
error_msg += f' Error: {stderr_output[:200]}'
|
stderr_lower = stderr_output.lower()
|
||||||
return jsonify({'status': 'error', 'message': error_msg})
|
|
||||||
|
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
|
||||||
|
error_msg = 'SDR device is busy. Another process may be using it.'
|
||||||
|
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
|
||||||
|
error_type = 'DEVICE_BUSY'
|
||||||
|
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
|
||||||
|
error_msg = 'RTL-SDR device not found.'
|
||||||
|
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
|
||||||
|
error_type = 'DEVICE_NOT_FOUND'
|
||||||
|
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
|
||||||
|
error_msg = 'Kernel DVB-T driver is blocking the device.'
|
||||||
|
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
|
||||||
|
error_type = 'KERNEL_DRIVER'
|
||||||
|
elif 'permission' in stderr_lower or 'access' in stderr_lower:
|
||||||
|
error_msg = 'Permission denied accessing RTL-SDR device.'
|
||||||
|
suggestion = 'Run Intercept with sudo, or add udev rules for RTL-SDR devices.'
|
||||||
|
error_type = 'PERMISSION_DENIED'
|
||||||
|
elif sdr_type == SDRType.RTL_SDR:
|
||||||
|
error_msg = 'dump1090 failed to start.'
|
||||||
|
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
|
||||||
else:
|
else:
|
||||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
|
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.'
|
||||||
if stderr_output:
|
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||||
error_msg += f' Error: {stderr_output[:200]}'
|
|
||||||
return jsonify({'status': 'error', 'message': error_msg})
|
full_msg = f'{error_msg} {suggestion}'
|
||||||
|
if stderr_output and len(stderr_output) < 300:
|
||||||
|
full_msg += f' (Details: {stderr_output})'
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': error_type,
|
||||||
|
'message': full_msg
|
||||||
|
})
|
||||||
|
|
||||||
adsb_using_service = True
|
adsb_using_service = True
|
||||||
|
adsb_active_device = device # Track which device is being used
|
||||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
session = _record_session_start(
|
||||||
|
device_index=device,
|
||||||
|
sdr_type=sdr_type.value,
|
||||||
|
remote_host=None,
|
||||||
|
remote_port=None,
|
||||||
|
start_source=start_source,
|
||||||
|
started_by=started_by,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': 'ADS-B tracking started',
|
||||||
|
'device': device,
|
||||||
|
'session': session
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/stop', methods=['POST'])
|
@adsb_bp.route('/stop', methods=['POST'])
|
||||||
def stop_adsb():
|
def stop_adsb():
|
||||||
"""Stop ADS-B tracking."""
|
"""Stop ADS-B tracking."""
|
||||||
global adsb_using_service
|
global adsb_using_service, adsb_active_device
|
||||||
|
data = request.json or {}
|
||||||
|
stop_source = data.get('source')
|
||||||
|
stopped_by = request.remote_addr
|
||||||
|
|
||||||
with app_module.adsb_lock:
|
with app_module.adsb_lock:
|
||||||
if app_module.adsb_process:
|
if app_module.adsb_process:
|
||||||
app_module.adsb_process.terminate()
|
|
||||||
try:
|
try:
|
||||||
|
# Kill the entire process group to ensure all child processes are terminated
|
||||||
|
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||||
|
os.killpg(pgid, 15) # SIGTERM
|
||||||
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
|
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
|
||||||
except subprocess.TimeoutExpired:
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||||
app_module.adsb_process.kill()
|
try:
|
||||||
|
# Force kill if terminate didn't work
|
||||||
|
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||||
|
os.killpg(pgid, 9) # SIGKILL
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
logger.info("ADS-B process stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if adsb_active_device is not None:
|
||||||
|
app_module.release_sdr_device(adsb_active_device)
|
||||||
|
|
||||||
adsb_using_service = False
|
adsb_using_service = False
|
||||||
|
adsb_active_device = None
|
||||||
|
|
||||||
app_module.adsb_aircraft.clear()
|
app_module.adsb_aircraft.clear()
|
||||||
_looked_up_icaos.clear()
|
_looked_up_icaos.clear()
|
||||||
return jsonify({'status': 'stopped'})
|
session = _record_session_stop(stop_source=stop_source, stopped_by=stopped_by)
|
||||||
|
return jsonify({'status': 'stopped', 'session': session})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/stream')
|
@adsb_bp.route('/stream')
|
||||||
@@ -475,7 +859,167 @@ def stream_adsb():
|
|||||||
@adsb_bp.route('/dashboard')
|
@adsb_bp.route('/dashboard')
|
||||||
def adsb_dashboard():
|
def adsb_dashboard():
|
||||||
"""Popout ADS-B dashboard."""
|
"""Popout ADS-B dashboard."""
|
||||||
return render_template('adsb_dashboard.html')
|
return render_template(
|
||||||
|
'adsb_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
adsb_auto_start=ADSB_AUTO_START,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history')
|
||||||
|
def adsb_history():
|
||||||
|
"""ADS-B history reporting dashboard."""
|
||||||
|
history_available = ADSB_HISTORY_ENABLED and PSYCOPG2_AVAILABLE
|
||||||
|
resp = make_response(render_template('adsb_history.html', history_enabled=history_available))
|
||||||
|
resp.headers['Cache-Control'] = 'no-store'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/summary')
|
||||||
|
def adsb_history_summary():
|
||||||
|
"""Summary stats for ADS-B history window."""
|
||||||
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM adsb_messages WHERE received_at >= NOW() - INTERVAL %s) AS message_count,
|
||||||
|
(SELECT COUNT(*) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS snapshot_count,
|
||||||
|
(SELECT COUNT(DISTINCT icao) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS aircraft_count,
|
||||||
|
(SELECT MIN(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS first_seen,
|
||||||
|
(SELECT MAX(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS last_seen
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (window, window, window, window, window))
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
return jsonify(row)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history summary failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/aircraft')
|
||||||
|
def adsb_history_aircraft():
|
||||||
|
"""List latest aircraft snapshots for a time window."""
|
||||||
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||||
|
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||||
|
search = (request.args.get('search') or '').strip()
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
pattern = f'%{search}%'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (icao)
|
||||||
|
icao,
|
||||||
|
callsign,
|
||||||
|
registration,
|
||||||
|
type_code,
|
||||||
|
type_desc,
|
||||||
|
altitude,
|
||||||
|
speed,
|
||||||
|
heading,
|
||||||
|
vertical_rate,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
squawk,
|
||||||
|
captured_at AS last_seen
|
||||||
|
FROM adsb_snapshots
|
||||||
|
WHERE captured_at >= NOW() - INTERVAL %s
|
||||||
|
AND (%s = '' OR icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)
|
||||||
|
ORDER BY icao, captured_at DESC
|
||||||
|
) latest
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify({'aircraft': rows, 'count': len(rows)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history aircraft query failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/timeline')
|
||||||
|
def adsb_history_timeline():
|
||||||
|
"""Timeline snapshots for a specific aircraft."""
|
||||||
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
|
if not icao:
|
||||||
|
return jsonify({'error': 'icao is required'}), 400
|
||||||
|
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||||
|
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT captured_at, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||||
|
FROM adsb_snapshots
|
||||||
|
WHERE icao = %s
|
||||||
|
AND captured_at >= NOW() - INTERVAL %s
|
||||||
|
ORDER BY captured_at ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (icao, window, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history timeline query failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/history/messages')
|
||||||
|
def adsb_history_messages():
|
||||||
|
"""Raw message history for a specific aircraft."""
|
||||||
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
|
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||||
|
_ensure_history_schema()
|
||||||
|
|
||||||
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 30, 1, 10080)
|
||||||
|
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||||
|
window = f'{since_minutes} minutes'
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT received_at, msg_type, callsign, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||||
|
FROM adsb_messages
|
||||||
|
WHERE received_at >= NOW() - INTERVAL %s
|
||||||
|
AND (%s = '' OR icao = %s)
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_history_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (window, icao, icao, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ADS-B history message query failed: %s", exc)
|
||||||
|
return jsonify({'error': 'History database unavailable'}), 503
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""AIS vessel tracking routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response, render_template
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.validation import validate_device_index, validate_gain
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.constants import (
|
||||||
|
AIS_TCP_PORT,
|
||||||
|
AIS_TERMINATE_TIMEOUT,
|
||||||
|
AIS_SOCKET_TIMEOUT,
|
||||||
|
AIS_RECONNECT_DELAY,
|
||||||
|
AIS_UPDATE_INTERVAL,
|
||||||
|
SOCKET_BUFFER_SIZE,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SOCKET_CONNECT_TIMEOUT,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ais')
|
||||||
|
|
||||||
|
ais_bp = Blueprint('ais', __name__, url_prefix='/ais')
|
||||||
|
|
||||||
|
# Track AIS state
|
||||||
|
ais_running = False
|
||||||
|
ais_connected = False
|
||||||
|
ais_messages_received = 0
|
||||||
|
ais_last_message_time = None
|
||||||
|
ais_active_device = None
|
||||||
|
_ais_error_logged = True
|
||||||
|
|
||||||
|
# Common installation paths for AIS-catcher
|
||||||
|
AIS_CATCHER_PATHS = [
|
||||||
|
'/usr/local/bin/AIS-catcher',
|
||||||
|
'/usr/bin/AIS-catcher',
|
||||||
|
'/opt/homebrew/bin/AIS-catcher',
|
||||||
|
'/opt/homebrew/bin/aiscatcher',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_ais_catcher():
|
||||||
|
"""Find AIS-catcher binary, checking PATH and common locations."""
|
||||||
|
# First try PATH
|
||||||
|
for name in ['AIS-catcher', 'aiscatcher']:
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
# Check common installation paths
|
||||||
|
for path in AIS_CATCHER_PATHS:
|
||||||
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ais_stream(port: int):
|
||||||
|
"""Parse JSON data from AIS-catcher TCP server."""
|
||||||
|
global ais_running, ais_connected, ais_messages_received, ais_last_message_time, _ais_error_logged
|
||||||
|
|
||||||
|
logger.info(f"AIS stream parser started, connecting to localhost:{port}")
|
||||||
|
ais_connected = True
|
||||||
|
ais_messages_received = 0
|
||||||
|
_ais_error_logged = True
|
||||||
|
|
||||||
|
while ais_running:
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||||
|
sock.connect(('localhost', port))
|
||||||
|
ais_connected = True
|
||||||
|
_ais_error_logged = True
|
||||||
|
logger.info("Connected to AIS-catcher TCP server")
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
last_update = time.time()
|
||||||
|
pending_updates = set()
|
||||||
|
|
||||||
|
while ais_running:
|
||||||
|
try:
|
||||||
|
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||||
|
if not data:
|
||||||
|
logger.warning("AIS connection closed (no data)")
|
||||||
|
break
|
||||||
|
buffer += data
|
||||||
|
|
||||||
|
while '\n' in buffer:
|
||||||
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
vessel = process_ais_message(msg)
|
||||||
|
if vessel:
|
||||||
|
mmsi = vessel.get('mmsi')
|
||||||
|
if mmsi:
|
||||||
|
app_module.ais_vessels.set(mmsi, vessel)
|
||||||
|
pending_updates.add(mmsi)
|
||||||
|
ais_messages_received += 1
|
||||||
|
ais_last_message_time = time.time()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
if ais_messages_received < 5:
|
||||||
|
logger.debug(f"Invalid JSON: {line[:100]}")
|
||||||
|
|
||||||
|
# Batch updates
|
||||||
|
now = time.time()
|
||||||
|
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||||
|
for mmsi in pending_updates:
|
||||||
|
if mmsi in app_module.ais_vessels:
|
||||||
|
try:
|
||||||
|
app_module.ais_queue.put_nowait({
|
||||||
|
'type': 'vessel',
|
||||||
|
**app_module.ais_vessels[mmsi]
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
pending_updates.clear()
|
||||||
|
last_update = now
|
||||||
|
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sock.close()
|
||||||
|
ais_connected = False
|
||||||
|
except OSError as e:
|
||||||
|
ais_connected = False
|
||||||
|
if not _ais_error_logged:
|
||||||
|
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||||
|
_ais_error_logged = True
|
||||||
|
time.sleep(AIS_RECONNECT_DELAY)
|
||||||
|
|
||||||
|
ais_connected = False
|
||||||
|
logger.info("AIS stream parser stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def process_ais_message(msg: dict) -> dict | None:
|
||||||
|
"""Process AIS-catcher JSON message and extract vessel data."""
|
||||||
|
# AIS-catcher outputs different message types
|
||||||
|
# We're interested in position reports and static data
|
||||||
|
|
||||||
|
mmsi = msg.get('mmsi')
|
||||||
|
if not mmsi:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mmsi = str(mmsi)
|
||||||
|
|
||||||
|
# Get existing vessel data or create new
|
||||||
|
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
|
||||||
|
|
||||||
|
# Extract common fields
|
||||||
|
# AIS-catcher JSON_FULL uses 'longitude'/'latitude', but some versions use 'lon'/'lat'
|
||||||
|
lat_val = msg.get('latitude') or msg.get('lat')
|
||||||
|
lon_val = msg.get('longitude') or msg.get('lon')
|
||||||
|
if lat_val is not None and lon_val is not None:
|
||||||
|
try:
|
||||||
|
lat = float(lat_val)
|
||||||
|
lon = float(lon_val)
|
||||||
|
# Validate coordinates (AIS uses 181 for unavailable)
|
||||||
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||||||
|
vessel['lat'] = lat
|
||||||
|
vessel['lon'] = lon
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Speed over ground (knots)
|
||||||
|
if 'speed' in msg:
|
||||||
|
try:
|
||||||
|
speed = float(msg['speed'])
|
||||||
|
if speed < 102.3: # 102.3 = not available
|
||||||
|
vessel['speed'] = round(speed, 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Course over ground (degrees)
|
||||||
|
if 'course' in msg:
|
||||||
|
try:
|
||||||
|
course = float(msg['course'])
|
||||||
|
if course < 360: # 360 = not available
|
||||||
|
vessel['course'] = round(course, 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# True heading (degrees)
|
||||||
|
if 'heading' in msg:
|
||||||
|
try:
|
||||||
|
heading = int(msg['heading'])
|
||||||
|
if heading < 511: # 511 = not available
|
||||||
|
vessel['heading'] = heading
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Navigation status
|
||||||
|
if 'status' in msg:
|
||||||
|
vessel['nav_status'] = msg['status']
|
||||||
|
if 'status_text' in msg:
|
||||||
|
vessel['nav_status_text'] = msg['status_text']
|
||||||
|
|
||||||
|
# Vessel name (from Type 5 or Type 24 messages)
|
||||||
|
if 'shipname' in msg:
|
||||||
|
name = msg['shipname'].strip().strip('@')
|
||||||
|
if name:
|
||||||
|
vessel['name'] = name
|
||||||
|
|
||||||
|
# Callsign
|
||||||
|
if 'callsign' in msg:
|
||||||
|
callsign = msg['callsign'].strip().strip('@')
|
||||||
|
if callsign:
|
||||||
|
vessel['callsign'] = callsign
|
||||||
|
|
||||||
|
# Ship type
|
||||||
|
if 'shiptype' in msg:
|
||||||
|
vessel['ship_type'] = msg['shiptype']
|
||||||
|
if 'shiptype_text' in msg:
|
||||||
|
vessel['ship_type_text'] = msg['shiptype_text']
|
||||||
|
|
||||||
|
# Destination
|
||||||
|
if 'destination' in msg:
|
||||||
|
dest = msg['destination'].strip().strip('@')
|
||||||
|
if dest:
|
||||||
|
vessel['destination'] = dest
|
||||||
|
|
||||||
|
# ETA
|
||||||
|
if 'eta' in msg:
|
||||||
|
vessel['eta'] = msg['eta']
|
||||||
|
|
||||||
|
# Dimensions
|
||||||
|
if 'to_bow' in msg and 'to_stern' in msg:
|
||||||
|
try:
|
||||||
|
length = int(msg['to_bow']) + int(msg['to_stern'])
|
||||||
|
if length > 0:
|
||||||
|
vessel['length'] = length
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'to_port' in msg and 'to_starboard' in msg:
|
||||||
|
try:
|
||||||
|
width = int(msg['to_port']) + int(msg['to_starboard'])
|
||||||
|
if width > 0:
|
||||||
|
vessel['width'] = width
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Draught
|
||||||
|
if 'draught' in msg:
|
||||||
|
try:
|
||||||
|
draught = float(msg['draught'])
|
||||||
|
if draught > 0:
|
||||||
|
vessel['draught'] = draught
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Rate of turn
|
||||||
|
if 'turn' in msg:
|
||||||
|
try:
|
||||||
|
turn = float(msg['turn'])
|
||||||
|
if -127 <= turn <= 127: # Valid range
|
||||||
|
vessel['rate_of_turn'] = turn
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Message type for debugging
|
||||||
|
if 'type' in msg:
|
||||||
|
vessel['last_msg_type'] = msg['type']
|
||||||
|
|
||||||
|
# Timestamp
|
||||||
|
vessel['last_seen'] = time.time()
|
||||||
|
|
||||||
|
return vessel
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/tools')
|
||||||
|
def check_ais_tools():
|
||||||
|
"""Check for AIS decoding tools and hardware."""
|
||||||
|
has_ais_catcher = find_ais_catcher() is not None
|
||||||
|
|
||||||
|
# Check what SDR hardware is detected
|
||||||
|
devices = SDRFactory.detect_devices()
|
||||||
|
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'ais_catcher': has_ais_catcher,
|
||||||
|
'ais_catcher_path': find_ais_catcher(),
|
||||||
|
'has_rtlsdr': has_rtlsdr,
|
||||||
|
'device_count': len(devices)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/status')
|
||||||
|
def ais_status():
|
||||||
|
"""Get AIS tracking status for debugging."""
|
||||||
|
process_running = False
|
||||||
|
if app_module.ais_process:
|
||||||
|
process_running = app_module.ais_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'tracking_active': ais_running,
|
||||||
|
'active_device': ais_active_device,
|
||||||
|
'connected': ais_connected,
|
||||||
|
'messages_received': ais_messages_received,
|
||||||
|
'last_message_time': ais_last_message_time,
|
||||||
|
'vessel_count': len(app_module.ais_vessels),
|
||||||
|
'vessels': dict(app_module.ais_vessels),
|
||||||
|
'queue_size': app_module.ais_queue.qsize(),
|
||||||
|
'ais_catcher_path': find_ais_catcher(),
|
||||||
|
'process_running': process_running
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/start', methods=['POST'])
|
||||||
|
def start_ais():
|
||||||
|
"""Start AIS tracking."""
|
||||||
|
global ais_running, ais_active_device
|
||||||
|
|
||||||
|
with app_module.ais_lock:
|
||||||
|
if ais_running:
|
||||||
|
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
gain = int(validate_gain(data.get('gain', '40')))
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Find AIS-catcher
|
||||||
|
ais_catcher_path = find_ais_catcher()
|
||||||
|
if not ais_catcher_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Get SDR type from request
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
# Kill any existing process
|
||||||
|
if app_module.ais_process:
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 15)
|
||||||
|
app_module.ais_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 9)
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
app_module.ais_process = None
|
||||||
|
logger.info("Killed existing AIS process")
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Build command using SDR abstraction
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
|
||||||
|
bias_t = data.get('bias_t', False)
|
||||||
|
tcp_port = AIS_TCP_PORT
|
||||||
|
|
||||||
|
cmd = builder.build_ais_command(
|
||||||
|
device=sdr_device,
|
||||||
|
gain=float(gain),
|
||||||
|
bias_t=bias_t,
|
||||||
|
tcp_port=tcp_port
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the found AIS-catcher path
|
||||||
|
cmd[0] = ais_catcher_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting AIS-catcher with device {device}: {' '.join(cmd)}")
|
||||||
|
app_module.ais_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for process to start
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
if app_module.ais_process.poll() is not None:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
|
stderr_output = ''
|
||||||
|
if app_module.ais_process.stderr:
|
||||||
|
try:
|
||||||
|
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||||
|
if stderr_output:
|
||||||
|
error_msg += f' Error: {stderr_output[:200]}'
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
|
ais_running = True
|
||||||
|
ais_active_device = device
|
||||||
|
|
||||||
|
# Start TCP parser thread
|
||||||
|
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': 'AIS tracking started',
|
||||||
|
'device': device,
|
||||||
|
'port': tcp_port
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_ais():
|
||||||
|
"""Stop AIS tracking."""
|
||||||
|
global ais_running, ais_active_device
|
||||||
|
|
||||||
|
with app_module.ais_lock:
|
||||||
|
if app_module.ais_process:
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 15)
|
||||||
|
app_module.ais_process.wait(timeout=AIS_TERMINATE_TIMEOUT)
|
||||||
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 9)
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
app_module.ais_process = None
|
||||||
|
logger.info("AIS process stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if ais_active_device is not None:
|
||||||
|
app_module.release_sdr_device(ais_active_device)
|
||||||
|
|
||||||
|
ais_running = False
|
||||||
|
ais_active_device = None
|
||||||
|
|
||||||
|
app_module.ais_vessels.clear()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/stream')
|
||||||
|
def stream_ais():
|
||||||
|
"""SSE stream for AIS vessels."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/dashboard')
|
||||||
|
def ais_dashboard():
|
||||||
|
"""Popout AIS dashboard."""
|
||||||
|
return render_template(
|
||||||
|
'ais_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
@@ -229,7 +229,11 @@ def init_audio_websocket(app: Flask):
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "timed out" not in str(e).lower():
|
msg = str(e).lower()
|
||||||
|
if "connection closed" in msg:
|
||||||
|
logger.info("WebSocket closed by client")
|
||||||
|
break
|
||||||
|
if "timed out" not in msg:
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
|
||||||
# Stream audio data if active
|
# Stream audio data if active
|
||||||
|
|||||||
@@ -0,0 +1,853 @@
|
|||||||
|
"""
|
||||||
|
Controller routes for managing remote Intercept agents.
|
||||||
|
|
||||||
|
This blueprint provides:
|
||||||
|
- Agent CRUD operations
|
||||||
|
- Proxy endpoints to forward requests to agents
|
||||||
|
- Push data ingestion endpoint
|
||||||
|
- Multi-agent SSE stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.database import (
|
||||||
|
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||||
|
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||||
|
)
|
||||||
|
from utils.agent_client import (
|
||||||
|
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||||
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.trilateration import (
|
||||||
|
DeviceLocationTracker, PathLossModel, Trilateration,
|
||||||
|
AgentObservation, estimate_location_from_observations
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.controller')
|
||||||
|
|
||||||
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
|
||||||
|
# Multi-agent data queue for combined SSE stream
|
||||||
|
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent CRUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents', methods=['GET'])
|
||||||
|
def get_agents():
|
||||||
|
"""List all registered agents."""
|
||||||
|
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||||
|
agents = list_agents(active_only=active_only)
|
||||||
|
|
||||||
|
# Optionally refresh status for each agent
|
||||||
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
|
if refresh:
|
||||||
|
for agent in agents:
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
agent['healthy'] = client.health_check()
|
||||||
|
except Exception:
|
||||||
|
agent['healthy'] = False
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agents': agents,
|
||||||
|
'count': len(agents)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents', methods=['POST'])
|
||||||
|
def register_agent():
|
||||||
|
"""
|
||||||
|
Register a new remote agent.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"name": "sensor-node-1",
|
||||||
|
"base_url": "http://192.168.1.50:8020",
|
||||||
|
"api_key": "optional-shared-secret",
|
||||||
|
"description": "Optional description"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
base_url = data.get('base_url', '').strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
|
||||||
|
if not base_url:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
try:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
||||||
|
if not parsed.netloc:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
|
|
||||||
|
# Check if agent already exists
|
||||||
|
existing = get_agent_by_name(name)
|
||||||
|
if existing:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent with name "{name}" already exists'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Try to connect and get capabilities
|
||||||
|
api_key = data.get('api_key', '').strip() or None
|
||||||
|
client = AgentClient(base_url, api_key=api_key)
|
||||||
|
|
||||||
|
capabilities = None
|
||||||
|
interfaces = None
|
||||||
|
try:
|
||||||
|
caps = client.get_capabilities()
|
||||||
|
capabilities = caps.get('modes', {})
|
||||||
|
interfaces = {'devices': caps.get('devices', [])}
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
logger.warning(f"Could not fetch capabilities from {base_url}: {e}")
|
||||||
|
|
||||||
|
# Create agent
|
||||||
|
try:
|
||||||
|
agent_id = create_agent(
|
||||||
|
name=name,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
description=data.get('description'),
|
||||||
|
capabilities=capabilities,
|
||||||
|
interfaces=interfaces
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last_seen since we just connected
|
||||||
|
if capabilities is not None:
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
message = 'Agent registered successfully'
|
||||||
|
if capabilities is None:
|
||||||
|
message += ' (could not connect - agent may be offline)'
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message,
|
||||||
|
'agent': agent
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to create agent")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
||||||
|
def get_agent_detail(agent_id: int):
|
||||||
|
"""Get details of a specific agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
# Optionally refresh from agent
|
||||||
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
|
if refresh:
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
if metadata['healthy']:
|
||||||
|
caps = metadata['capabilities'] or {}
|
||||||
|
# Store full interfaces structure (wifi, bt, sdr)
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
# Fallback: also include top-level devices for backwards compatibility
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
agent['healthy'] = True
|
||||||
|
else:
|
||||||
|
agent['healthy'] = False
|
||||||
|
except Exception:
|
||||||
|
agent['healthy'] = False
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'agent': agent})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['PUT', 'PATCH'])
|
||||||
|
def update_agent_detail(agent_id: int):
|
||||||
|
"""Update an agent's details."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Update allowed fields
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
base_url=data.get('base_url'),
|
||||||
|
description=data.get('description'),
|
||||||
|
api_key=data.get('api_key'),
|
||||||
|
is_active=data.get('is_active')
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
return jsonify({'status': 'success', 'agent': agent})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['DELETE'])
|
||||||
|
def remove_agent(agent_id: int):
|
||||||
|
"""Delete an agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
delete_agent(agent_id)
|
||||||
|
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/refresh', methods=['POST'])
|
||||||
|
def refresh_agent_metadata(agent_id: int):
|
||||||
|
"""Refresh an agent's capabilities and status."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
|
||||||
|
if metadata['healthy']:
|
||||||
|
caps = metadata['capabilities'] or {}
|
||||||
|
# Store full interfaces structure (wifi, bt, sdr)
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
# Fallback: also include top-level devices for backwards compatibility
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent': agent,
|
||||||
|
'metadata': metadata
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Agent is not reachable'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to reach agent: {e}'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent Status - Get running state
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/status', methods=['GET'])
|
||||||
|
def get_agent_status(agent_id: int):
|
||||||
|
"""Get an agent's current status including running modes."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
status = client.get_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'agent_status': status
|
||||||
|
})
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to reach agent: {e}'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/health', methods=['GET'])
|
||||||
|
def check_all_agents_health():
|
||||||
|
"""
|
||||||
|
Check health of all registered agents in one call.
|
||||||
|
|
||||||
|
More efficient than checking each agent individually.
|
||||||
|
Returns health status, response time, and running modes for each agent.
|
||||||
|
"""
|
||||||
|
agents_list = list_agents(active_only=True)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for agent in agents_list:
|
||||||
|
result = {
|
||||||
|
'id': agent['id'],
|
||||||
|
'name': agent['name'],
|
||||||
|
'healthy': False,
|
||||||
|
'response_time_ms': None,
|
||||||
|
'running_modes': [],
|
||||||
|
'error': None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
|
||||||
|
# Time the health check
|
||||||
|
start_time = time.time()
|
||||||
|
is_healthy = client.health_check()
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
result['healthy'] = is_healthy
|
||||||
|
result['response_time_ms'] = round(response_time, 1)
|
||||||
|
|
||||||
|
if is_healthy:
|
||||||
|
# Update last_seen in database
|
||||||
|
update_agent(agent['id'], update_last_seen=True)
|
||||||
|
|
||||||
|
# Also fetch running modes
|
||||||
|
try:
|
||||||
|
status = client.get_status()
|
||||||
|
result['running_modes'] = status.get('running_modes', [])
|
||||||
|
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||||
|
except Exception:
|
||||||
|
pass # Status fetch is optional
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
result['error'] = f'Connection failed: {str(e)}'
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
result['error'] = f'HTTP error: {str(e)}'
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'agents': results,
|
||||||
|
'total': len(results),
|
||||||
|
'healthy_count': sum(1 for r in results if r['healthy'])
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Proxy Operations - Forward requests to agents
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/start', methods=['POST'])
|
||||||
|
def proxy_start_mode(agent_id: int, mode: str):
|
||||||
|
"""Start a mode on a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
params = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.start_mode(mode, params)
|
||||||
|
|
||||||
|
# Update last_seen
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
||||||
|
def proxy_stop_mode(agent_id: int, mode: str):
|
||||||
|
"""Stop a mode on a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.stop_mode(mode)
|
||||||
|
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
||||||
|
def proxy_mode_status(agent_id: int, mode: str):
|
||||||
|
"""Get mode status from a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.get_mode_status(mode)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||||
|
def proxy_mode_data(agent_id: int, mode: str):
|
||||||
|
"""Get current data from a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.get_mode_data(mode)
|
||||||
|
|
||||||
|
# Tag data with agent info
|
||||||
|
result['agent_id'] = agent_id
|
||||||
|
result['agent_name'] = agent['name']
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'mode': mode,
|
||||||
|
'data': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||||
|
def proxy_wifi_monitor(agent_id: int):
|
||||||
|
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.post('/wifi/monitor', data)
|
||||||
|
|
||||||
|
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
|
||||||
|
if result.get('status') == 'success':
|
||||||
|
try:
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
if metadata.get('healthy'):
|
||||||
|
caps = metadata.get('capabilities') or {}
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-fatal if refresh fails
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': result.get('status', 'error'),
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'monitor_interface': result.get('monitor_interface'),
|
||||||
|
'message': result.get('message')
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Push Data Ingestion
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/api/ingest', methods=['POST'])
|
||||||
|
def ingest_push_data():
|
||||||
|
"""
|
||||||
|
Receive pushed data from remote agents.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"agent_name": "sensor-node-1",
|
||||||
|
"scan_type": "adsb",
|
||||||
|
"interface": "rtlsdr0",
|
||||||
|
"payload": {...},
|
||||||
|
"received_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
Expected header:
|
||||||
|
X-API-Key: shared-secret (if agent has api_key configured)
|
||||||
|
"""
|
||||||
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
|
agent_name = data.get('agent_name')
|
||||||
|
if not agent_name:
|
||||||
|
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
||||||
|
|
||||||
|
# Find agent
|
||||||
|
agent = get_agent_by_name(agent_name)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
|
||||||
|
|
||||||
|
# Validate API key if configured
|
||||||
|
if agent.get('api_key'):
|
||||||
|
provided_key = request.headers.get('X-API-Key', '')
|
||||||
|
if provided_key != agent['api_key']:
|
||||||
|
logger.warning(f"Invalid API key from agent {agent_name}")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
|
||||||
|
|
||||||
|
# Store payload
|
||||||
|
try:
|
||||||
|
payload_id = store_push_payload(
|
||||||
|
agent_id=agent['id'],
|
||||||
|
scan_type=data.get('scan_type', 'unknown'),
|
||||||
|
payload=data.get('payload', {}),
|
||||||
|
interface=data.get('interface'),
|
||||||
|
received_at=data.get('received_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Emit to SSE stream
|
||||||
|
try:
|
||||||
|
agent_data_queue.put_nowait({
|
||||||
|
'type': 'agent_data',
|
||||||
|
'agent_id': agent['id'],
|
||||||
|
'agent_name': agent_name,
|
||||||
|
'scan_type': data.get('scan_type'),
|
||||||
|
'interface': data.get('interface'),
|
||||||
|
'payload': data.get('payload'),
|
||||||
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("Agent data queue full, data may be lost")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'accepted',
|
||||||
|
'payload_id': payload_id
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to store push payload")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/payloads', methods=['GET'])
|
||||||
|
def get_payloads():
|
||||||
|
"""Get recent push payloads."""
|
||||||
|
agent_id = request.args.get('agent_id', type=int)
|
||||||
|
scan_type = request.args.get('scan_type')
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
|
||||||
|
payloads = get_recent_payloads(
|
||||||
|
agent_id=agent_id,
|
||||||
|
scan_type=scan_type,
|
||||||
|
limit=min(limit, 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'payloads': payloads,
|
||||||
|
'count': len(payloads)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Multi-Agent SSE Stream
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/stream/all')
|
||||||
|
def stream_all_agents():
|
||||||
|
"""
|
||||||
|
Combined SSE stream for data from all agents.
|
||||||
|
|
||||||
|
This endpoint streams push data as it arrives from agents.
|
||||||
|
Each message is tagged with agent_id and agent_name.
|
||||||
|
"""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = agent_data_queue.get(timeout=1.0)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent Management Page
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/manage')
|
||||||
|
def agent_management_page():
|
||||||
|
"""Render the agent management page."""
|
||||||
|
from flask import render_template
|
||||||
|
from config import VERSION
|
||||||
|
return render_template('agents.html', version=VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/monitor')
|
||||||
|
def network_monitor_page():
|
||||||
|
"""Render the network monitor page for multi-agent aggregated view."""
|
||||||
|
from flask import render_template
|
||||||
|
return render_template('network_monitor.html')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Device Location Estimation (Trilateration)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Global device location tracker
|
||||||
|
device_tracker = DeviceLocationTracker(
|
||||||
|
trilateration=Trilateration(
|
||||||
|
path_loss_model=PathLossModel('outdoor'),
|
||||||
|
min_observations=2
|
||||||
|
),
|
||||||
|
observation_window_seconds=120.0, # 2 minute window
|
||||||
|
min_observations=2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/observe', methods=['POST'])
|
||||||
|
def add_location_observation():
|
||||||
|
"""
|
||||||
|
Add an observation for device location estimation.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"agent_name": "sensor-node-1",
|
||||||
|
"agent_lat": 40.7128,
|
||||||
|
"agent_lon": -74.0060,
|
||||||
|
"rssi": -55,
|
||||||
|
"frequency_mhz": 2400 (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns location estimate if enough data, null otherwise.
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
||||||
|
for field in required:
|
||||||
|
if field not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
|
||||||
|
|
||||||
|
# Look up agent GPS from database if not provided
|
||||||
|
agent_lat = data.get('agent_lat')
|
||||||
|
agent_lon = data.get('agent_lon')
|
||||||
|
|
||||||
|
if agent_lat is None or agent_lon is None:
|
||||||
|
agent = get_agent_by_name(data['agent_name'])
|
||||||
|
if agent and agent.get('gps_coords'):
|
||||||
|
coords = agent['gps_coords']
|
||||||
|
agent_lat = coords.get('lat') or coords.get('latitude')
|
||||||
|
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||||
|
|
||||||
|
if agent_lat is None or agent_lon is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Agent GPS coordinates required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
estimate = device_tracker.add_observation(
|
||||||
|
device_id=data['device_id'],
|
||||||
|
agent_name=data['agent_name'],
|
||||||
|
agent_lat=float(agent_lat),
|
||||||
|
agent_lon=float(agent_lon),
|
||||||
|
rssi=float(data['rssi']),
|
||||||
|
frequency_mhz=data.get('frequency_mhz')
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'device_id': data['device_id'],
|
||||||
|
'location': estimate.to_dict() if estimate else None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/estimate', methods=['POST'])
|
||||||
|
def estimate_location():
|
||||||
|
"""
|
||||||
|
Estimate device location from provided observations.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"observations": [
|
||||||
|
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
|
||||||
|
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
|
||||||
|
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"}
|
||||||
|
],
|
||||||
|
"environment": "outdoor" (optional: outdoor, indoor, free_space)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
observations = data.get('observations', [])
|
||||||
|
if len(observations) < 2:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'At least 2 observations required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
environment = data.get('environment', 'outdoor')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = estimate_location_from_observations(observations, environment)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if result else 'insufficient_data',
|
||||||
|
'location': result
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Location estimation failed")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
||||||
|
def get_device_location(device_id: str):
|
||||||
|
"""Get the latest location estimate for a device."""
|
||||||
|
estimate = device_tracker.get_location(device_id)
|
||||||
|
|
||||||
|
if not estimate:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'not_found',
|
||||||
|
'device_id': device_id,
|
||||||
|
'location': None
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'device_id': device_id,
|
||||||
|
'location': estimate.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/all', methods=['GET'])
|
||||||
|
def get_all_locations():
|
||||||
|
"""Get all current device location estimates."""
|
||||||
|
locations = device_tracker.get_all_locations()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'count': len(locations),
|
||||||
|
'devices': {
|
||||||
|
device_id: estimate.to_dict()
|
||||||
|
for device_id, estimate in locations.items()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/near', methods=['GET'])
|
||||||
|
def get_devices_near():
|
||||||
|
"""
|
||||||
|
Find devices near a location.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
lat: latitude
|
||||||
|
lon: longitude
|
||||||
|
radius: radius in meters (default 100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lat = float(request.args.get('lat', 0))
|
||||||
|
lon = float(request.args.get('lon', 0))
|
||||||
|
radius = float(request.args.get('radius', 100))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||||
|
|
||||||
|
results = device_tracker.get_devices_near(lat, lon, radius)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'center': {'lat': lat, 'lon': lon},
|
||||||
|
'radius_meters': radius,
|
||||||
|
'count': len(results),
|
||||||
|
'devices': [
|
||||||
|
{'device_id': device_id, 'location': estimate.to_dict()}
|
||||||
|
for device_id, estimate in results
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
"""VHF DSC (Digital Selective Calling) routes.
|
||||||
|
|
||||||
|
DSC operates on VHF Channel 70 (156.525 MHz) for maritime
|
||||||
|
distress and safety communications per ITU-R M.493.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import queue
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.constants import (
|
||||||
|
DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
DSC_SAMPLE_RATE,
|
||||||
|
DSC_TERMINATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alerts,
|
||||||
|
get_dsc_alert,
|
||||||
|
acknowledge_dsc_alert,
|
||||||
|
get_dsc_alert_summary,
|
||||||
|
)
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.validation import validate_device_index, validate_gain
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.dsc')
|
||||||
|
|
||||||
|
dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
|
||||||
|
|
||||||
|
# Module state (track if running independent of process state)
|
||||||
|
dsc_running = False
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
dsc_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dsc_decoder_path() -> str | None:
|
||||||
|
"""Get path to DSC decoder."""
|
||||||
|
# Check for our custom decoder
|
||||||
|
project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder')
|
||||||
|
if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK):
|
||||||
|
return project_bin
|
||||||
|
|
||||||
|
# Check system PATH
|
||||||
|
system_decoder = shutil.which('dsc-decoder')
|
||||||
|
if system_decoder:
|
||||||
|
return system_decoder
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_dsc_tools() -> dict:
|
||||||
|
"""Check availability of DSC decoding tools."""
|
||||||
|
rtl_fm_path = get_tool_path('rtl_fm')
|
||||||
|
decoder_path = _get_dsc_decoder_path()
|
||||||
|
|
||||||
|
# Check for scipy/numpy (needed for decoder)
|
||||||
|
scipy_available = False
|
||||||
|
try:
|
||||||
|
import scipy
|
||||||
|
import numpy
|
||||||
|
scipy_available = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rtl_fm': {
|
||||||
|
'available': rtl_fm_path is not None,
|
||||||
|
'path': rtl_fm_path
|
||||||
|
},
|
||||||
|
'dsc_decoder': {
|
||||||
|
'available': decoder_path is not None,
|
||||||
|
'path': decoder_path
|
||||||
|
},
|
||||||
|
'scipy': {
|
||||||
|
'available': scipy_available,
|
||||||
|
'note': 'Required for DSC signal processing'
|
||||||
|
},
|
||||||
|
'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> None:
|
||||||
|
"""
|
||||||
|
Stream DSC decoder output to queue using PTY for unbuffered output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
master_fd: PTY master file descriptor
|
||||||
|
decoder_process: Decoder subprocess
|
||||||
|
"""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
while dsc_running:
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
data = os.read(master_fd, 1024)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buffer += data.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
while '\n' in buffer:
|
||||||
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse DSC message
|
||||||
|
parsed = parse_dsc_message(line)
|
||||||
|
if parsed:
|
||||||
|
# Generate unique message ID
|
||||||
|
msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}"
|
||||||
|
parsed['id'] = msg_id
|
||||||
|
|
||||||
|
# Store in transient DataStore
|
||||||
|
app_module.dsc_messages.set(msg_id, parsed)
|
||||||
|
|
||||||
|
# Queue for SSE
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.put_nowait(parsed)
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("DSC queue full, dropping message")
|
||||||
|
|
||||||
|
# Store critical alerts permanently
|
||||||
|
if parsed.get('is_critical'):
|
||||||
|
_store_critical_alert(parsed)
|
||||||
|
else:
|
||||||
|
# Raw output for debugging
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'raw',
|
||||||
|
'text': line
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if process is still running
|
||||||
|
if decoder_process.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DSC decoder error: {e}")
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
decoder_process.wait()
|
||||||
|
dsc_running = False
|
||||||
|
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
app_module.dsc_process = None
|
||||||
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
|
|
||||||
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
|
"""Store critical DSC alert (DISTRESS/URGENCY) to database."""
|
||||||
|
try:
|
||||||
|
store_dsc_alert(
|
||||||
|
source_mmsi=msg.get('source_mmsi', ''),
|
||||||
|
format_code=str(msg.get('format_code', '')),
|
||||||
|
category=msg.get('category', 'UNKNOWN'),
|
||||||
|
source_name=msg.get('source_name'),
|
||||||
|
dest_mmsi=msg.get('dest_mmsi'),
|
||||||
|
nature_of_distress=msg.get('nature_of_distress'),
|
||||||
|
latitude=msg.get('latitude'),
|
||||||
|
longitude=msg.get('longitude'),
|
||||||
|
raw_message=msg.get('raw_message')
|
||||||
|
)
|
||||||
|
logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store DSC alert: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_rtl_stderr(process: subprocess.Popen) -> None:
|
||||||
|
"""Monitor rtl_fm stderr for errors."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in process.stderr:
|
||||||
|
if not dsc_running:
|
||||||
|
break
|
||||||
|
err_text = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if err_text:
|
||||||
|
logger.debug(f"[RTL_FM] {err_text}")
|
||||||
|
|
||||||
|
# Check for device busy error
|
||||||
|
if 'usb_claim_interface' in err_text.lower():
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': 'SDR device busy',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'suggestion': 'Use a different SDR device or stop other SDR processes'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for other common errors
|
||||||
|
if 'no supported devices' in err_text.lower():
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': 'No SDR device found',
|
||||||
|
'error_type': 'NO_DEVICE'
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/status')
|
||||||
|
def get_status() -> Response:
|
||||||
|
"""Get DSC decoder status."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
running = (
|
||||||
|
dsc_running and
|
||||||
|
app_module.dsc_process is not None and
|
||||||
|
app_module.dsc_process.poll() is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get message counts
|
||||||
|
message_count = len(app_module.dsc_messages)
|
||||||
|
alert_summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
'message_count': message_count,
|
||||||
|
'alerts': alert_summary
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/tools')
|
||||||
|
def check_tools() -> Response:
|
||||||
|
"""Check DSC decoder tool availability."""
|
||||||
|
tools = _check_dsc_tools()
|
||||||
|
return jsonify(tools)
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/start', methods=['POST'])
|
||||||
|
def start_decoding() -> Response:
|
||||||
|
"""Start DSC decoder."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
if app_module.dsc_process and app_module.dsc_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'DSC decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check tools
|
||||||
|
tools = _check_dsc_tools()
|
||||||
|
if not tools['ready']:
|
||||||
|
missing = []
|
||||||
|
if not tools['rtl_fm']['available']:
|
||||||
|
missing.append('rtl_fm')
|
||||||
|
if not tools['dsc_decoder']['available']:
|
||||||
|
missing.append('dsc-decoder')
|
||||||
|
if not tools['scipy']['available']:
|
||||||
|
missing.append('scipy/numpy')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Missing required tools: {", ".join(missing)}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate device
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate gain
|
||||||
|
try:
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Check if device is available using centralized registry
|
||||||
|
global dsc_active_device
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
dsc_active_device = device_int
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.dsc_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build rtl_fm command
|
||||||
|
rtl_fm_path = tools['rtl_fm']['path']
|
||||||
|
decoder_path = tools['dsc_decoder']['path']
|
||||||
|
|
||||||
|
# rtl_fm command for DSC decoding
|
||||||
|
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||||
|
rtl_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||||
|
'-s', str(DSC_SAMPLE_RATE),
|
||||||
|
'-d', str(device),
|
||||||
|
'-g', str(gain),
|
||||||
|
'-M', 'fm', # FM demodulation
|
||||||
|
'-l', '0', # No squelch for DSC
|
||||||
|
'-E', 'dc' # DC blocking filter
|
||||||
|
]
|
||||||
|
|
||||||
|
# Decoder command
|
||||||
|
decoder_cmd = [decoder_path]
|
||||||
|
|
||||||
|
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd)
|
||||||
|
logger.info(f"Starting DSC decoder: {full_cmd}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start rtl_fm subprocess
|
||||||
|
rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start stderr monitor thread
|
||||||
|
stderr_thread = threading.Thread(
|
||||||
|
target=monitor_rtl_stderr,
|
||||||
|
args=(rtl_process,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
stderr_thread.start()
|
||||||
|
|
||||||
|
# Create PTY for decoder output
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
# Start decoder subprocess
|
||||||
|
decoder_process = subprocess.Popen(
|
||||||
|
decoder_cmd,
|
||||||
|
stdin=rtl_process.stdout,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=slave_fd,
|
||||||
|
close_fds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
os.close(slave_fd)
|
||||||
|
rtl_process.stdout.close()
|
||||||
|
|
||||||
|
# Store process references
|
||||||
|
app_module.dsc_process = decoder_process
|
||||||
|
app_module.dsc_rtl_process = rtl_process
|
||||||
|
dsc_running = True
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
output_thread = threading.Thread(
|
||||||
|
target=stream_dsc_decoder,
|
||||||
|
args=(master_fd, decoder_process),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
output_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
'device': device,
|
||||||
|
'gain': gain,
|
||||||
|
'command': full_cmd
|
||||||
|
})
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
# Release device on failure
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Tool not found: {e.filename}'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_decoding() -> Response:
|
||||||
|
"""Stop DSC decoder."""
|
||||||
|
global dsc_running, dsc_active_device
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
if not app_module.dsc_process:
|
||||||
|
return jsonify({'status': 'not_running'})
|
||||||
|
|
||||||
|
dsc_running = False
|
||||||
|
|
||||||
|
# Terminate rtl_fm process first
|
||||||
|
if app_module.dsc_rtl_process:
|
||||||
|
try:
|
||||||
|
app_module.dsc_rtl_process.terminate()
|
||||||
|
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
app_module.dsc_rtl_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Terminate decoder process
|
||||||
|
if app_module.dsc_process:
|
||||||
|
try:
|
||||||
|
app_module.dsc_process.terminate()
|
||||||
|
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
app_module.dsc_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
app_module.dsc_process = None
|
||||||
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/stream')
|
||||||
|
def stream() -> Response:
|
||||||
|
"""SSE stream for real-time DSC messages."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = app_module.dsc_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/messages')
|
||||||
|
def get_messages() -> Response:
|
||||||
|
"""Get current DSC messages from transient store."""
|
||||||
|
messages = list(app_module.dsc_messages.values())
|
||||||
|
|
||||||
|
# Sort by timestamp (newest first)
|
||||||
|
messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'count': len(messages),
|
||||||
|
'messages': messages
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts')
|
||||||
|
def get_alerts_endpoint() -> Response:
|
||||||
|
"""Get stored DSC alerts (paginated)."""
|
||||||
|
# Parse query params
|
||||||
|
category = request.args.get('category')
|
||||||
|
acknowledged = request.args.get('acknowledged')
|
||||||
|
limit = min(int(request.args.get('limit', 50)), 200)
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
|
||||||
|
# Convert acknowledged param
|
||||||
|
ack_filter = None
|
||||||
|
if acknowledged is not None:
|
||||||
|
ack_filter = acknowledged.lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
alerts = get_dsc_alerts(
|
||||||
|
category=category,
|
||||||
|
acknowledged=ack_filter,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'alerts': alerts,
|
||||||
|
'count': len(alerts),
|
||||||
|
'summary': summary,
|
||||||
|
'pagination': {
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/<int:alert_id>')
|
||||||
|
def get_alert(alert_id: int) -> Response:
|
||||||
|
"""Get a specific DSC alert by ID."""
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
if not alert:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Alert not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify(alert)
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/<int:alert_id>/acknowledge', methods=['POST'])
|
||||||
|
def acknowledge_alert(alert_id: int) -> Response:
|
||||||
|
"""Acknowledge a DSC alert."""
|
||||||
|
data = request.json or {}
|
||||||
|
notes = data.get('notes')
|
||||||
|
|
||||||
|
success = acknowledge_dsc_alert(alert_id, notes)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Alert not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'acknowledged',
|
||||||
|
'alert_id': alert_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/summary')
|
||||||
|
def get_alerts_summary() -> Response:
|
||||||
|
"""Get summary of unacknowledged DSC alerts."""
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
return jsonify(summary)
|
||||||
@@ -161,32 +161,6 @@ def get_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/debug')
|
|
||||||
def debug_gps():
|
|
||||||
"""Debug endpoint showing GPS client state."""
|
|
||||||
reader = get_gps_reader()
|
|
||||||
|
|
||||||
if not reader:
|
|
||||||
return jsonify({
|
|
||||||
'reader': None,
|
|
||||||
'message': 'No GPS client initialized'
|
|
||||||
})
|
|
||||||
|
|
||||||
position = reader.position
|
|
||||||
return jsonify({
|
|
||||||
'running': reader.is_running,
|
|
||||||
'source': 'gpsd',
|
|
||||||
'device': reader.device_path,
|
|
||||||
'host': reader.host,
|
|
||||||
'port': reader.port,
|
|
||||||
'has_position': position is not None,
|
|
||||||
'position': position.to_dict() if position else None,
|
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
|
||||||
'error': reader.error,
|
|
||||||
'callbacks_registered': len(reader._callbacks),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position updates."""
|
"""SSE stream of GPS position updates."""
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from typing import Generator, Optional, List, Dict
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -47,18 +48,23 @@ scanner_running = False
|
|||||||
scanner_lock = threading.Lock()
|
scanner_lock = threading.Lock()
|
||||||
scanner_paused = False
|
scanner_paused = False
|
||||||
scanner_current_freq = 0.0
|
scanner_current_freq = 0.0
|
||||||
|
scanner_active_device: Optional[int] = None
|
||||||
|
listening_active_device: Optional[int] = None
|
||||||
|
scanner_power_process: Optional[subprocess.Popen] = None
|
||||||
scanner_config = {
|
scanner_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
'end_freq': 108.0,
|
'end_freq': 108.0,
|
||||||
'step': 0.1,
|
'step': 0.1,
|
||||||
'modulation': 'wfm',
|
'modulation': 'wfm',
|
||||||
'squelch': 20,
|
'squelch': 0,
|
||||||
'dwell_time': 10.0, # Seconds to stay on active frequency
|
'dwell_time': 10.0, # Seconds to stay on active frequency
|
||||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||||
'device': 0,
|
'device': 0,
|
||||||
'gain': 40,
|
'gain': 40,
|
||||||
'bias_t': False, # Bias-T power for external LNA
|
'bias_t': False, # Bias-T power for external LNA
|
||||||
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
||||||
|
'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
|
||||||
|
'snr_threshold': 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Activity log
|
# Activity log
|
||||||
@@ -79,6 +85,11 @@ def find_rtl_fm() -> str | None:
|
|||||||
return shutil.which('rtl_fm')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def find_rtl_power() -> str | None:
|
||||||
|
"""Find rtl_power binary."""
|
||||||
|
return shutil.which('rtl_power')
|
||||||
|
|
||||||
|
|
||||||
def find_rx_fm() -> str | None:
|
def find_rx_fm() -> str | None:
|
||||||
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
||||||
return shutil.which('rx_fm')
|
return shutil.which('rx_fm')
|
||||||
@@ -161,7 +172,9 @@ def scanner_loop():
|
|||||||
scanner_queue.put_nowait({
|
scanner_queue.put_nowait({
|
||||||
'type': 'freq_change',
|
'type': 'freq_change',
|
||||||
'frequency': current_freq,
|
'frequency': current_freq,
|
||||||
'scanning': not signal_detected
|
'scanning': not signal_detected,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
@@ -238,11 +251,14 @@ def scanner_loop():
|
|||||||
if mod == 'wfm':
|
if mod == 'wfm':
|
||||||
# WFM: threshold 500-10000 based on squelch
|
# WFM: threshold 500-10000 based on squelch
|
||||||
threshold = 500 + (squelch * 95)
|
threshold = 500 + (squelch * 95)
|
||||||
|
min_threshold = 1500
|
||||||
else:
|
else:
|
||||||
# AM/NFM: threshold 300-6500 based on squelch
|
# AM/NFM: threshold 300-6500 based on squelch
|
||||||
threshold = 300 + (squelch * 62)
|
threshold = 300 + (squelch * 62)
|
||||||
|
min_threshold = 900
|
||||||
|
|
||||||
audio_detected = rms > threshold
|
effective_threshold = max(threshold, min_threshold)
|
||||||
|
audio_detected = rms > effective_threshold
|
||||||
|
|
||||||
# Send level info to clients
|
# Send level info to clients
|
||||||
try:
|
try:
|
||||||
@@ -250,8 +266,10 @@ def scanner_loop():
|
|||||||
'type': 'scan_update',
|
'type': 'scan_update',
|
||||||
'frequency': current_freq,
|
'frequency': current_freq,
|
||||||
'level': int(rms),
|
'level': int(rms),
|
||||||
'threshold': int(threshold) if 'threshold' in dir() else 0,
|
'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
|
||||||
'detected': audio_detected
|
'detected': audio_detected,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
@@ -268,15 +286,19 @@ def scanner_loop():
|
|||||||
# Start audio streaming for user
|
# Start audio streaming for user
|
||||||
_start_audio_stream(current_freq, mod)
|
_start_audio_stream(current_freq, mod)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
scanner_queue.put_nowait({
|
scanner_queue.put_nowait({
|
||||||
'type': 'signal_found',
|
'type': 'signal_found',
|
||||||
'frequency': current_freq,
|
'frequency': current_freq,
|
||||||
'modulation': mod,
|
'modulation': mod,
|
||||||
'audio_streaming': True
|
'audio_streaming': True,
|
||||||
})
|
'level': int(rms),
|
||||||
except queue.Full:
|
'threshold': int(effective_threshold),
|
||||||
pass
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check for skip signal
|
# Check for skip signal
|
||||||
if scanner_skip_signal:
|
if scanner_skip_signal:
|
||||||
@@ -305,6 +327,26 @@ def scanner_loop():
|
|||||||
|
|
||||||
last_signal_time = time.time()
|
last_signal_time = time.time()
|
||||||
|
|
||||||
|
# After dwell, move on to keep scanning
|
||||||
|
if scanner_running and not scanner_skip_signal:
|
||||||
|
signal_detected = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_lost',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_freq += step_mhz
|
||||||
|
if current_freq > scanner_config['end_freq']:
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
||||||
|
time.sleep(scanner_config['scan_delay'])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# No signal at this frequency
|
# No signal at this frequency
|
||||||
if signal_detected:
|
if signal_detected:
|
||||||
@@ -346,6 +388,241 @@ def scanner_loop():
|
|||||||
logger.info("Scanner thread stopped")
|
logger.info("Scanner thread stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def scanner_loop_power():
|
||||||
|
"""Power sweep scanner using rtl_power to detect peaks."""
|
||||||
|
global scanner_running, scanner_paused, scanner_current_freq, scanner_power_process
|
||||||
|
|
||||||
|
logger.info("Power sweep scanner thread started")
|
||||||
|
add_activity_log('scanner_start', scanner_config['start_freq'],
|
||||||
|
f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
||||||
|
|
||||||
|
rtl_power_path = find_rtl_power()
|
||||||
|
if not rtl_power_path:
|
||||||
|
logger.error("rtl_power not found")
|
||||||
|
add_activity_log('error', 0, 'rtl_power not found')
|
||||||
|
scanner_running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while scanner_running:
|
||||||
|
if scanner_paused:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_mhz = scanner_config['start_freq']
|
||||||
|
end_mhz = scanner_config['end_freq']
|
||||||
|
step_khz = scanner_config['step']
|
||||||
|
gain = scanner_config['gain']
|
||||||
|
device = scanner_config['device']
|
||||||
|
squelch = scanner_config['squelch']
|
||||||
|
mod = scanner_config['modulation']
|
||||||
|
|
||||||
|
# Configure sweep
|
||||||
|
bin_hz = max(1000, int(step_khz * 1000))
|
||||||
|
start_hz = int(start_mhz * 1e6)
|
||||||
|
end_hz = int(end_mhz * 1e6)
|
||||||
|
# Integration time per sweep (seconds)
|
||||||
|
integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
rtl_power_path,
|
||||||
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
|
'-i', f'{integration}',
|
||||||
|
'-1',
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
scanner_power_process = proc
|
||||||
|
stdout, _ = proc.communicate(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
stdout = b''
|
||||||
|
finally:
|
||||||
|
scanner_power_process = None
|
||||||
|
|
||||||
|
if not scanner_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not stdout:
|
||||||
|
add_activity_log('error', start_mhz, 'Power sweep produced no data')
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': end_mhz,
|
||||||
|
'level': 0,
|
||||||
|
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
||||||
|
'detected': False,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = stdout.decode(errors='ignore').splitlines()
|
||||||
|
segments = []
|
||||||
|
for line in lines:
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
# Find start_hz token
|
||||||
|
start_idx = None
|
||||||
|
for i, tok in enumerate(parts):
|
||||||
|
try:
|
||||||
|
val = float(tok)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if val > 1e5:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None or len(parts) < start_idx + 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sweep_start = float(parts[start_idx])
|
||||||
|
sweep_end = float(parts[start_idx + 1])
|
||||||
|
sweep_bin = float(parts[start_idx + 2])
|
||||||
|
raw_values = []
|
||||||
|
for v in parts[start_idx + 3:]:
|
||||||
|
try:
|
||||||
|
raw_values.append(float(v))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
# rtl_power may include a samples field before the power list
|
||||||
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
|
raw_values = raw_values[1:]
|
||||||
|
bin_values = raw_values
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not bin_values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
add_activity_log('error', start_mhz, 'Power sweep bins missing')
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': end_mhz,
|
||||||
|
'level': 0,
|
||||||
|
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
||||||
|
'detected': False,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process segments in ascending frequency order to avoid backtracking in UI
|
||||||
|
segments.sort(key=lambda s: s[0])
|
||||||
|
total_bins = sum(len(seg[3]) for seg in segments)
|
||||||
|
if total_bins <= 0:
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
segment_offset = 0
|
||||||
|
|
||||||
|
for sweep_start, sweep_end, sweep_bin, bin_values in segments:
|
||||||
|
# Noise floor (median)
|
||||||
|
sorted_vals = sorted(bin_values)
|
||||||
|
mid = len(sorted_vals) // 2
|
||||||
|
noise_floor = sorted_vals[mid]
|
||||||
|
|
||||||
|
# SNR threshold (dB)
|
||||||
|
snr_threshold = float(scanner_config.get('snr_threshold', 12))
|
||||||
|
|
||||||
|
# Emit progress updates (throttled)
|
||||||
|
emit_stride = max(1, len(bin_values) // 60)
|
||||||
|
for idx, val in enumerate(bin_values):
|
||||||
|
if idx % emit_stride != 0 and idx != len(bin_values) - 1:
|
||||||
|
continue
|
||||||
|
freq_hz = sweep_start + sweep_bin * idx
|
||||||
|
scanner_current_freq = freq_hz / 1e6
|
||||||
|
snr = val - noise_floor
|
||||||
|
level = int(max(0, snr) * 100)
|
||||||
|
threshold = int(snr_threshold * 100)
|
||||||
|
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': scanner_current_freq,
|
||||||
|
'level': level,
|
||||||
|
'threshold': threshold,
|
||||||
|
'detected': snr >= snr_threshold,
|
||||||
|
'progress': progress,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
segment_offset += len(bin_values)
|
||||||
|
|
||||||
|
# Detect peaks (clusters above threshold)
|
||||||
|
peaks = []
|
||||||
|
in_cluster = False
|
||||||
|
peak_idx = None
|
||||||
|
peak_val = None
|
||||||
|
for idx, val in enumerate(bin_values):
|
||||||
|
snr = val - noise_floor
|
||||||
|
if snr >= snr_threshold:
|
||||||
|
if not in_cluster:
|
||||||
|
in_cluster = True
|
||||||
|
peak_idx = idx
|
||||||
|
peak_val = val
|
||||||
|
else:
|
||||||
|
if val > peak_val:
|
||||||
|
peak_val = val
|
||||||
|
peak_idx = idx
|
||||||
|
else:
|
||||||
|
if in_cluster and peak_idx is not None:
|
||||||
|
peaks.append((peak_idx, peak_val))
|
||||||
|
in_cluster = False
|
||||||
|
peak_idx = None
|
||||||
|
peak_val = None
|
||||||
|
if in_cluster and peak_idx is not None:
|
||||||
|
peaks.append((peak_idx, peak_val))
|
||||||
|
|
||||||
|
for idx, val in peaks:
|
||||||
|
freq_hz = sweep_start + sweep_bin * (idx + 0.5)
|
||||||
|
freq_mhz = freq_hz / 1e6
|
||||||
|
snr = val - noise_floor
|
||||||
|
level = int(max(0, snr) * 100)
|
||||||
|
threshold = int(snr_threshold * 100)
|
||||||
|
add_activity_log('signal_found', freq_mhz,
|
||||||
|
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_found',
|
||||||
|
'frequency': freq_mhz,
|
||||||
|
'modulation': mod,
|
||||||
|
'audio_streaming': False,
|
||||||
|
'level': level,
|
||||||
|
'threshold': threshold,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
|
||||||
|
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Power sweep scanner error: {e}")
|
||||||
|
finally:
|
||||||
|
scanner_running = False
|
||||||
|
add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
|
||||||
|
logger.info("Power sweep scanner thread stopped")
|
||||||
|
|
||||||
|
|
||||||
def _start_audio_stream(frequency: float, modulation: str):
|
def _start_audio_stream(frequency: float, modulation: str):
|
||||||
"""Start audio streaming at given frequency."""
|
"""Start audio streaming at given frequency."""
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
||||||
@@ -398,6 +675,8 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
]
|
]
|
||||||
if scanner_config.get('bias_t', False):
|
if scanner_config.get('bias_t', False):
|
||||||
sdr_cmd.append('-T')
|
sdr_cmd.append('-T')
|
||||||
|
# Explicitly output to stdout (some rtl_fm versions need this)
|
||||||
|
sdr_cmd.append('-')
|
||||||
else:
|
else:
|
||||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||||
rx_fm_path = find_rx_fm()
|
rx_fm_path = find_rx_fm()
|
||||||
@@ -426,21 +705,27 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
ffmpeg_path,
|
ffmpeg_path,
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'error',
|
'-loglevel', 'error',
|
||||||
|
'-fflags', 'nobuffer',
|
||||||
|
'-flags', 'low_delay',
|
||||||
|
'-probesize', '32',
|
||||||
|
'-analyzeduration', '0',
|
||||||
'-f', 's16le',
|
'-f', 's16le',
|
||||||
'-ar', str(resample_rate),
|
'-ar', str(resample_rate),
|
||||||
'-ac', '1',
|
'-ac', '1',
|
||||||
'-i', 'pipe:0',
|
'-i', 'pipe:0',
|
||||||
'-acodec', 'libmp3lame',
|
'-acodec', 'pcm_s16le',
|
||||||
'-b:a', '128k',
|
|
||||||
'-ar', '44100',
|
'-ar', '44100',
|
||||||
'-f', 'mp3',
|
'-f', 'wav',
|
||||||
'pipe:1'
|
'pipe:1'
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
|
# Use shell pipe for reliable streaming
|
||||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
|
# Log stderr to temp files for error diagnosis
|
||||||
logger.info(f"Starting audio pipeline: {shell_cmd}")
|
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
||||||
|
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
||||||
|
shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
|
||||||
|
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
|
||||||
|
|
||||||
audio_rtl_process = None # Not used in shell mode
|
audio_rtl_process = None # Not used in shell mode
|
||||||
audio_process = subprocess.Popen(
|
audio_process = subprocess.Popen(
|
||||||
@@ -456,10 +741,30 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
if audio_process.poll() is not None:
|
if audio_process.poll() is not None:
|
||||||
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
|
# Read stderr from temp files
|
||||||
logger.error(f"Audio pipeline exited immediately: {stderr}")
|
rtl_stderr = ''
|
||||||
|
ffmpeg_stderr = ''
|
||||||
|
try:
|
||||||
|
with open(rtl_stderr_log, 'r') as f:
|
||||||
|
rtl_stderr = f.read().strip()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(ffmpeg_stderr_log, 'r') as f:
|
||||||
|
ffmpeg_stderr = f.read().strip()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Validate that audio is producing data quickly
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
||||||
|
if not ready:
|
||||||
|
logger.warning("Audio pipeline produced no data in startup window")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Audio startup check failed: {e}")
|
||||||
|
|
||||||
audio_running = True
|
audio_running = True
|
||||||
audio_frequency = frequency
|
audio_frequency = frequency
|
||||||
audio_modulation = modulation
|
audio_modulation = modulation
|
||||||
@@ -520,6 +825,7 @@ def _stop_audio_stream_internal():
|
|||||||
def check_tools() -> Response:
|
def check_tools() -> Response:
|
||||||
"""Check for required tools."""
|
"""Check for required tools."""
|
||||||
rtl_fm = find_rtl_fm()
|
rtl_fm = find_rtl_fm()
|
||||||
|
rtl_power = find_rtl_power()
|
||||||
rx_fm = find_rx_fm()
|
rx_fm = find_rx_fm()
|
||||||
ffmpeg = find_ffmpeg()
|
ffmpeg = find_ffmpeg()
|
||||||
|
|
||||||
@@ -533,6 +839,7 @@ def check_tools() -> Response:
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'rtl_fm': rtl_fm is not None,
|
'rtl_fm': rtl_fm is not None,
|
||||||
|
'rtl_power': rtl_power is not None,
|
||||||
'rx_fm': rx_fm is not None,
|
'rx_fm': rx_fm is not None,
|
||||||
'ffmpeg': ffmpeg is not None,
|
'ffmpeg': ffmpeg is not None,
|
||||||
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
||||||
@@ -543,7 +850,7 @@ def check_tools() -> Response:
|
|||||||
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
||||||
def start_scanner() -> Response:
|
def start_scanner() -> Response:
|
||||||
"""Start the frequency scanner."""
|
"""Start the frequency scanner."""
|
||||||
global scanner_thread, scanner_running, scanner_config
|
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
|
||||||
|
|
||||||
with scanner_lock:
|
with scanner_lock:
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -552,6 +859,13 @@ def start_scanner() -> Response:
|
|||||||
'message': 'Scanner already running'
|
'message': 'Scanner already running'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
|
# Clear stale queue entries so UI updates immediately
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
scanner_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
# Update scanner config
|
# Update scanner config
|
||||||
@@ -560,13 +874,16 @@ def start_scanner() -> Response:
|
|||||||
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||||
scanner_config['step'] = float(data.get('step', 0.1))
|
scanner_config['step'] = float(data.get('step', 0.1))
|
||||||
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
|
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
|
||||||
scanner_config['squelch'] = int(data.get('squelch', 20))
|
scanner_config['squelch'] = int(data.get('squelch', 0))
|
||||||
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
||||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||||
scanner_config['device'] = int(data.get('device', 0))
|
scanner_config['device'] = int(data.get('device', 0))
|
||||||
scanner_config['gain'] = int(data.get('gain', 40))
|
scanner_config['gain'] = int(data.get('gain', 40))
|
||||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
||||||
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
|
||||||
|
if data.get('snr_threshold') is not None:
|
||||||
|
scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -580,25 +897,68 @@ def start_scanner() -> Response:
|
|||||||
'message': 'start_freq must be less than end_freq'
|
'message': 'start_freq must be less than end_freq'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Check tools based on SDR type
|
# Decide scan method
|
||||||
sdr_type = scanner_config['sdr_type']
|
if not scanner_config['scan_method']:
|
||||||
if sdr_type == 'rtlsdr':
|
scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
|
||||||
if not find_rtl_fm():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
|
||||||
}), 503
|
|
||||||
else:
|
|
||||||
if not find_rx_fm():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
# Start scanner thread
|
sdr_type = scanner_config['sdr_type']
|
||||||
scanner_running = True
|
|
||||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
# Power scan only supports RTL-SDR for now
|
||||||
scanner_thread.start()
|
if scanner_config['scan_method'] == 'power':
|
||||||
|
if sdr_type != 'rtlsdr' or not find_rtl_power():
|
||||||
|
scanner_config['scan_method'] = 'classic'
|
||||||
|
|
||||||
|
# Check tools based on chosen method
|
||||||
|
if scanner_config['scan_method'] == 'power':
|
||||||
|
if not find_rtl_power():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
||||||
|
}), 503
|
||||||
|
# Release listening device if active
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
listening_active_device = None
|
||||||
|
# Claim device for scanner
|
||||||
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
scanner_active_device = scanner_config['device']
|
||||||
|
scanner_running = True
|
||||||
|
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
||||||
|
scanner_thread.start()
|
||||||
|
else:
|
||||||
|
if sdr_type == 'rtlsdr':
|
||||||
|
if not find_rtl_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||||
|
}), 503
|
||||||
|
else:
|
||||||
|
if not find_rx_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||||
|
}), 503
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
listening_active_device = None
|
||||||
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
scanner_active_device = scanner_config['device']
|
||||||
|
|
||||||
|
scanner_running = True
|
||||||
|
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||||
|
scanner_thread.start()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
@@ -609,10 +969,23 @@ def start_scanner() -> Response:
|
|||||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
||||||
def stop_scanner() -> Response:
|
def stop_scanner() -> Response:
|
||||||
"""Stop the frequency scanner."""
|
"""Stop the frequency scanner."""
|
||||||
global scanner_running
|
global scanner_running, scanner_active_device, scanner_power_process
|
||||||
|
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
|
if scanner_power_process and scanner_power_process.poll() is None:
|
||||||
|
try:
|
||||||
|
scanner_power_process.terminate()
|
||||||
|
scanner_power_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
scanner_power_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
scanner_power_process = None
|
||||||
|
if scanner_active_device is not None:
|
||||||
|
app_module.release_sdr_device(scanner_active_device)
|
||||||
|
scanner_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -773,13 +1146,33 @@ def get_presets() -> Response:
|
|||||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
@listening_post_bp.route('/audio/start', methods=['POST'])
|
||||||
def start_audio() -> Response:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running
|
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
||||||
|
|
||||||
logger.info("Audio start request received")
|
|
||||||
|
|
||||||
# Stop scanner if running
|
# Stop scanner if running
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
|
if scanner_active_device is not None:
|
||||||
|
app_module.release_sdr_device(scanner_active_device)
|
||||||
|
scanner_active_device = None
|
||||||
|
if scanner_thread and scanner_thread.is_alive():
|
||||||
|
try:
|
||||||
|
scanner_thread.join(timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if scanner_power_process and scanner_power_process.poll() is None:
|
||||||
|
try:
|
||||||
|
scanner_power_process.terminate()
|
||||||
|
scanner_power_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
scanner_power_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
scanner_power_process = None
|
||||||
|
try:
|
||||||
|
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -823,6 +1216,19 @@ def start_audio() -> Response:
|
|||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
|
# Claim device for listening audio
|
||||||
|
if listening_active_device is None or listening_active_device != device:
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
error = app_module.claim_sdr_device(device, 'listening')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
listening_active_device = device
|
||||||
|
|
||||||
_start_audio_stream(frequency, modulation)
|
_start_audio_stream(frequency, modulation)
|
||||||
|
|
||||||
if audio_running:
|
if audio_running:
|
||||||
@@ -841,7 +1247,11 @@ def start_audio() -> Response:
|
|||||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
||||||
def stop_audio() -> Response:
|
def stop_audio() -> Response:
|
||||||
"""Stop audio."""
|
"""Stop audio."""
|
||||||
|
global listening_active_device
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
|
if listening_active_device is not None:
|
||||||
|
app_module.release_sdr_device(listening_active_device)
|
||||||
|
listening_active_device = None
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -855,9 +1265,71 @@ def audio_status() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/debug')
|
||||||
|
def audio_debug() -> Response:
|
||||||
|
"""Get audio debug status and recent stderr logs."""
|
||||||
|
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
||||||
|
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
|
||||||
|
def _read_log(path: str) -> str:
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as handle:
|
||||||
|
return handle.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': audio_running,
|
||||||
|
'frequency': audio_frequency,
|
||||||
|
'modulation': audio_modulation,
|
||||||
|
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||||
|
'device': scanner_config.get('device', 0),
|
||||||
|
'gain': scanner_config.get('gain', 0),
|
||||||
|
'squelch': scanner_config.get('squelch', 0),
|
||||||
|
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
||||||
|
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||||
|
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
||||||
|
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/probe')
|
||||||
|
def audio_probe() -> Response:
|
||||||
|
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||||
|
global audio_process
|
||||||
|
|
||||||
|
if not audio_process or not audio_process.stdout:
|
||||||
|
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||||
|
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
||||||
|
if not ready:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no data available'}), 504
|
||||||
|
data = audio_process.stdout.read(4096)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no data read'}), 504
|
||||||
|
with open(sample_path, 'wb') as handle:
|
||||||
|
handle.write(data)
|
||||||
|
size = len(data)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'bytes': size})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stream')
|
@listening_post_bp.route('/audio/stream')
|
||||||
def stream_audio() -> Response:
|
def stream_audio() -> Response:
|
||||||
"""Stream MP3 audio."""
|
"""Stream WAV audio."""
|
||||||
|
# Optionally restart pipeline so the stream starts with a fresh header
|
||||||
|
if request.args.get('fresh') == '1' and audio_running:
|
||||||
|
try:
|
||||||
|
_start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Audio stream restart failed: {e}")
|
||||||
|
|
||||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if audio_running and audio_process:
|
if audio_running and audio_process:
|
||||||
@@ -868,26 +1340,40 @@ def stream_audio() -> Response:
|
|||||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
|
# Capture local reference to avoid race condition with stop
|
||||||
|
proc = audio_process
|
||||||
|
if not proc or not proc.stdout:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
while audio_running and audio_process and audio_process.poll() is None:
|
# First byte timeout to avoid hanging clients forever
|
||||||
|
first_chunk_deadline = time.time() + 3.0
|
||||||
|
while audio_running and proc.poll() is None:
|
||||||
# Use select to avoid blocking forever
|
# Use select to avoid blocking forever
|
||||||
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||||
if ready:
|
if ready:
|
||||||
chunk = audio_process.stdout.read(4096)
|
chunk = proc.stdout.read(4096)
|
||||||
if chunk:
|
if chunk:
|
||||||
yield chunk
|
yield chunk
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
# If no data arrives shortly after start, exit so caller can retry
|
||||||
|
if time.time() > first_chunk_deadline:
|
||||||
|
logger.warning("Audio stream timed out waiting for first chunk")
|
||||||
|
break
|
||||||
|
# Timeout - check if process died
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
except GeneratorExit:
|
except GeneratorExit:
|
||||||
pass
|
pass
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.error(f"Audio stream error: {e}")
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
generate(),
|
generate(),
|
||||||
mimetype='audio/mpeg',
|
mimetype='audio/wav',
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'audio/mpeg',
|
'Content-Type': 'audio/wav',
|
||||||
'Cache-Control': 'no-cache, no-store',
|
'Cache-Control': 'no-cache, no-store',
|
||||||
'X-Accel-Buffering': 'no',
|
'X-Accel-Buffering': 'no',
|
||||||
'Transfer-Encoding': 'chunked',
|
'Transfer-Encoding': 'chunked',
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Offline mode routes - Asset management and settings for offline operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from utils.database import get_setting, set_setting
|
||||||
|
import os
|
||||||
|
|
||||||
|
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||||
|
|
||||||
|
# Default offline settings
|
||||||
|
OFFLINE_DEFAULTS = {
|
||||||
|
'offline.enabled': False,
|
||||||
|
'offline.assets_source': 'cdn',
|
||||||
|
'offline.fonts_source': 'cdn',
|
||||||
|
'offline.tile_provider': 'openstreetmap',
|
||||||
|
'offline.tile_server_url': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asset paths to check
|
||||||
|
ASSET_PATHS = {
|
||||||
|
'leaflet': [
|
||||||
|
'static/vendor/leaflet/leaflet.js',
|
||||||
|
'static/vendor/leaflet/leaflet.css'
|
||||||
|
],
|
||||||
|
'chartjs': [
|
||||||
|
'static/vendor/chartjs/chart.umd.min.js'
|
||||||
|
],
|
||||||
|
'inter': [
|
||||||
|
'static/vendor/fonts/Inter-Regular.woff2',
|
||||||
|
'static/vendor/fonts/Inter-Medium.woff2',
|
||||||
|
'static/vendor/fonts/Inter-SemiBold.woff2',
|
||||||
|
'static/vendor/fonts/Inter-Bold.woff2'
|
||||||
|
],
|
||||||
|
'jetbrains': [
|
||||||
|
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
|
||||||
|
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
|
||||||
|
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
|
||||||
|
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
|
||||||
|
],
|
||||||
|
'leaflet_images': [
|
||||||
|
'static/vendor/leaflet/images/marker-icon.png',
|
||||||
|
'static/vendor/leaflet/images/marker-icon-2x.png',
|
||||||
|
'static/vendor/leaflet/images/marker-shadow.png',
|
||||||
|
'static/vendor/leaflet/images/layers.png',
|
||||||
|
'static/vendor/leaflet/images/layers-2x.png'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_offline_settings():
|
||||||
|
"""Get all offline settings with defaults."""
|
||||||
|
settings = {}
|
||||||
|
for key, default in OFFLINE_DEFAULTS.items():
|
||||||
|
settings[key] = get_setting(key, default)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/settings', methods=['GET'])
|
||||||
|
def get_settings():
|
||||||
|
"""Get current offline settings."""
|
||||||
|
settings = get_offline_settings()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'settings': settings
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/settings', methods=['POST'])
|
||||||
|
def save_setting():
|
||||||
|
"""Save an offline setting."""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'key' not in data or 'value' not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
||||||
|
|
||||||
|
key = data['key']
|
||||||
|
value = data['value']
|
||||||
|
|
||||||
|
# Validate key is an allowed setting
|
||||||
|
if key not in OFFLINE_DEFAULTS:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
|
||||||
|
|
||||||
|
# Validate value type matches default
|
||||||
|
default_type = type(OFFLINE_DEFAULTS[key])
|
||||||
|
if not isinstance(value, default_type):
|
||||||
|
# Try to convert
|
||||||
|
try:
|
||||||
|
if default_type == bool:
|
||||||
|
value = str(value).lower() in ('true', '1', 'yes')
|
||||||
|
else:
|
||||||
|
value = default_type(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid value type for {key}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
set_setting(key, value)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'value': value
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/status', methods=['GET'])
|
||||||
|
def get_status():
|
||||||
|
"""Check status of local assets."""
|
||||||
|
# Get the app root directory
|
||||||
|
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
all_available = True
|
||||||
|
|
||||||
|
for asset_name, paths in ASSET_PATHS.items():
|
||||||
|
available = True
|
||||||
|
missing = []
|
||||||
|
for path in paths:
|
||||||
|
full_path = os.path.join(app_root, path)
|
||||||
|
if not os.path.exists(full_path):
|
||||||
|
available = False
|
||||||
|
missing.append(path)
|
||||||
|
|
||||||
|
results[asset_name] = {
|
||||||
|
'available': available,
|
||||||
|
'missing': missing if not available else []
|
||||||
|
}
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
all_available = False
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'all_available': all_available,
|
||||||
|
'assets': results,
|
||||||
|
'offline_enabled': get_setting('offline.enabled', False)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/check-asset', methods=['GET'])
|
||||||
|
def check_asset():
|
||||||
|
"""Check if a specific asset file exists."""
|
||||||
|
path = request.args.get('path', '')
|
||||||
|
if not path:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
||||||
|
|
||||||
|
# Security: only allow checking within static/vendor
|
||||||
|
if not path.startswith('/static/vendor/'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
|
||||||
|
|
||||||
|
# Remove leading slash and construct full path
|
||||||
|
relative_path = path.lstrip('/')
|
||||||
|
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
full_path = os.path.join(app_root, relative_path)
|
||||||
|
|
||||||
|
exists = os.path.exists(full_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'path': path,
|
||||||
|
'exists': exists
|
||||||
|
})
|
||||||
@@ -29,6 +29,9 @@ from utils.dependencies import get_tool_path
|
|||||||
|
|
||||||
pager_bp = Blueprint('pager', __name__)
|
pager_bp = Blueprint('pager', __name__)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
pager_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||||
"""Parse multimon-ng output line."""
|
"""Parse multimon-ng output line."""
|
||||||
@@ -155,6 +158,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
|
|
||||||
@pager_bp.route('/start', methods=['POST'])
|
@pager_bp.route('/start', methods=['POST'])
|
||||||
def start_decoding() -> Response:
|
def start_decoding() -> Response:
|
||||||
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||||
@@ -178,10 +183,29 @@ def start_decoding() -> Response:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||||
|
|
||||||
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Claim local device if not using remote rtl_tcp
|
||||||
|
if not rtl_tcp_host:
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
pager_active_device = device_int
|
||||||
|
|
||||||
# Validate protocols
|
# Validate protocols
|
||||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||||
protocols = data.get('protocols', valid_protocols)
|
protocols = data.get('protocols', valid_protocols)
|
||||||
if not isinstance(protocols, list):
|
if not isinstance(protocols, list):
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
@@ -213,10 +237,6 @@ def start_decoding() -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -302,13 +322,23 @@ def start_decoding() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
# Release device on failure
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
# Kill rtl_fm process first
|
# Kill rtl_fm process first
|
||||||
@@ -337,6 +367,12 @@ def stop_decoding() -> Response:
|
|||||||
app_module.current_process.kill()
|
app_module.current_process.kill()
|
||||||
|
|
||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
return jsonify({'status': 'not_running'})
|
return jsonify({'status': 'not_running'})
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""RTLAMR utility meter monitoring routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.validation import (
|
||||||
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.process import safe_terminate, register_process
|
||||||
|
|
||||||
|
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||||
|
|
||||||
|
# Store rtl_tcp process separately
|
||||||
|
rtl_tcp_process = None
|
||||||
|
rtl_tcp_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
rtlamr_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
|
"""Stream rtlamr JSON output to queue."""
|
||||||
|
try:
|
||||||
|
app_module.rtlamr_queue.put({'type': 'status', 'text': 'started'})
|
||||||
|
|
||||||
|
for line in iter(process.stdout.readline, b''):
|
||||||
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# rtlamr outputs JSON objects, one per line
|
||||||
|
data = json.loads(line)
|
||||||
|
data['type'] = 'rtlamr'
|
||||||
|
app_module.rtlamr_queue.put(data)
|
||||||
|
|
||||||
|
# Log if enabled
|
||||||
|
if app_module.logging_enabled:
|
||||||
|
try:
|
||||||
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"{timestamp} | RTLAMR | {json.dumps(data)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON, send as raw
|
||||||
|
app_module.rtlamr_queue.put({'type': 'raw', 'text': line})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||||
|
finally:
|
||||||
|
process.wait()
|
||||||
|
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
|
with app_module.rtlamr_lock:
|
||||||
|
app_module.rtlamr_process = None
|
||||||
|
|
||||||
|
|
||||||
|
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||||
|
def start_rtlamr() -> Response:
|
||||||
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
|
with app_module.rtlamr_lock:
|
||||||
|
if app_module.rtlamr_process:
|
||||||
|
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
freq = validate_frequency(data.get('frequency', '912.0'))
|
||||||
|
gain = validate_gain(data.get('gain', '0'))
|
||||||
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
rtlamr_active_device = device_int
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.rtlamr_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.rtlamr_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get message type (default to scm)
|
||||||
|
msgtype = data.get('msgtype', 'scm')
|
||||||
|
output_format = data.get('format', 'json')
|
||||||
|
|
||||||
|
# Start rtl_tcp first
|
||||||
|
with rtl_tcp_lock:
|
||||||
|
if not rtl_tcp_process:
|
||||||
|
logger.info("Starting rtl_tcp server...")
|
||||||
|
try:
|
||||||
|
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
|
||||||
|
|
||||||
|
# Add device index if not 0
|
||||||
|
if device and device != '0':
|
||||||
|
rtl_tcp_cmd.extend(['-d', str(device)])
|
||||||
|
|
||||||
|
# Add gain if not auto
|
||||||
|
if gain and gain != '0':
|
||||||
|
rtl_tcp_cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
|
# Add PPM correction if not 0
|
||||||
|
if ppm and ppm != '0':
|
||||||
|
rtl_tcp_cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
|
rtl_tcp_process = subprocess.Popen(
|
||||||
|
rtl_tcp_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a moment for rtl_tcp to start
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
|
||||||
|
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||||
|
|
||||||
|
# Build rtlamr command
|
||||||
|
cmd = [
|
||||||
|
'rtlamr',
|
||||||
|
'-server=127.0.0.1:1234',
|
||||||
|
f'-msgtype={msgtype}',
|
||||||
|
f'-format={output_format}',
|
||||||
|
f'-centerfreq={int(float(freq) * 1e6)}'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add filter options if provided
|
||||||
|
filterid = data.get('filterid')
|
||||||
|
if filterid:
|
||||||
|
cmd.append(f'-filterid={filterid}')
|
||||||
|
|
||||||
|
filtertype = data.get('filtertype')
|
||||||
|
if filtertype:
|
||||||
|
cmd.append(f'-filtertype={filtertype}')
|
||||||
|
|
||||||
|
# Unique messages only
|
||||||
|
if data.get('unique', True):
|
||||||
|
cmd.append('-unique=true')
|
||||||
|
|
||||||
|
full_cmd = ' '.join(cmd)
|
||||||
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.rtlamr_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start output thread
|
||||||
|
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# Monitor stderr
|
||||||
|
def monitor_stderr():
|
||||||
|
for line in app_module.rtlamr_process.stderr:
|
||||||
|
err = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if err:
|
||||||
|
logger.debug(f"[rtlamr] {err}")
|
||||||
|
app_module.rtlamr_queue.put({'type': 'info', 'text': f'[rtlamr] {err}'})
|
||||||
|
|
||||||
|
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||||
|
stderr_thread.daemon = True
|
||||||
|
stderr_thread.start()
|
||||||
|
|
||||||
|
app_module.rtlamr_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||||
|
|
||||||
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
|
with rtl_tcp_lock:
|
||||||
|
if rtl_tcp_process:
|
||||||
|
rtl_tcp_process.terminate()
|
||||||
|
rtl_tcp_process.wait(timeout=2)
|
||||||
|
rtl_tcp_process = None
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
|
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||||
|
except Exception as e:
|
||||||
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
|
with rtl_tcp_lock:
|
||||||
|
if rtl_tcp_process:
|
||||||
|
rtl_tcp_process.terminate()
|
||||||
|
rtl_tcp_process.wait(timeout=2)
|
||||||
|
rtl_tcp_process = None
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||||
|
def stop_rtlamr() -> Response:
|
||||||
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
|
with app_module.rtlamr_lock:
|
||||||
|
if app_module.rtlamr_process:
|
||||||
|
app_module.rtlamr_process.terminate()
|
||||||
|
try:
|
||||||
|
app_module.rtlamr_process.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app_module.rtlamr_process.kill()
|
||||||
|
app_module.rtlamr_process = None
|
||||||
|
|
||||||
|
# Also stop rtl_tcp
|
||||||
|
with rtl_tcp_lock:
|
||||||
|
if rtl_tcp_process:
|
||||||
|
rtl_tcp_process.terminate()
|
||||||
|
try:
|
||||||
|
rtl_tcp_process.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rtl_tcp_process.kill()
|
||||||
|
rtl_tcp_process = None
|
||||||
|
logger.info("rtl_tcp stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@rtlamr_bp.route('/stream_rtlamr')
|
||||||
|
def stream_rtlamr() -> Response:
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
@@ -3,13 +3,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, render_template, Response
|
from flask import Blueprint, jsonify, request, render_template, Response
|
||||||
|
|
||||||
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
from utils.logging import satellite_logger as logger
|
from utils.logging import satellite_logger as logger
|
||||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||||
@@ -26,10 +31,101 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Fetch real-time ISS position from external APIs.
|
||||||
|
|
||||||
|
Returns position data dict or None if all APIs fail.
|
||||||
|
"""
|
||||||
|
iss_lat = None
|
||||||
|
iss_lon = None
|
||||||
|
iss_alt = 420 # Default altitude in km
|
||||||
|
source = None
|
||||||
|
|
||||||
|
# Try primary API: Open Notify
|
||||||
|
try:
|
||||||
|
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('message') == 'success':
|
||||||
|
iss_lat = float(data['iss_position']['latitude'])
|
||||||
|
iss_lon = float(data['iss_position']['longitude'])
|
||||||
|
source = 'open-notify'
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Open Notify API failed: {e}")
|
||||||
|
|
||||||
|
# Try fallback API: Where The ISS At
|
||||||
|
if iss_lat is None:
|
||||||
|
try:
|
||||||
|
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
iss_lat = float(data['latitude'])
|
||||||
|
iss_lon = float(data['longitude'])
|
||||||
|
iss_alt = float(data.get('altitude', 420))
|
||||||
|
source = 'wheretheiss'
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Where The ISS At API failed: {e}")
|
||||||
|
|
||||||
|
if iss_lat is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'satellite': 'ISS',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': iss_alt,
|
||||||
|
'source': source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
# Earth radius in km
|
||||||
|
earth_radius = 6371
|
||||||
|
|
||||||
|
# Convert to radians
|
||||||
|
lat1 = math.radians(observer_lat)
|
||||||
|
lat2 = math.radians(iss_lat)
|
||||||
|
lon1 = math.radians(observer_lon)
|
||||||
|
lon2 = math.radians(iss_lon)
|
||||||
|
|
||||||
|
# Haversine for ground distance
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||||
|
c = 2 * math.asin(math.sqrt(a))
|
||||||
|
ground_distance = earth_radius * c
|
||||||
|
|
||||||
|
# Calculate slant range
|
||||||
|
slant_range = math.sqrt(ground_distance**2 + iss_alt**2)
|
||||||
|
|
||||||
|
# Calculate elevation angle (simplified)
|
||||||
|
if ground_distance > 0:
|
||||||
|
elevation = math.degrees(math.atan2(iss_alt - (ground_distance**2 / (2 * earth_radius)), ground_distance))
|
||||||
|
else:
|
||||||
|
elevation = 90.0
|
||||||
|
|
||||||
|
# Calculate azimuth
|
||||||
|
y = math.sin(dlon) * math.cos(lat2)
|
||||||
|
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
||||||
|
azimuth = math.degrees(math.atan2(y, x))
|
||||||
|
azimuth = (azimuth + 360) % 360
|
||||||
|
|
||||||
|
result['elevation'] = round(elevation, 1)
|
||||||
|
result['azimuth'] = round(azimuth, 1)
|
||||||
|
result['distance'] = round(slant_range, 1)
|
||||||
|
result['visible'] = elevation > 0
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/dashboard')
|
@satellite_bp.route('/dashboard')
|
||||||
def satellite_dashboard():
|
def satellite_dashboard():
|
||||||
"""Popout satellite tracking dashboard."""
|
"""Popout satellite tracking dashboard."""
|
||||||
return render_template('satellite_dashboard.html')
|
return render_template(
|
||||||
|
'satellite_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/predict', methods=['POST'])
|
@satellite_bp.route('/predict', methods=['POST'])
|
||||||
@@ -239,6 +335,35 @@ def get_satellite_position():
|
|||||||
positions = []
|
positions = []
|
||||||
|
|
||||||
for sat_name in satellites:
|
for sat_name in satellites:
|
||||||
|
# Special handling for ISS - use real-time API for accurate position
|
||||||
|
if sat_name == 'ISS':
|
||||||
|
iss_data = _fetch_iss_realtime(lat, lon)
|
||||||
|
if iss_data:
|
||||||
|
# Add orbit track if requested (using TLE for track prediction)
|
||||||
|
if include_track and 'ISS' in _tle_cache:
|
||||||
|
try:
|
||||||
|
tle_data = _tle_cache['ISS']
|
||||||
|
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||||
|
orbit_track = []
|
||||||
|
for minutes_offset in range(-45, 46, 1):
|
||||||
|
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
|
||||||
|
try:
|
||||||
|
geo = satellite.at(t_point)
|
||||||
|
sp = wgs84.subpoint(geo)
|
||||||
|
orbit_track.append({
|
||||||
|
'lat': float(sp.latitude.degrees),
|
||||||
|
'lon': float(sp.longitude.degrees),
|
||||||
|
'past': minutes_offset < 0
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
iss_data['track'] = orbit_track
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
positions.append(iss_data)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Other satellites - use TLE data
|
||||||
if sat_name not in _tle_cache:
|
if sat_name not in _tle_cache:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -292,56 +417,69 @@ def get_satellite_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/update-tle', methods=['POST'])
|
def refresh_tle_data() -> list:
|
||||||
def update_tle():
|
"""
|
||||||
"""Update TLE data from CelesTrak."""
|
Refresh TLE data from CelesTrak.
|
||||||
|
|
||||||
|
This can be called at startup or periodically to keep TLE data fresh.
|
||||||
|
Returns list of satellite names that were updated.
|
||||||
|
"""
|
||||||
global _tle_cache
|
global _tle_cache
|
||||||
|
|
||||||
try:
|
name_mappings = {
|
||||||
name_mappings = {
|
'ISS (ZARYA)': 'ISS',
|
||||||
'ISS (ZARYA)': 'ISS',
|
'NOAA 15': 'NOAA-15',
|
||||||
'NOAA 15': 'NOAA-15',
|
'NOAA 18': 'NOAA-18',
|
||||||
'NOAA 18': 'NOAA-18',
|
'NOAA 19': 'NOAA-19',
|
||||||
'NOAA 19': 'NOAA-19',
|
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
||||||
'METEOR-M 2': 'METEOR-M2',
|
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
||||||
'METEOR-M2 3': 'METEOR-M2-3'
|
'METEOR-M 2': 'METEOR-M2',
|
||||||
}
|
'METEOR-M2 3': 'METEOR-M2-3'
|
||||||
|
}
|
||||||
|
|
||||||
updated = []
|
updated = []
|
||||||
|
|
||||||
for group in ['stations', 'weather']:
|
for group in ['stations', 'weather', 'noaa']:
|
||||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=10) as response:
|
with urllib.request.urlopen(url, timeout=15) as response:
|
||||||
content = response.read().decode('utf-8')
|
content = response.read().decode('utf-8')
|
||||||
lines = content.strip().split('\n')
|
lines = content.strip().split('\n')
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while i + 2 < len(lines):
|
while i + 2 < len(lines):
|
||||||
name = lines[i].strip()
|
name = lines[i].strip()
|
||||||
line1 = lines[i + 1].strip()
|
line1 = lines[i + 1].strip()
|
||||||
line2 = lines[i + 2].strip()
|
line2 = lines[i + 2].strip()
|
||||||
|
|
||||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
internal_name = name_mappings.get(name, name)
|
internal_name = name_mappings.get(name, name)
|
||||||
|
|
||||||
if internal_name in _tle_cache:
|
if internal_name in _tle_cache:
|
||||||
_tle_cache[internal_name] = (name, line1, line2)
|
_tle_cache[internal_name] = (name, line1, line2)
|
||||||
|
if internal_name not in updated:
|
||||||
updated.append(internal_name)
|
updated.append(internal_name)
|
||||||
|
|
||||||
i += 3
|
i += 3
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching {group}: {e}")
|
logger.warning(f"Error fetching TLE group {group}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/update-tle', methods=['POST'])
|
||||||
|
def update_tle():
|
||||||
|
"""Update TLE data from CelesTrak (API endpoint)."""
|
||||||
|
try:
|
||||||
|
updated = refresh_tle_data()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'updated': updated
|
'updated': updated
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ from utils.sdr import SDRFactory, SDRType
|
|||||||
|
|
||||||
sensor_bp = Blueprint('sensor', __name__)
|
sensor_bp = Blueprint('sensor', __name__)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
sensor_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
@@ -64,6 +67,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||||
@@ -79,6 +84,22 @@ def start_sensor() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Claim local device if not using remote rtl_tcp
|
||||||
|
if not rtl_tcp_host:
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
sensor_active_device = device_int
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.sensor_queue.empty():
|
while not app_module.sensor_queue.empty():
|
||||||
try:
|
try:
|
||||||
@@ -93,10 +114,6 @@ def start_sensor() -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -155,13 +172,23 @@ def start_sensor() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
# Release device on failure
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
def stop_sensor() -> Response:
|
def stop_sensor() -> Response:
|
||||||
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
app_module.sensor_process.terminate()
|
app_module.sensor_process.terminate()
|
||||||
@@ -170,6 +197,12 @@ def stop_sensor() -> Response:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
app_module.sensor_process.kill()
|
app_module.sensor_process.kill()
|
||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
return jsonify({'status': 'not_running'})
|
return jsonify({'status': 'not_running'})
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
@@ -164,3 +168,123 @@ def get_device_correlations() -> Response:
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RTL-SDR DVB Driver Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DVB_MODULES = ['dvb_usb_rtl28xxu', 'rtl2832_sdr', 'rtl2832', 'rtl2830', 'r820t']
|
||||||
|
BLACKLIST_FILE = '/etc/modprobe.d/blacklist-rtlsdr.conf'
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/rtlsdr/driver-status', methods=['GET'])
|
||||||
|
def check_dvb_driver_status() -> Response:
|
||||||
|
"""Check if DVB kernel drivers are loaded and blocking RTL-SDR devices."""
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'platform': sys.platform,
|
||||||
|
'issue_detected': False,
|
||||||
|
'message': 'DVB driver conflict only affects Linux systems'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check which DVB modules are currently loaded
|
||||||
|
loaded_modules = []
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['lsmod'], capture_output=True, text=True, timeout=5)
|
||||||
|
lsmod_output = result.stdout
|
||||||
|
for mod in DVB_MODULES:
|
||||||
|
if mod in lsmod_output:
|
||||||
|
loaded_modules.append(mod)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not check loaded modules: {e}")
|
||||||
|
|
||||||
|
# Check if blacklist file exists
|
||||||
|
blacklist_exists = os.path.exists(BLACKLIST_FILE)
|
||||||
|
|
||||||
|
# Check blacklist file contents
|
||||||
|
blacklist_contents = []
|
||||||
|
if blacklist_exists:
|
||||||
|
try:
|
||||||
|
with open(BLACKLIST_FILE, 'r') as f:
|
||||||
|
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
issue_detected = len(loaded_modules) > 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'platform': 'linux',
|
||||||
|
'issue_detected': issue_detected,
|
||||||
|
'loaded_modules': loaded_modules,
|
||||||
|
'blacklist_file_exists': blacklist_exists,
|
||||||
|
'blacklist_contents': blacklist_contents,
|
||||||
|
'message': 'DVB drivers are claiming RTL-SDR devices' if issue_detected else 'No DVB driver conflict detected'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/rtlsdr/blacklist-drivers', methods=['POST'])
|
||||||
|
def blacklist_dvb_drivers() -> Response:
|
||||||
|
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'This feature is only available on Linux'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Check if we have permission (need to be running as root or with sudo)
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
successes = []
|
||||||
|
|
||||||
|
# Create blacklist file if it doesn't exist
|
||||||
|
if not os.path.exists(BLACKLIST_FILE):
|
||||||
|
try:
|
||||||
|
blacklist_content = """# RTL-SDR blacklist - prevents DVB drivers from claiming RTL-SDR devices
|
||||||
|
# Created by INTERCEPT
|
||||||
|
blacklist dvb_usb_rtl28xxu
|
||||||
|
blacklist rtl2832
|
||||||
|
blacklist rtl2830
|
||||||
|
blacklist r820t
|
||||||
|
"""
|
||||||
|
with open(BLACKLIST_FILE, 'w') as f:
|
||||||
|
f.write(blacklist_content)
|
||||||
|
successes.append(f'Created {BLACKLIST_FILE}')
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'Failed to create blacklist file: {e}')
|
||||||
|
|
||||||
|
# Unload the modules
|
||||||
|
for mod in DVB_MODULES:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['modprobe', '-r', mod],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
successes.append(f'Unloaded module: {mod}')
|
||||||
|
# returncode != 0 is OK - module might not be loaded
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not unload {mod}: {e}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'partial',
|
||||||
|
'message': 'Some operations failed. Please unplug and replug your RTL-SDR device.',
|
||||||
|
'successes': successes,
|
||||||
|
'errors': errors
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.',
|
||||||
|
'successes': successes
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
"""Spy Stations routes - Number stations and diplomatic HF networks."""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations')
|
||||||
|
|
||||||
|
# Active spy stations data from priyom.org
|
||||||
|
STATIONS = [
|
||||||
|
# Number Stations (Intelligence)
|
||||||
|
{
|
||||||
|
"id": "e06",
|
||||||
|
"name": "E06",
|
||||||
|
"nickname": "English Man",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 4310, "primary": True},
|
||||||
|
{"freq_khz": 4800, "primary": False},
|
||||||
|
{"freq_khz": 5370, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB+carrier",
|
||||||
|
"description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.",
|
||||||
|
"operator": "Russian 6",
|
||||||
|
"schedule": "Weekdays, 2 transmissions 1 hour apart",
|
||||||
|
"source_url": "https://priyom.org/number-stations/english/e06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s06",
|
||||||
|
"name": "S06",
|
||||||
|
"nickname": "Russian Man",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 4310, "primary": True},
|
||||||
|
{"freq_khz": 4800, "primary": False},
|
||||||
|
{"freq_khz": 5370, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB+carrier",
|
||||||
|
"description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.",
|
||||||
|
"operator": "Russian 6",
|
||||||
|
"schedule": "Same schedule as E06, alternating languages",
|
||||||
|
"source_url": "https://priyom.org/number-stations/russian/s06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uvb76",
|
||||||
|
"name": "UVB-76",
|
||||||
|
"nickname": "The Buzzer",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 4625, "primary": True},
|
||||||
|
{"freq_khz": 5779, "primary": False},
|
||||||
|
{"freq_khz": 6810, "primary": False},
|
||||||
|
{"freq_khz": 7490, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.",
|
||||||
|
"operator": "Russian Military",
|
||||||
|
"schedule": "24/7 continuous operation",
|
||||||
|
"source_url": "https://priyom.org/number-stations/russia/uvb-76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hm01",
|
||||||
|
"name": "HM01",
|
||||||
|
"nickname": "Cuban Numbers",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Cuba",
|
||||||
|
"country_code": "CU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 9065, "primary": True},
|
||||||
|
{"freq_khz": 9155, "primary": False},
|
||||||
|
{"freq_khz": 9240, "primary": False},
|
||||||
|
{"freq_khz": 9330, "primary": False},
|
||||||
|
{"freq_khz": 10345, "primary": False},
|
||||||
|
{"freq_khz": 10715, "primary": False},
|
||||||
|
{"freq_khz": 10860, "primary": False},
|
||||||
|
{"freq_khz": 11435, "primary": False},
|
||||||
|
{"freq_khz": 11462, "primary": False},
|
||||||
|
{"freq_khz": 11530, "primary": False},
|
||||||
|
{"freq_khz": 11635, "primary": False},
|
||||||
|
{"freq_khz": 12180, "primary": False},
|
||||||
|
{"freq_khz": 13435, "primary": False},
|
||||||
|
{"freq_khz": 14375, "primary": False},
|
||||||
|
{"freq_khz": 16180, "primary": False},
|
||||||
|
{"freq_khz": 17480, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "AM/OFDM",
|
||||||
|
"description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.",
|
||||||
|
"operator": "DGI (Cuban Intelligence)",
|
||||||
|
"schedule": "Multiple daily transmissions",
|
||||||
|
"source_url": "https://priyom.org/number-stations/cuba/hm01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e07",
|
||||||
|
"name": "E07",
|
||||||
|
"nickname": "7-dash",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 5292, "primary": True},
|
||||||
|
{"freq_khz": 6388, "primary": False},
|
||||||
|
{"freq_khz": 7482, "primary": False},
|
||||||
|
{"freq_khz": 8576, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Russian intelligence station using distinctive 7-dash interval signal. Female voice reading 5-figure groups in English. Part of the 'Russian 7' operator network.",
|
||||||
|
"operator": "Russian 7",
|
||||||
|
"schedule": "Irregular, typically evenings UTC",
|
||||||
|
"source_url": "https://priyom.org/number-stations/english/e07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e11",
|
||||||
|
"name": "E11",
|
||||||
|
"nickname": "Mazielka",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Poland",
|
||||||
|
"country_code": "PL",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 4030, "primary": True},
|
||||||
|
{"freq_khz": 5240, "primary": False},
|
||||||
|
{"freq_khz": 6910, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Polish intelligence number station. Female voice reads 5-figure groups in English. Named after distinctive melody interval signal.",
|
||||||
|
"operator": "ABW (Polish Intelligence)",
|
||||||
|
"schedule": "Weekly transmissions",
|
||||||
|
"source_url": "https://priyom.org/number-stations/english/e11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e17z",
|
||||||
|
"name": "E17z",
|
||||||
|
"nickname": "Israeli Numbers",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Israel",
|
||||||
|
"country_code": "IL",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 4779, "primary": True},
|
||||||
|
{"freq_khz": 5091, "primary": False},
|
||||||
|
{"freq_khz": 6446, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Israeli intelligence number station. Female voice with distinctive Hebrew-accented English. Transmits 5-figure groups with phonetic alphabet.",
|
||||||
|
"operator": "Mossad (suspected)",
|
||||||
|
"schedule": "Irregular schedule",
|
||||||
|
"source_url": "https://priyom.org/number-stations/english/e17z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g06",
|
||||||
|
"name": "G06",
|
||||||
|
"nickname": "Russian German",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 4310, "primary": True},
|
||||||
|
{"freq_khz": 4800, "primary": False},
|
||||||
|
{"freq_khz": 5370, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB+carrier",
|
||||||
|
"description": "German language mode of Russian 6 operator. Male synthesized voice reads 5-figure groups in German. Shares frequencies with E06/S06.",
|
||||||
|
"operator": "Russian 6",
|
||||||
|
"schedule": "Same schedule as E06",
|
||||||
|
"source_url": "https://priyom.org/number-stations/german/g06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "v02a",
|
||||||
|
"name": "V02a",
|
||||||
|
"nickname": "Cuban Spy Numbers",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Cuba",
|
||||||
|
"country_code": "CU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 5855, "primary": True},
|
||||||
|
{"freq_khz": 9330, "primary": False},
|
||||||
|
{"freq_khz": 11635, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "AM",
|
||||||
|
"description": "Cuban intelligence station using AM mode. Female Spanish voice reading 4-figure groups. Related to HM01 but separate schedule.",
|
||||||
|
"operator": "DGI (Cuban Intelligence)",
|
||||||
|
"schedule": "Evening transmissions, weekdays",
|
||||||
|
"source_url": "https://priyom.org/number-stations/spanish/v02a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "v07",
|
||||||
|
"name": "V07",
|
||||||
|
"nickname": "Russian 7 Voice",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 3756, "primary": True},
|
||||||
|
{"freq_khz": 4625, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Russian voice number station. Female voice reads 5-figure groups in Russian. Part of Russian 7 operator network. Often shares 4625 kHz with UVB-76.",
|
||||||
|
"operator": "Russian 7",
|
||||||
|
"schedule": "Irregular transmissions",
|
||||||
|
"source_url": "https://priyom.org/number-stations/russian/v07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s11a",
|
||||||
|
"name": "S11a",
|
||||||
|
"nickname": "Russian Phonetic",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 4560, "primary": True},
|
||||||
|
{"freq_khz": 5200, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Russian phonetic alphabet number station. Male voice reads 5-letter groups using Russian phonetic alphabet (Anna, Boris, etc.).",
|
||||||
|
"operator": "GRU (suspected)",
|
||||||
|
"schedule": "Weekly scheduled transmissions",
|
||||||
|
"source_url": "https://priyom.org/number-stations/russian/s11a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "v13",
|
||||||
|
"name": "V13",
|
||||||
|
"nickname": "The Pip",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 3756, "primary": True},
|
||||||
|
{"freq_khz": 5448, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Russian military channel marker known as 'The Pip'. Continuous short beep every 1 second with occasional voice messages. Sister station to UVB-76.",
|
||||||
|
"operator": "Russian Military",
|
||||||
|
"schedule": "24/7 continuous operation",
|
||||||
|
"source_url": "https://priyom.org/military-stations/russia/the-pip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "v24",
|
||||||
|
"name": "V24",
|
||||||
|
"nickname": "Air Horn",
|
||||||
|
"type": "number",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 3243, "primary": True},
|
||||||
|
],
|
||||||
|
"mode": "USB",
|
||||||
|
"description": "Russian channel marker known as 'Air Horn' due to distinctive foghorn-like sound. Continuous tone with occasional voice messages in Russian.",
|
||||||
|
"operator": "Russian Military",
|
||||||
|
"schedule": "24/7 continuous operation",
|
||||||
|
"source_url": "https://priyom.org/military-stations/russia/the-air-horn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vc01",
|
||||||
|
"name": "VC01",
|
||||||
|
"nickname": "Chinese Robot",
|
||||||
|
"type": "number",
|
||||||
|
"country": "China",
|
||||||
|
"country_code": "CN",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 8300, "primary": True},
|
||||||
|
{"freq_khz": 9725, "primary": False},
|
||||||
|
{"freq_khz": 11430, "primary": False},
|
||||||
|
{"freq_khz": 13750, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "AM",
|
||||||
|
"description": "Chinese intelligence number station. Robotic female voice reading 4-figure groups in Chinese. Distinctive electronic music interval signal.",
|
||||||
|
"operator": "MSS (Chinese Intelligence)",
|
||||||
|
"schedule": "Daily transmissions",
|
||||||
|
"source_url": "https://priyom.org/number-stations/chinese/vc01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "v22",
|
||||||
|
"name": "V22",
|
||||||
|
"nickname": "Chinese Lady",
|
||||||
|
"type": "number",
|
||||||
|
"country": "China",
|
||||||
|
"country_code": "CN",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 7883, "primary": True},
|
||||||
|
{"freq_khz": 9170, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "AM",
|
||||||
|
"description": "Chinese number station using female voice. Reads 4-figure groups in Mandarin Chinese. Often reported in Southeast Asian target areas.",
|
||||||
|
"operator": "MSS (Chinese Intelligence)",
|
||||||
|
"schedule": "Evening transmissions UTC",
|
||||||
|
"source_url": "https://priyom.org/number-stations/chinese/v22"
|
||||||
|
},
|
||||||
|
# Diplomatic Stations
|
||||||
|
{
|
||||||
|
"id": "bulgaria_mfa",
|
||||||
|
"name": "Bulgaria MFA",
|
||||||
|
"nickname": "Sofia Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Bulgaria",
|
||||||
|
"country_code": "BG",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 5145, "primary": True},
|
||||||
|
{"freq_khz": 6755, "primary": False},
|
||||||
|
{"freq_khz": 7670, "primary": False},
|
||||||
|
{"freq_khz": 9155, "primary": False},
|
||||||
|
{"freq_khz": 10175, "primary": False},
|
||||||
|
{"freq_khz": 11445, "primary": False},
|
||||||
|
{"freq_khz": 14725, "primary": False},
|
||||||
|
{"freq_khz": 18520, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "RFSM-8000/MIL-STD-188-110",
|
||||||
|
"description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.",
|
||||||
|
"operator": "Bulgarian MFA",
|
||||||
|
"schedule": "Daily scheduled transmissions",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/bulgaria"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "czechia_mfa",
|
||||||
|
"name": "Czechia MFA",
|
||||||
|
"nickname": "Czech Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Czechia",
|
||||||
|
"country_code": "CZ",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 6830, "primary": True},
|
||||||
|
{"freq_khz": 8130, "primary": False},
|
||||||
|
{"freq_khz": 10232, "primary": False},
|
||||||
|
{"freq_khz": 13890, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "PACTOR-III",
|
||||||
|
"description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.",
|
||||||
|
"operator": "Czech MFA / MoD",
|
||||||
|
"schedule": "Regular scheduled traffic",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/czechia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "egypt_mfa",
|
||||||
|
"name": "Egypt MFA",
|
||||||
|
"nickname": "Egyptian Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Egypt",
|
||||||
|
"country_code": "EG",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 7830, "primary": True},
|
||||||
|
{"freq_khz": 9048, "primary": False},
|
||||||
|
{"freq_khz": 10780, "primary": False},
|
||||||
|
{"freq_khz": 13950, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "SITOR/Codan 3012",
|
||||||
|
"description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.",
|
||||||
|
"operator": "Egyptian MFA",
|
||||||
|
"schedule": "Daily traffic windows",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/egypt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dprk_mfa",
|
||||||
|
"name": "DPRK MFA",
|
||||||
|
"nickname": "North Korea Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "North Korea",
|
||||||
|
"country_code": "KP",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 7200, "primary": True},
|
||||||
|
{"freq_khz": 9450, "primary": False},
|
||||||
|
{"freq_khz": 11475, "primary": False},
|
||||||
|
{"freq_khz": 13785, "primary": False},
|
||||||
|
{"freq_khz": 15245, "primary": False},
|
||||||
|
{"freq_khz": 17550, "primary": False},
|
||||||
|
{"freq_khz": 21680, "primary": False},
|
||||||
|
{"freq_khz": 25120, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)",
|
||||||
|
"description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.",
|
||||||
|
"operator": "DPRK MFA",
|
||||||
|
"schedule": "Daily, multiple time slots",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/north-korea"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "russia_mfa",
|
||||||
|
"name": "Russia MFA",
|
||||||
|
"nickname": "Russian Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Russia",
|
||||||
|
"country_code": "RU",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 5154, "primary": True},
|
||||||
|
{"freq_khz": 7654, "primary": False},
|
||||||
|
{"freq_khz": 9045, "primary": False},
|
||||||
|
{"freq_khz": 10755, "primary": False},
|
||||||
|
{"freq_khz": 13455, "primary": False},
|
||||||
|
{"freq_khz": 16354, "primary": False},
|
||||||
|
{"freq_khz": 18954, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "Perelivt/Serdolik/X06/OFDM",
|
||||||
|
"description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.",
|
||||||
|
"operator": "Russian MFA",
|
||||||
|
"schedule": "24/7 network operations",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/russia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tunisia_mfa",
|
||||||
|
"name": "Tunisia MFA",
|
||||||
|
"nickname": "Tunisian Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Tunisia",
|
||||||
|
"country_code": "TN",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 5810, "primary": True},
|
||||||
|
{"freq_khz": 7954, "primary": False},
|
||||||
|
{"freq_khz": 8014, "primary": False},
|
||||||
|
{"freq_khz": 8180, "primary": False},
|
||||||
|
{"freq_khz": 10113, "primary": False},
|
||||||
|
{"freq_khz": 10176, "primary": False},
|
||||||
|
{"freq_khz": 11111, "primary": False},
|
||||||
|
{"freq_khz": 12140, "primary": False},
|
||||||
|
{"freq_khz": 13945, "primary": False},
|
||||||
|
{"freq_khz": 14700, "primary": False},
|
||||||
|
{"freq_khz": 14724, "primary": False},
|
||||||
|
{"freq_khz": 15635, "primary": False},
|
||||||
|
{"freq_khz": 16125, "primary": False},
|
||||||
|
{"freq_khz": 16285, "primary": False},
|
||||||
|
{"freq_khz": 16290, "primary": False},
|
||||||
|
{"freq_khz": 18295, "primary": False},
|
||||||
|
{"freq_khz": 19675, "primary": False},
|
||||||
|
{"freq_khz": 23540, "primary": False},
|
||||||
|
{"freq_khz": 24080, "primary": False},
|
||||||
|
{"freq_khz": 24170, "primary": False},
|
||||||
|
{"freq_khz": 26890, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "2G ALE/PACTOR-II",
|
||||||
|
"description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.",
|
||||||
|
"operator": "Tunisian MFA",
|
||||||
|
"schedule": "Regular diplomatic traffic",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/tunisia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "usa_state",
|
||||||
|
"name": "US State Dept",
|
||||||
|
"nickname": "American Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "United States",
|
||||||
|
"country_code": "US",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 5749, "primary": True},
|
||||||
|
{"freq_khz": 6903, "primary": False},
|
||||||
|
{"freq_khz": 8059, "primary": False},
|
||||||
|
{"freq_khz": 10734, "primary": False},
|
||||||
|
{"freq_khz": 11169, "primary": False},
|
||||||
|
{"freq_khz": 13504, "primary": False},
|
||||||
|
{"freq_khz": 16284, "primary": False},
|
||||||
|
{"freq_khz": 18249, "primary": False},
|
||||||
|
{"freq_khz": 20811, "primary": False},
|
||||||
|
{"freq_khz": 24884, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "2G ALE (MIL-STD-188-141A)",
|
||||||
|
"description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.",
|
||||||
|
"operator": "US State Department",
|
||||||
|
"schedule": "24/7 global network",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/united-states"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "morocco_mfa",
|
||||||
|
"name": "Morocco MFA",
|
||||||
|
"nickname": "Moroccan Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Morocco",
|
||||||
|
"country_code": "MA",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 8010, "primary": True},
|
||||||
|
{"freq_khz": 11205, "primary": False},
|
||||||
|
{"freq_khz": 14620, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "PACTOR-II/ALE",
|
||||||
|
"description": "Moroccan Ministry of Foreign Affairs diplomatic network. Links Rabat with embassies in Europe and Africa. Uses PACTOR-II and 2G ALE.",
|
||||||
|
"operator": "Moroccan MFA",
|
||||||
|
"schedule": "Daily scheduled traffic",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/morocco"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poland_mfa",
|
||||||
|
"name": "Poland MFA",
|
||||||
|
"nickname": "Polish Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Poland",
|
||||||
|
"country_code": "PL",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 6825, "primary": True},
|
||||||
|
{"freq_khz": 9250, "primary": False},
|
||||||
|
{"freq_khz": 13485, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "STANAG-4285/ALE",
|
||||||
|
"description": "Polish Ministry of Foreign Affairs HF network. Uses NATO STANAG-4285 modem with 2G ALE linking. Connects Warsaw with global embassies.",
|
||||||
|
"operator": "Polish MFA",
|
||||||
|
"schedule": "Regular diplomatic traffic",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/poland"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "france_mfa",
|
||||||
|
"name": "France MFA",
|
||||||
|
"nickname": "French Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "France",
|
||||||
|
"country_code": "FR",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 6910, "primary": True},
|
||||||
|
{"freq_khz": 10640, "primary": False},
|
||||||
|
{"freq_khz": 13870, "primary": False},
|
||||||
|
{"freq_khz": 16840, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "MIL-STD-188-110/ALE",
|
||||||
|
"description": "French Ministry of Foreign Affairs network. Extensive global coverage with Paris hub. Uses MIL-STD-188-110 with 2G/3G ALE linking protocols.",
|
||||||
|
"operator": "French MFA",
|
||||||
|
"schedule": "24/7 network operations",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/france"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "romania_mfa",
|
||||||
|
"name": "Romania MFA",
|
||||||
|
"nickname": "Romanian Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Romania",
|
||||||
|
"country_code": "RO",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 5390, "primary": True},
|
||||||
|
{"freq_khz": 8158, "primary": False},
|
||||||
|
{"freq_khz": 11555, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "PACTOR-III/ALE",
|
||||||
|
"description": "Romanian diplomatic network linking Bucharest with embassies. Uses PACTOR-III for traffic and 2G ALE for channel establishment.",
|
||||||
|
"operator": "Romanian MFA",
|
||||||
|
"schedule": "Scheduled daily windows",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/romania"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "algeria_mfa",
|
||||||
|
"name": "Algeria MFA",
|
||||||
|
"nickname": "Algerian Diplomatic",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Algeria",
|
||||||
|
"country_code": "DZ",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 7706, "primary": True},
|
||||||
|
{"freq_khz": 10235, "primary": False},
|
||||||
|
{"freq_khz": 14385, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "SITOR-B/PACTOR",
|
||||||
|
"description": "Algerian Ministry of Foreign Affairs network. Links Algiers with African and European embassies. Uses SITOR-B and PACTOR modes.",
|
||||||
|
"operator": "Algerian MFA",
|
||||||
|
"schedule": "Daily scheduled transmissions",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/algeria"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "egypt_mfa_m14a",
|
||||||
|
"name": "Egypt MFA M14a",
|
||||||
|
"nickname": "Egyptian Extended",
|
||||||
|
"type": "diplomatic",
|
||||||
|
"country": "Egypt",
|
||||||
|
"country_code": "EG",
|
||||||
|
"frequencies": [
|
||||||
|
{"freq_khz": 12175, "primary": True},
|
||||||
|
{"freq_khz": 16360, "primary": False},
|
||||||
|
],
|
||||||
|
"mode": "Codan 3012/SITOR",
|
||||||
|
"description": "Extended Egyptian diplomatic network frequencies. Higher frequency allocations for long-distance embassy communications to Asia and Americas.",
|
||||||
|
"operator": "Egyptian MFA",
|
||||||
|
"schedule": "Daily traffic windows",
|
||||||
|
"source_url": "https://priyom.org/diplomatic/egypt"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@spy_stations_bp.route('/stations')
|
||||||
|
def get_stations():
|
||||||
|
"""Return all spy stations, optionally filtered."""
|
||||||
|
station_type = request.args.get('type')
|
||||||
|
country = request.args.get('country')
|
||||||
|
mode = request.args.get('mode')
|
||||||
|
|
||||||
|
filtered = STATIONS
|
||||||
|
|
||||||
|
if station_type:
|
||||||
|
filtered = [s for s in filtered if s['type'] == station_type]
|
||||||
|
|
||||||
|
if country:
|
||||||
|
filtered = [s for s in filtered if s['country_code'].upper() == country.upper()]
|
||||||
|
|
||||||
|
if mode:
|
||||||
|
mode_lower = mode.lower()
|
||||||
|
filtered = [s for s in filtered if mode_lower in s['mode'].lower()]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'count': len(filtered),
|
||||||
|
'stations': filtered
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@spy_stations_bp.route('/stations/<station_id>')
|
||||||
|
def get_station(station_id):
|
||||||
|
"""Get a single station by ID."""
|
||||||
|
for station in STATIONS:
|
||||||
|
if station['id'] == station_id:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'station': station
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Station not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@spy_stations_bp.route('/filters')
|
||||||
|
def get_filters():
|
||||||
|
"""Return available filter options."""
|
||||||
|
types = list(set(s['type'] for s in STATIONS))
|
||||||
|
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
|
||||||
|
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'filters': {
|
||||||
|
'types': types,
|
||||||
|
'countries': [{'name': c[0], 'code': c[1]} for c in countries],
|
||||||
|
'modes': modes
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"""ISS SSTV (Slow-Scan Television) decoder routes.
|
||||||
|
|
||||||
|
Provides endpoints for decoding SSTV images from the International Space Station.
|
||||||
|
ISS SSTV events occur during special commemorations and typically transmit on 145.800 MHz FM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.sstv import (
|
||||||
|
get_sstv_decoder,
|
||||||
|
is_sstv_available,
|
||||||
|
ISS_SSTV_FREQ,
|
||||||
|
DecodeProgress,
|
||||||
|
DopplerInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.sstv')
|
||||||
|
|
||||||
|
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||||
|
|
||||||
|
# Queue for SSE progress streaming
|
||||||
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_callback(progress: DecodeProgress) -> None:
|
||||||
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_sstv_queue.get_nowait()
|
||||||
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
"""
|
||||||
|
Get SSTV decoder status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with decoder availability and current status.
|
||||||
|
"""
|
||||||
|
available = is_sstv_available()
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'available': available,
|
||||||
|
'decoder': decoder.decoder_available,
|
||||||
|
'running': decoder.is_running,
|
||||||
|
'iss_frequency': ISS_SSTV_FREQ,
|
||||||
|
'image_count': len(decoder.get_images()),
|
||||||
|
'doppler_enabled': decoder.doppler_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include Doppler info if available
|
||||||
|
doppler_info = decoder.last_doppler_info
|
||||||
|
if doppler_info:
|
||||||
|
result['doppler'] = doppler_info.to_dict()
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/start', methods=['POST'])
|
||||||
|
def start_decoder():
|
||||||
|
"""
|
||||||
|
Start SSTV decoder.
|
||||||
|
|
||||||
|
JSON body (optional):
|
||||||
|
{
|
||||||
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||||
|
"device": 0, // RTL-SDR device index
|
||||||
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||||
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||||
|
}
|
||||||
|
|
||||||
|
If latitude and longitude are provided, real-time Doppler shift compensation
|
||||||
|
will be enabled, which improves reception by tracking the ISS frequency shift
|
||||||
|
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
if not is_sstv_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'frequency': ISS_SSTV_FREQ,
|
||||||
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not _sstv_queue.empty():
|
||||||
|
try:
|
||||||
|
_sstv_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get parameters
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||||
|
device_index = data.get('device', 0)
|
||||||
|
latitude = data.get('latitude')
|
||||||
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
|
# Validate frequency
|
||||||
|
try:
|
||||||
|
frequency = float(frequency)
|
||||||
|
if not (100 <= frequency <= 500): # VHF range
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Frequency must be between 100-500 MHz'
|
||||||
|
}), 400
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid frequency'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate location if provided
|
||||||
|
if latitude is not None and longitude is not None:
|
||||||
|
try:
|
||||||
|
latitude = float(latitude)
|
||||||
|
longitude = float(longitude)
|
||||||
|
if not (-90 <= latitude <= 90):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Latitude must be between -90 and 90'
|
||||||
|
}), 400
|
||||||
|
if not (-180 <= longitude <= 180):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Longitude must be between -180 and 180'
|
||||||
|
}), 400
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid latitude or longitude'
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
latitude = None
|
||||||
|
longitude = None
|
||||||
|
|
||||||
|
# Set callback and start
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
success = decoder.start(
|
||||||
|
frequency=frequency,
|
||||||
|
device_index=device_index,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
result = {
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'device': device_index,
|
||||||
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include initial Doppler info if available
|
||||||
|
if decoder.doppler_enabled and decoder.last_doppler_info:
|
||||||
|
result['doppler'] = decoder.last_doppler_info.to_dict()
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start decoder'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_decoder():
|
||||||
|
"""
|
||||||
|
Stop SSTV decoder.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
decoder.stop()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/doppler')
|
||||||
|
def get_doppler():
|
||||||
|
"""
|
||||||
|
Get current Doppler shift information.
|
||||||
|
|
||||||
|
Returns real-time Doppler shift data if tracking is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with Doppler shift information.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
if not decoder.doppler_enabled:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'disabled',
|
||||||
|
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
|
||||||
|
})
|
||||||
|
|
||||||
|
doppler_info = decoder.last_doppler_info
|
||||||
|
if not doppler_info:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': 'Doppler data not yet available'
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'doppler': doppler_info.to_dict(),
|
||||||
|
'nominal_frequency_mhz': ISS_SSTV_FREQ,
|
||||||
|
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/images')
|
||||||
|
def list_images():
|
||||||
|
"""
|
||||||
|
Get list of decoded SSTV images.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
limit: Maximum number of images to return (default: all)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of decoded images.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
images = decoder.get_images()
|
||||||
|
|
||||||
|
limit = request.args.get('limit', type=int)
|
||||||
|
if limit and limit > 0:
|
||||||
|
images = images[-limit:]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'images': [img.to_dict() for img in images],
|
||||||
|
'count': len(images)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/images/<filename>')
|
||||||
|
def get_image(filename: str):
|
||||||
|
"""
|
||||||
|
Get a decoded SSTV image file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image file or 404.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not filename.endswith('.png'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
|
# Find image in decoder's output directory
|
||||||
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/stream')
|
||||||
|
def stream_progress():
|
||||||
|
"""
|
||||||
|
SSE stream of SSTV decode progress.
|
||||||
|
|
||||||
|
Provides real-time Server-Sent Events stream of decode progress.
|
||||||
|
|
||||||
|
Event format:
|
||||||
|
data: {"type": "sstv_progress", "status": "decoding", "mode": "PD120", ...}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE stream (text/event-stream)
|
||||||
|
"""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
progress = _sstv_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(progress)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/iss-schedule')
|
||||||
|
def iss_schedule():
|
||||||
|
"""
|
||||||
|
Get ISS pass schedule for SSTV reception.
|
||||||
|
|
||||||
|
Calculates ISS passes directly using skyfield.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
latitude: Observer latitude (required)
|
||||||
|
longitude: Observer longitude (required)
|
||||||
|
hours: Hours to look ahead (default: 48)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with ISS pass schedule.
|
||||||
|
"""
|
||||||
|
lat = request.args.get('latitude', type=float)
|
||||||
|
lon = request.args.get('longitude', type=float)
|
||||||
|
hours = request.args.get('hours', 48, type=int)
|
||||||
|
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'latitude and longitude parameters required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from skyfield.api import load, wgs84, EarthSatellite
|
||||||
|
from skyfield.almanac import find_discrete
|
||||||
|
from datetime import timedelta
|
||||||
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
|
# Get ISS TLE
|
||||||
|
iss_tle = TLE_SATELLITES.get('ISS')
|
||||||
|
if not iss_tle:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'ISS TLE data not available'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
ts = load.timescale()
|
||||||
|
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||||
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
|
||||||
|
t0 = ts.now()
|
||||||
|
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||||
|
|
||||||
|
def above_horizon(t):
|
||||||
|
diff = satellite - observer
|
||||||
|
topocentric = diff.at(t)
|
||||||
|
alt, _, _ = topocentric.altaz()
|
||||||
|
return alt.degrees > 0
|
||||||
|
|
||||||
|
above_horizon.step_days = 1/720
|
||||||
|
|
||||||
|
times, events = find_discrete(t0, t1, above_horizon)
|
||||||
|
|
||||||
|
passes = []
|
||||||
|
i = 0
|
||||||
|
while i < len(times):
|
||||||
|
if i < len(events) and events[i]: # Rising
|
||||||
|
rise_time = times[i]
|
||||||
|
set_time = None
|
||||||
|
|
||||||
|
for j in range(i + 1, len(times)):
|
||||||
|
if not events[j]: # Setting
|
||||||
|
set_time = times[j]
|
||||||
|
i = j
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if set_time is None:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate max elevation
|
||||||
|
max_el = 0
|
||||||
|
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
|
||||||
|
duration_minutes = int(duration_seconds / 60)
|
||||||
|
|
||||||
|
for k in range(30):
|
||||||
|
frac = k / 29
|
||||||
|
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||||
|
diff = satellite - observer
|
||||||
|
topocentric = diff.at(t_point)
|
||||||
|
alt, _, _ = topocentric.altaz()
|
||||||
|
if alt.degrees > max_el:
|
||||||
|
max_el = alt.degrees
|
||||||
|
|
||||||
|
if max_el >= 10: # Min elevation filter
|
||||||
|
passes.append({
|
||||||
|
'satellite': 'ISS',
|
||||||
|
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||||
|
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||||
|
'maxEl': round(max_el, 1),
|
||||||
|
'duration': duration_minutes,
|
||||||
|
'color': '#00ffff'
|
||||||
|
})
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'passes': passes,
|
||||||
|
'count': len(passes),
|
||||||
|
'sstv_frequency': ISS_SSTV_FREQ,
|
||||||
|
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
||||||
|
})
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'skyfield library not installed'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting ISS schedule: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/iss-position')
|
||||||
|
def iss_position():
|
||||||
|
"""
|
||||||
|
Get current ISS position from real-time API.
|
||||||
|
|
||||||
|
Uses the Open Notify API for accurate real-time position,
|
||||||
|
with fallback to "Where The ISS At" API.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
latitude: Observer latitude (optional, for elevation calc)
|
||||||
|
longitude: Observer longitude (optional, for elevation calc)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with ISS current position.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
observer_lat = request.args.get('latitude', type=float)
|
||||||
|
observer_lon = request.args.get('longitude', type=float)
|
||||||
|
|
||||||
|
# Try primary API: Open Notify
|
||||||
|
try:
|
||||||
|
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('message') == 'success':
|
||||||
|
iss_lat = float(data['iss_position']['latitude'])
|
||||||
|
iss_lon = float(data['iss_position']['longitude'])
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'status': 'ok',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': 420, # Approximate ISS altitude in km
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'source': 'open-notify'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Open Notify API failed: {e}")
|
||||||
|
|
||||||
|
# Try fallback API: Where The ISS At
|
||||||
|
try:
|
||||||
|
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
iss_lat = float(data['latitude'])
|
||||||
|
iss_lon = float(data['longitude'])
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'status': 'ok',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': float(data.get('altitude', 420)),
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'source': 'wheretheiss'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Where The ISS At API failed: {e}")
|
||||||
|
|
||||||
|
# Both APIs failed
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
||||||
|
"""Calculate elevation, azimuth, and distance from observer to ISS."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
# ISS altitude in km
|
||||||
|
iss_alt_km = 420
|
||||||
|
|
||||||
|
# Earth radius in km
|
||||||
|
earth_radius = 6371
|
||||||
|
|
||||||
|
# Convert to radians
|
||||||
|
lat1 = math.radians(obs_lat)
|
||||||
|
lat2 = math.radians(iss_lat)
|
||||||
|
lon1 = math.radians(obs_lon)
|
||||||
|
lon2 = math.radians(iss_lon)
|
||||||
|
|
||||||
|
# Haversine for ground distance
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||||
|
c = 2 * math.asin(math.sqrt(a))
|
||||||
|
ground_distance = earth_radius * c
|
||||||
|
|
||||||
|
# Calculate elevation angle (simplified)
|
||||||
|
# Using spherical geometry approximation
|
||||||
|
iss_height = iss_alt_km
|
||||||
|
slant_range = math.sqrt(ground_distance**2 + iss_height**2)
|
||||||
|
|
||||||
|
if ground_distance > 0:
|
||||||
|
elevation = math.degrees(math.atan2(iss_height - (ground_distance**2 / (2 * earth_radius)), ground_distance))
|
||||||
|
else:
|
||||||
|
elevation = 90.0
|
||||||
|
|
||||||
|
# Calculate azimuth
|
||||||
|
y = math.sin(dlon) * math.cos(lat2)
|
||||||
|
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
||||||
|
azimuth = math.degrees(math.atan2(y, x))
|
||||||
|
azimuth = (azimuth + 360) % 360
|
||||||
|
|
||||||
|
return {
|
||||||
|
'elevation': round(elevation, 1),
|
||||||
|
'azimuth': round(azimuth, 1),
|
||||||
|
'distance': round(slant_range, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/decode-file', methods=['POST'])
|
||||||
|
def decode_file():
|
||||||
|
"""
|
||||||
|
Decode SSTV from an uploaded audio file.
|
||||||
|
|
||||||
|
Expects multipart/form-data with 'audio' file field.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with decoded images.
|
||||||
|
"""
|
||||||
|
if 'audio' not in request.files:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No audio file provided'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
audio_file = request.files['audio']
|
||||||
|
|
||||||
|
if not audio_file.filename:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No file selected'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Save to temp file
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||||
|
audio_file.save(tmp.name)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
images = decoder.decode_file(tmp_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'images': [img.to_dict() for img in images],
|
||||||
|
'count': len(images)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error decoding file: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
try:
|
||||||
|
Path(tmp_path).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Updater routes - GitHub update checking and application updates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.updater import (
|
||||||
|
check_for_updates,
|
||||||
|
dismiss_update,
|
||||||
|
get_update_status,
|
||||||
|
perform_update,
|
||||||
|
restart_application,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.routes.updater')
|
||||||
|
|
||||||
|
updater_bp = Blueprint('updater', __name__, url_prefix='/updater')
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/check', methods=['GET'])
|
||||||
|
def check_updates() -> Response:
|
||||||
|
"""
|
||||||
|
Check for updates from GitHub.
|
||||||
|
|
||||||
|
Uses caching to avoid excessive API calls. Will only hit GitHub
|
||||||
|
if the cache is stale (default: 6 hours).
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
force: Set to 'true' to bypass cache and check GitHub directly
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with update status information
|
||||||
|
"""
|
||||||
|
force = request.args.get('force', '').lower() == 'true'
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = check_for_updates(force=force)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking for updates: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/status', methods=['GET'])
|
||||||
|
def update_status() -> Response:
|
||||||
|
"""
|
||||||
|
Get current update status from cache.
|
||||||
|
|
||||||
|
This endpoint does NOT trigger a GitHub check - it only returns
|
||||||
|
cached data. Use /check to trigger a fresh check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with cached update status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = get_update_status()
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting update status: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/update', methods=['POST'])
|
||||||
|
def do_update() -> Response:
|
||||||
|
"""
|
||||||
|
Perform a git pull to update the application.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
stash_changes: If true, stash local changes before pulling
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with update result information
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
stash_changes = data.get('stash_changes', False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = perform_update(stash_changes=stash_changes)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
return jsonify(result)
|
||||||
|
else:
|
||||||
|
# Return appropriate status code based on error type
|
||||||
|
error = result.get('error', '')
|
||||||
|
if error == 'local_changes':
|
||||||
|
return jsonify(result), 409 # Conflict
|
||||||
|
elif error == 'merge_conflict':
|
||||||
|
return jsonify(result), 409
|
||||||
|
elif result.get('manual_update'):
|
||||||
|
return jsonify(result), 400
|
||||||
|
else:
|
||||||
|
return jsonify(result), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error performing update: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/dismiss', methods=['POST'])
|
||||||
|
def dismiss_notification() -> Response:
|
||||||
|
"""
|
||||||
|
Dismiss update notification for a specific version.
|
||||||
|
|
||||||
|
The notification will not be shown again until a newer version
|
||||||
|
is available.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
version: The version to dismiss notifications for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with success status
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
version = data.get('version')
|
||||||
|
|
||||||
|
if not version:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Version is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = dismiss_update(version)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error dismissing update: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/restart', methods=['POST'])
|
||||||
|
def restart_app() -> Response:
|
||||||
|
"""
|
||||||
|
Restart the application.
|
||||||
|
|
||||||
|
This endpoint triggers a graceful restart of the application:
|
||||||
|
1. Stops all running decoder processes
|
||||||
|
2. Cleans up global state
|
||||||
|
3. Replaces the current process with a fresh instance
|
||||||
|
|
||||||
|
The response may not be received by the client since the process
|
||||||
|
is replaced immediately. Clients should poll /health until the
|
||||||
|
server responds again.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with restart status (may not be delivered)
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
logger.info("Restart requested via API")
|
||||||
|
|
||||||
|
# Send response before restarting
|
||||||
|
# Use a short delay to allow the response to be sent
|
||||||
|
def delayed_restart():
|
||||||
|
import time
|
||||||
|
time.sleep(0.5) # Allow response to be sent
|
||||||
|
restart_application()
|
||||||
|
|
||||||
|
# Start restart in a background thread so we can return a response
|
||||||
|
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
|
||||||
|
restart_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Application is restarting. Please wait...',
|
||||||
|
'action': 'restart'
|
||||||
|
})
|
||||||
@@ -1098,3 +1098,458 @@ def stream_wifi():
|
|||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# V2 API Endpoints - Using unified WiFi scanner
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
from utils.wifi.scanner import get_wifi_scanner, reset_wifi_scanner
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/capabilities')
|
||||||
|
def get_v2_capabilities():
|
||||||
|
"""Get WiFi scanning capabilities on this system."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
caps = scanner.check_capabilities()
|
||||||
|
return jsonify({
|
||||||
|
'platform': caps.platform,
|
||||||
|
'is_root': caps.is_root,
|
||||||
|
'can_quick_scan': caps.can_quick_scan,
|
||||||
|
'can_deep_scan': caps.can_deep_scan,
|
||||||
|
'preferred_quick_tool': caps.preferred_quick_tool,
|
||||||
|
'interfaces': caps.interfaces,
|
||||||
|
'default_interface': caps.default_interface,
|
||||||
|
'has_monitor_capable_interface': caps.has_monitor_capable_interface,
|
||||||
|
'monitor_interface': caps.monitor_interface,
|
||||||
|
'issues': caps.issues,
|
||||||
|
'tools': {
|
||||||
|
'nmcli': caps.has_nmcli,
|
||||||
|
'iw': caps.has_iw,
|
||||||
|
'iwlist': caps.has_iwlist,
|
||||||
|
'airport': caps.has_airport,
|
||||||
|
'airmon_ng': caps.has_airmon_ng,
|
||||||
|
'airodump_ng': caps.has_airodump_ng,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error checking capabilities")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/scan/quick', methods=['POST'])
|
||||||
|
def v2_quick_scan():
|
||||||
|
"""Perform a quick one-shot WiFi scan using system tools."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
interface = data.get('interface')
|
||||||
|
timeout = data.get('timeout', 10.0)
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
result = scanner.quick_scan(interface=interface, timeout=timeout)
|
||||||
|
|
||||||
|
if result.error:
|
||||||
|
return jsonify({
|
||||||
|
'error': result.error,
|
||||||
|
'access_points': [],
|
||||||
|
'channel_stats': [],
|
||||||
|
'recommendations': [],
|
||||||
|
}), 200 # Return 200 with error in body for cleaner handling
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'access_points': [ap.to_summary_dict() for ap in result.access_points],
|
||||||
|
'channel_stats': [s.to_dict() for s in result.channel_stats],
|
||||||
|
'recommendations': [r.to_dict() for r in result.recommendations],
|
||||||
|
'duration_seconds': result.duration_seconds,
|
||||||
|
'warnings': result.warnings,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error in quick scan")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/scan/start', methods=['POST'])
|
||||||
|
def v2_start_scan():
|
||||||
|
"""Start continuous deep scan with airodump-ng."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
interface = data.get('interface')
|
||||||
|
band = data.get('band', 'all')
|
||||||
|
channel = data.get('channel')
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
success = scanner.start_deep_scan(interface=interface, band=band, channel=channel)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'started'})
|
||||||
|
else:
|
||||||
|
status = scanner.get_status()
|
||||||
|
return jsonify({'error': status.error or 'Failed to start scan'}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error starting deep scan")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/scan/stop', methods=['POST'])
|
||||||
|
def v2_stop_scan():
|
||||||
|
"""Stop the current scan."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.stop_deep_scan()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error stopping scan")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/scan/status')
|
||||||
|
def v2_scan_status():
|
||||||
|
"""Get current scan status."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
status = scanner.get_status()
|
||||||
|
return jsonify({
|
||||||
|
'is_scanning': status.is_scanning,
|
||||||
|
'scan_mode': status.scan_mode,
|
||||||
|
'interface': status.interface,
|
||||||
|
'started_at': status.started_at.isoformat() if status.started_at else None,
|
||||||
|
'networks_found': status.networks_found,
|
||||||
|
'clients_found': status.clients_found,
|
||||||
|
'error': status.error,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting scan status")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/networks')
|
||||||
|
def v2_get_networks():
|
||||||
|
"""Get all discovered networks."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
networks = scanner.access_points
|
||||||
|
return jsonify({
|
||||||
|
'networks': [ap.to_summary_dict() for ap in networks],
|
||||||
|
'total': len(networks),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting networks")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/clients')
|
||||||
|
def v2_get_clients():
|
||||||
|
"""Get all discovered clients."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
clients = scanner.clients
|
||||||
|
return jsonify({
|
||||||
|
'clients': [c.to_dict() for c in clients],
|
||||||
|
'total': len(clients),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting clients")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/probes')
|
||||||
|
def v2_get_probes():
|
||||||
|
"""Get probe requests."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
probes = scanner.probe_requests
|
||||||
|
return jsonify({
|
||||||
|
'probes': [p.to_dict() for p in probes[-100:]], # Last 100
|
||||||
|
'total': len(probes),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting probes")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/channels')
|
||||||
|
def v2_get_channels():
|
||||||
|
"""Get channel statistics and recommendations."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
stats = scanner._calculate_channel_stats()
|
||||||
|
recommendations = scanner._generate_recommendations(stats)
|
||||||
|
return jsonify({
|
||||||
|
'channel_stats': [s.to_dict() for s in stats],
|
||||||
|
'recommendations': [r.to_dict() for r in recommendations],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting channel stats")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/stream')
|
||||||
|
def v2_stream():
|
||||||
|
"""SSE stream for real-time WiFi events."""
|
||||||
|
def generate():
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
for event in scanner.get_event_stream():
|
||||||
|
yield format_sse(event)
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/export')
|
||||||
|
def v2_export():
|
||||||
|
"""Export scan data as CSV or JSON."""
|
||||||
|
try:
|
||||||
|
format_type = request.args.get('format', 'json')
|
||||||
|
data_type = request.args.get('type', 'all')
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
|
||||||
|
if format_type == 'json':
|
||||||
|
data = {}
|
||||||
|
if data_type in ('all', 'networks'):
|
||||||
|
data['networks'] = [ap.to_summary_dict() for ap in scanner.access_points]
|
||||||
|
if data_type in ('all', 'clients'):
|
||||||
|
data['clients'] = [c.to_dict() for c in scanner.clients]
|
||||||
|
if data_type in ('all', 'probes'):
|
||||||
|
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
json.dumps(data, indent=2, default=str),
|
||||||
|
mimetype='application/json',
|
||||||
|
)
|
||||||
|
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.json'
|
||||||
|
return response
|
||||||
|
|
||||||
|
elif format_type == 'csv':
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# Write networks
|
||||||
|
writer.writerow(['Networks'])
|
||||||
|
writer.writerow(['BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security', 'Vendor', 'Clients', 'First Seen', 'Last Seen'])
|
||||||
|
for ap in scanner.access_points:
|
||||||
|
writer.writerow([
|
||||||
|
ap.bssid,
|
||||||
|
ap.essid or '[Hidden]',
|
||||||
|
ap.channel,
|
||||||
|
ap.band,
|
||||||
|
ap.rssi_current,
|
||||||
|
ap.security,
|
||||||
|
ap.vendor,
|
||||||
|
ap.client_count,
|
||||||
|
ap.first_seen.isoformat() if ap.first_seen else '',
|
||||||
|
ap.last_seen.isoformat() if ap.last_seen else '',
|
||||||
|
])
|
||||||
|
|
||||||
|
writer.writerow([])
|
||||||
|
|
||||||
|
# Write clients
|
||||||
|
writer.writerow(['Clients'])
|
||||||
|
writer.writerow(['MAC', 'BSSID', 'Vendor', 'RSSI', 'Probed SSIDs', 'First Seen', 'Last Seen'])
|
||||||
|
for c in scanner.clients:
|
||||||
|
writer.writerow([
|
||||||
|
c.mac,
|
||||||
|
c.associated_bssid or '',
|
||||||
|
c.vendor,
|
||||||
|
c.rssi_current,
|
||||||
|
', '.join(c.probed_ssids),
|
||||||
|
c.first_seen.isoformat() if c.first_seen else '',
|
||||||
|
c.last_seen.isoformat() if c.last_seen else '',
|
||||||
|
])
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
output.getvalue(),
|
||||||
|
mimetype='text/csv',
|
||||||
|
)
|
||||||
|
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.csv'
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
return jsonify({'error': f'Unknown format: {format_type}'}), 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error exporting data")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/baseline/set', methods=['POST'])
|
||||||
|
def v2_set_baseline():
|
||||||
|
"""Set current networks as baseline."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.set_baseline()
|
||||||
|
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error setting baseline")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/baseline/clear', methods=['POST'])
|
||||||
|
def v2_clear_baseline():
|
||||||
|
"""Clear the baseline."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.clear_baseline()
|
||||||
|
return jsonify({'status': 'baseline_cleared'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error clearing baseline")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/clear', methods=['POST'])
|
||||||
|
def v2_clear_data():
|
||||||
|
"""Clear all discovered data."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.clear_data()
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error clearing data")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# V2 Deauth Detection Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/status')
|
||||||
|
def v2_deauth_status():
|
||||||
|
"""
|
||||||
|
Get deauth detection status and recent alerts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- is_running: Whether deauth detector is active
|
||||||
|
- interface: Monitor interface being used
|
||||||
|
- stats: Detection statistics
|
||||||
|
- recent_alerts: Recent deauth alerts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
detector = scanner.deauth_detector
|
||||||
|
|
||||||
|
if detector:
|
||||||
|
stats = detector.stats
|
||||||
|
alerts = detector.get_alerts(limit=50)
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
'is_running': False,
|
||||||
|
'interface': None,
|
||||||
|
'packets_captured': 0,
|
||||||
|
'alerts_generated': 0,
|
||||||
|
}
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'is_running': stats.get('is_running', False),
|
||||||
|
'interface': stats.get('interface'),
|
||||||
|
'started_at': stats.get('started_at'),
|
||||||
|
'stats': {
|
||||||
|
'packets_captured': stats.get('packets_captured', 0),
|
||||||
|
'alerts_generated': stats.get('alerts_generated', 0),
|
||||||
|
'active_trackers': stats.get('active_trackers', 0),
|
||||||
|
},
|
||||||
|
'recent_alerts': alerts,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting deauth status")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/stream')
|
||||||
|
def v2_deauth_stream():
|
||||||
|
"""
|
||||||
|
SSE stream for real-time deauth alerts.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- deauth_alert: A deauth attack was detected
|
||||||
|
- deauth_detector_started: Detector started
|
||||||
|
- deauth_detector_stopped: Detector stopped
|
||||||
|
- deauth_error: An error occurred
|
||||||
|
- keepalive: Periodic keepalive
|
||||||
|
"""
|
||||||
|
def generate():
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Try to get from the dedicated deauth queue
|
||||||
|
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/alerts')
|
||||||
|
def v2_deauth_alerts():
|
||||||
|
"""
|
||||||
|
Get historical deauth alerts.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- limit: Maximum number of alerts to return (default 100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
alerts = scanner.get_deauth_alerts(limit=limit)
|
||||||
|
|
||||||
|
# Also include alerts from DataStore that might have been persisted
|
||||||
|
try:
|
||||||
|
stored_alerts = list(app_module.deauth_alerts.values())
|
||||||
|
# Merge and deduplicate by ID
|
||||||
|
alert_ids = {a.get('id') for a in alerts}
|
||||||
|
for alert in stored_alerts:
|
||||||
|
if alert.get('id') not in alert_ids:
|
||||||
|
alerts.append(alert)
|
||||||
|
# Sort by timestamp descending
|
||||||
|
alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True)
|
||||||
|
alerts = alerts[:limit]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'alerts': alerts,
|
||||||
|
'count': len(alerts),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting deauth alerts")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
|
||||||
|
def v2_deauth_clear():
|
||||||
|
"""Clear deauth alert history."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.clear_deauth_alerts()
|
||||||
|
|
||||||
|
# Clear the queue
|
||||||
|
while not app_module.deauth_detector_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.deauth_detector_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error clearing deauth alerts")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
"""
|
||||||
|
WiFi v2 API routes.
|
||||||
|
|
||||||
|
New unified WiFi scanning API with Quick Scan and Deep Scan modes,
|
||||||
|
channel analysis, hidden SSID correlation, and SSE streaming.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.wifi import (
|
||||||
|
get_wifi_scanner,
|
||||||
|
analyze_channels,
|
||||||
|
get_hidden_correlator,
|
||||||
|
SCAN_MODE_QUICK,
|
||||||
|
SCAN_MODE_DEEP,
|
||||||
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
wifi_v2_bp = Blueprint('wifi_v2', __name__, url_prefix='/wifi/v2')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Capabilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/capabilities', methods=['GET'])
|
||||||
|
def get_capabilities():
|
||||||
|
"""
|
||||||
|
Get WiFi scanning capabilities.
|
||||||
|
|
||||||
|
Returns available tools, interfaces, and scan mode support.
|
||||||
|
"""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
caps = scanner.check_capabilities()
|
||||||
|
return jsonify(caps.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Quick Scan
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/scan/quick', methods=['POST'])
|
||||||
|
def quick_scan():
|
||||||
|
"""
|
||||||
|
Perform a quick one-shot WiFi scan.
|
||||||
|
|
||||||
|
Uses system tools (nmcli, iw, iwlist, airport) without monitor mode.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
interface: Optional interface name
|
||||||
|
timeout: Optional scan timeout in seconds (default 15)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WiFiScanResult with discovered networks and channel analysis.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
interface = data.get('interface')
|
||||||
|
timeout = float(data.get('timeout', 15))
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
result = scanner.quick_scan(interface=interface, timeout=timeout)
|
||||||
|
|
||||||
|
return jsonify(result.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Deep Scan (Monitor Mode)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/scan/start', methods=['POST'])
|
||||||
|
def start_deep_scan():
|
||||||
|
"""
|
||||||
|
Start a deep scan using airodump-ng.
|
||||||
|
|
||||||
|
Requires monitor mode interface and root privileges.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||||
|
band: Band to scan ('2.4', '5', 'all')
|
||||||
|
channel: Optional specific channel to monitor
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
interface = data.get('interface')
|
||||||
|
band = data.get('band', 'all')
|
||||||
|
channel = data.get('channel')
|
||||||
|
|
||||||
|
if channel:
|
||||||
|
try:
|
||||||
|
channel = int(channel)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'Invalid channel'}), 400
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
success = scanner.start_deep_scan(
|
||||||
|
interface=interface,
|
||||||
|
band=band,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'mode': SCAN_MODE_DEEP,
|
||||||
|
'interface': interface or scanner._capabilities.monitor_interface,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error': scanner._status.error,
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
||||||
|
def stop_deep_scan():
|
||||||
|
"""Stop the deep scan."""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.stop_deep_scan()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'stopped',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/scan/status', methods=['GET'])
|
||||||
|
def get_scan_status():
|
||||||
|
"""Get current scan status."""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
status = scanner.get_status()
|
||||||
|
return jsonify(status.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Data Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/networks', methods=['GET'])
|
||||||
|
def get_networks():
|
||||||
|
"""
|
||||||
|
Get all discovered networks.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
band: Filter by band ('2.4GHz', '5GHz', '6GHz')
|
||||||
|
security: Filter by security type ('Open', 'WEP', 'WPA', 'WPA2', 'WPA3')
|
||||||
|
hidden: Filter hidden networks only (true/false)
|
||||||
|
min_rssi: Minimum RSSI threshold
|
||||||
|
sort: Sort field ('rssi', 'channel', 'essid', 'last_seen')
|
||||||
|
order: Sort order ('asc', 'desc')
|
||||||
|
format: Response format ('full', 'summary')
|
||||||
|
"""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
networks = scanner.access_points
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
band = request.args.get('band')
|
||||||
|
if band:
|
||||||
|
networks = [n for n in networks if n.band == band]
|
||||||
|
|
||||||
|
security = request.args.get('security')
|
||||||
|
if security:
|
||||||
|
networks = [n for n in networks if n.security == security]
|
||||||
|
|
||||||
|
hidden = request.args.get('hidden')
|
||||||
|
if hidden == 'true':
|
||||||
|
networks = [n for n in networks if n.is_hidden]
|
||||||
|
elif hidden == 'false':
|
||||||
|
networks = [n for n in networks if not n.is_hidden]
|
||||||
|
|
||||||
|
min_rssi = request.args.get('min_rssi')
|
||||||
|
if min_rssi:
|
||||||
|
try:
|
||||||
|
min_rssi = int(min_rssi)
|
||||||
|
networks = [n for n in networks if n.rssi_current and n.rssi_current >= min_rssi]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
sort_field = request.args.get('sort', 'rssi')
|
||||||
|
order = request.args.get('order', 'desc')
|
||||||
|
reverse = order == 'desc'
|
||||||
|
|
||||||
|
sort_key_map = {
|
||||||
|
'rssi': lambda n: n.rssi_current or -100,
|
||||||
|
'channel': lambda n: n.channel or 0,
|
||||||
|
'essid': lambda n: (n.essid or '').lower(),
|
||||||
|
'last_seen': lambda n: n.last_seen,
|
||||||
|
'clients': lambda n: n.client_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort_field in sort_key_map:
|
||||||
|
networks.sort(key=sort_key_map[sort_field], reverse=reverse)
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
output_format = request.args.get('format', 'summary')
|
||||||
|
if output_format == 'full':
|
||||||
|
return jsonify([n.to_dict() for n in networks])
|
||||||
|
else:
|
||||||
|
return jsonify([n.to_summary_dict() for n in networks])
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/networks/<bssid>', methods=['GET'])
|
||||||
|
def get_network(bssid):
|
||||||
|
"""Get a specific network by BSSID."""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
network = scanner.get_network(bssid)
|
||||||
|
|
||||||
|
if network:
|
||||||
|
return jsonify(network.to_dict())
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Network not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/clients', methods=['GET'])
|
||||||
|
def get_clients():
|
||||||
|
"""
|
||||||
|
Get all discovered clients.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
associated: Filter by association status (true/false)
|
||||||
|
bssid: Filter by associated BSSID
|
||||||
|
min_rssi: Minimum RSSI threshold
|
||||||
|
"""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
clients = scanner.clients
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
associated = request.args.get('associated')
|
||||||
|
if associated == 'true':
|
||||||
|
clients = [c for c in clients if c.is_associated]
|
||||||
|
elif associated == 'false':
|
||||||
|
clients = [c for c in clients if not c.is_associated]
|
||||||
|
|
||||||
|
bssid = request.args.get('bssid')
|
||||||
|
if bssid:
|
||||||
|
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
|
||||||
|
|
||||||
|
min_rssi = request.args.get('min_rssi')
|
||||||
|
if min_rssi:
|
||||||
|
try:
|
||||||
|
min_rssi = int(min_rssi)
|
||||||
|
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify([c.to_dict() for c in clients])
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/clients/<mac>', methods=['GET'])
|
||||||
|
def get_client(mac):
|
||||||
|
"""Get a specific client by MAC address."""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
client = scanner.get_client(mac)
|
||||||
|
|
||||||
|
if client:
|
||||||
|
return jsonify(client.to_dict())
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Client not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/probes', methods=['GET'])
|
||||||
|
def get_probes():
|
||||||
|
"""
|
||||||
|
Get captured probe requests.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
client_mac: Filter by client MAC
|
||||||
|
ssid: Filter by probed SSID
|
||||||
|
limit: Maximum number of results
|
||||||
|
"""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
probes = scanner.probe_requests
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
client_mac = request.args.get('client_mac')
|
||||||
|
if client_mac:
|
||||||
|
probes = [p for p in probes if p.client_mac == client_mac.upper()]
|
||||||
|
|
||||||
|
ssid = request.args.get('ssid')
|
||||||
|
if ssid:
|
||||||
|
probes = [p for p in probes if p.probed_ssid == ssid]
|
||||||
|
|
||||||
|
# Apply limit
|
||||||
|
limit = request.args.get('limit')
|
||||||
|
if limit:
|
||||||
|
try:
|
||||||
|
limit = int(limit)
|
||||||
|
probes = probes[-limit:] # Most recent
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify([p.to_dict() for p in probes])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Channel Analysis
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/channels', methods=['GET'])
|
||||||
|
def get_channel_stats():
|
||||||
|
"""
|
||||||
|
Get channel utilization statistics and recommendations.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
include_dfs: Include DFS channels in recommendations (true/false)
|
||||||
|
"""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
include_dfs = request.args.get('include_dfs', 'false') == 'true'
|
||||||
|
|
||||||
|
stats, recommendations = analyze_channels(
|
||||||
|
scanner.access_points,
|
||||||
|
include_dfs=include_dfs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'stats': [s.to_dict() for s in stats],
|
||||||
|
'recommendations': [r.to_dict() for r in recommendations],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Hidden SSID Correlation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/hidden', methods=['GET'])
|
||||||
|
def get_hidden_correlations():
|
||||||
|
"""
|
||||||
|
Get revealed hidden SSIDs from correlation.
|
||||||
|
|
||||||
|
Returns mapping of BSSID -> revealed SSID.
|
||||||
|
"""
|
||||||
|
correlator = get_hidden_correlator()
|
||||||
|
return jsonify(correlator.get_all_revealed())
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Baseline Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/baseline/set', methods=['POST'])
|
||||||
|
def set_baseline():
|
||||||
|
"""Mark current networks as baseline (known networks)."""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.set_baseline()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'baseline_set',
|
||||||
|
'network_count': len(scanner._baseline_networks),
|
||||||
|
'set_at': datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/baseline/clear', methods=['POST'])
|
||||||
|
def clear_baseline():
|
||||||
|
"""Clear the baseline."""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.clear_baseline()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'baseline_cleared',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SSE Streaming
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/stream', methods=['GET'])
|
||||||
|
def event_stream():
|
||||||
|
"""
|
||||||
|
Server-Sent Events stream for real-time updates.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- network_update: Network discovered/updated
|
||||||
|
- client_update: Client discovered/updated
|
||||||
|
- probe_request: Probe request detected
|
||||||
|
- hidden_revealed: Hidden SSID revealed
|
||||||
|
- scan_started, scan_stopped, scan_error
|
||||||
|
- keepalive: Periodic keepalive
|
||||||
|
"""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
|
||||||
|
for event in scanner.get_event_stream():
|
||||||
|
yield format_sse(event)
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Data Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/clear', methods=['POST'])
|
||||||
|
def clear_data():
|
||||||
|
"""Clear all discovered data."""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.clear_data()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'cleared',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Export
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_v2_bp.route('/export', methods=['GET'])
|
||||||
|
def export_data():
|
||||||
|
"""
|
||||||
|
Export scan data.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
format: 'json' or 'csv' (default: json)
|
||||||
|
type: 'networks', 'clients', 'probes', 'all' (default: all)
|
||||||
|
"""
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
export_format = request.args.get('format', 'json')
|
||||||
|
export_type = request.args.get('type', 'all')
|
||||||
|
|
||||||
|
if export_format == 'csv':
|
||||||
|
return _export_csv(scanner, export_type)
|
||||||
|
else:
|
||||||
|
return _export_json(scanner, export_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _export_json(scanner, export_type: str) -> Response:
|
||||||
|
"""Export data as JSON."""
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if export_type in ('networks', 'all'):
|
||||||
|
data['networks'] = [n.to_dict() for n in scanner.access_points]
|
||||||
|
|
||||||
|
if export_type in ('clients', 'all'):
|
||||||
|
data['clients'] = [c.to_dict() for c in scanner.clients]
|
||||||
|
|
||||||
|
if export_type in ('probes', 'all'):
|
||||||
|
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
|
||||||
|
|
||||||
|
data['exported_at'] = datetime.now().isoformat()
|
||||||
|
data['network_count'] = len(scanner.access_points)
|
||||||
|
data['client_count'] = len(scanner.clients)
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
json.dumps(data, indent=2),
|
||||||
|
mimetype='application/json',
|
||||||
|
)
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _export_csv(scanner, export_type: str) -> Response:
|
||||||
|
"""Export data as CSV."""
|
||||||
|
output = io.StringIO()
|
||||||
|
|
||||||
|
if export_type in ('networks', 'all'):
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
'BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security',
|
||||||
|
'Cipher', 'Auth', 'Vendor', 'Clients', 'First Seen', 'Last Seen'
|
||||||
|
])
|
||||||
|
|
||||||
|
for n in scanner.access_points:
|
||||||
|
writer.writerow([
|
||||||
|
n.bssid,
|
||||||
|
n.essid or '[Hidden]',
|
||||||
|
n.channel,
|
||||||
|
n.band,
|
||||||
|
n.rssi_current,
|
||||||
|
n.security,
|
||||||
|
n.cipher,
|
||||||
|
n.auth,
|
||||||
|
n.vendor or '',
|
||||||
|
n.client_count,
|
||||||
|
n.first_seen.isoformat(),
|
||||||
|
n.last_seen.isoformat(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if export_type == 'all':
|
||||||
|
writer.writerow([]) # Blank line separator
|
||||||
|
|
||||||
|
if export_type in ('clients', 'all'):
|
||||||
|
writer = csv.writer(output)
|
||||||
|
if export_type == 'clients':
|
||||||
|
writer.writerow([
|
||||||
|
'MAC', 'Vendor', 'RSSI', 'Associated BSSID', 'Probed SSIDs',
|
||||||
|
'First Seen', 'Last Seen'
|
||||||
|
])
|
||||||
|
|
||||||
|
for c in scanner.clients:
|
||||||
|
writer.writerow([
|
||||||
|
c.mac,
|
||||||
|
c.vendor or '',
|
||||||
|
c.rssi_current,
|
||||||
|
c.associated_bssid or '',
|
||||||
|
', '.join(c.probed_ssids),
|
||||||
|
c.first_seen.isoformat(),
|
||||||
|
c.last_seen.isoformat(),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = Response(output.getvalue(), mimetype='text/csv')
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||||
|
return response
|
||||||
@@ -69,6 +69,18 @@ echo
|
|||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
NON_INTERACTIVE=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--non-interactive)
|
||||||
|
NON_INTERACTIVE=true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
cmd_exists() {
|
cmd_exists() {
|
||||||
local c="$1"
|
local c="$1"
|
||||||
command -v "$c" >/dev/null 2>&1 && return 0
|
command -v "$c" >/dev/null 2>&1 && return 0
|
||||||
@@ -76,6 +88,32 @@ cmd_exists() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ask_yes_no() {
|
||||||
|
local prompt="$1"
|
||||||
|
local default="${2:-n}" # default to no for safety
|
||||||
|
local response
|
||||||
|
|
||||||
|
if $NON_INTERACTIVE; then
|
||||||
|
info "Non-interactive mode: defaulting to ${default} for prompt: ${prompt}"
|
||||||
|
[[ "$default" == "y" ]]
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
warn "No TTY available for prompt: ${prompt}"
|
||||||
|
[[ "$default" == "y" ]]
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$default" == "y" ]]; then
|
||||||
|
read -r -p "$prompt [Y/n]: " response
|
||||||
|
[[ -z "$response" || "$response" =~ ^[Yy] ]]
|
||||||
|
else
|
||||||
|
read -r -p "$prompt [y/N]: " response
|
||||||
|
[[ "$response" =~ ^[Yy] ]]
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
have_any() {
|
have_any() {
|
||||||
local c
|
local c
|
||||||
for c in "$@"; do
|
for c in "$@"; do
|
||||||
@@ -111,6 +149,18 @@ detect_os() {
|
|||||||
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
|
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detect_dragonos() {
|
||||||
|
IS_DRAGONOS=false
|
||||||
|
# Check for DragonOS markers
|
||||||
|
if [[ -f /etc/dragonos-release ]] || \
|
||||||
|
[[ -d /usr/share/dragonos ]] || \
|
||||||
|
grep -qi "dragonos" /etc/os-release 2>/dev/null; then
|
||||||
|
IS_DRAGONOS=true
|
||||||
|
warn "DragonOS detected! This distro has many tools pre-installed."
|
||||||
|
warn "The script will prompt before making system changes."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Required tool checks (with alternates)
|
# Required tool checks (with alternates)
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -128,6 +178,17 @@ check_required() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_optional() {
|
||||||
|
local label="$1"; shift
|
||||||
|
local desc="$1"; shift
|
||||||
|
|
||||||
|
if have_any "$@"; then
|
||||||
|
ok "${label} - ${desc}"
|
||||||
|
else
|
||||||
|
warn "${label} - ${desc} (missing, optional)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
check_tools() {
|
check_tools() {
|
||||||
info "Checking required tools..."
|
info "Checking required tools..."
|
||||||
missing_required=()
|
missing_required=()
|
||||||
@@ -136,10 +197,14 @@ check_tools() {
|
|||||||
info "Core SDR:"
|
info "Core SDR:"
|
||||||
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
|
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
|
||||||
check_required "rtl_test" "RTL-SDR device detection" rtl_test
|
check_required "rtl_test" "RTL-SDR device detection" rtl_test
|
||||||
|
check_required "rtl_tcp" "RTL-SDR TCP server" rtl_tcp
|
||||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||||
|
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
|
||||||
check_required "dump1090" "ADS-B decoder" dump1090
|
check_required "dump1090" "ADS-B decoder" dump1090
|
||||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||||
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
|
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
|
||||||
|
|
||||||
echo
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
@@ -230,15 +295,19 @@ install_python_deps() {
|
|||||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
||||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
warn "Some pip packages failed - checking if apt packages cover them..."
|
||||||
# Verify critical packages are available
|
# Verify critical packages are available
|
||||||
python -c "import flask; import requests" 2>/dev/null || {
|
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||||
fail "Critical Python packages (flask, requests) not installed"
|
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||||
echo "Try: sudo apt install python3-flask python3-requests"
|
echo "Try: pip install flask requests flask-limiter"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
ok "Core Python dependencies available"
|
ok "Core Python dependencies available"
|
||||||
else
|
else
|
||||||
ok "Python dependencies installed"
|
ok "Python dependencies installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility)
|
||||||
|
# System apt packages may have older Flask 2.x which is incompatible
|
||||||
|
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +343,92 @@ brew_install() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_rtlamr_from_source() {
|
||||||
|
info "Installing rtlamr from source (requires Go)..."
|
||||||
|
|
||||||
|
# Check if Go is installed, install if needed
|
||||||
|
if ! cmd_exists go; then
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
info "Installing Go via Homebrew..."
|
||||||
|
brew_install go || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
|
||||||
|
else
|
||||||
|
info "Installing Go via apt..."
|
||||||
|
$SUDO apt-get install -y golang >/dev/null 2>&1 || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set up Go environment
|
||||||
|
export GOPATH="${GOPATH:-$HOME/go}"
|
||||||
|
export PATH="$GOPATH/bin:$PATH"
|
||||||
|
mkdir -p "$GOPATH/bin"
|
||||||
|
|
||||||
|
info "Building rtlamr..."
|
||||||
|
if go install github.com/bemasher/rtlamr@latest 2>/dev/null; then
|
||||||
|
# Link to system path
|
||||||
|
if [[ -f "$GOPATH/bin/rtlamr" ]]; then
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
|
else
|
||||||
|
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
|
fi
|
||||||
|
ok "rtlamr installed successfully"
|
||||||
|
else
|
||||||
|
warn "rtlamr binary not found after build"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Failed to build rtlamr"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_slowrx_from_source_macos() {
|
||||||
|
info "slowrx not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
|
# Ensure build dependencies are installed
|
||||||
|
brew_install cmake
|
||||||
|
brew_install fftw
|
||||||
|
brew_install libsndfile
|
||||||
|
brew_install gtk+3
|
||||||
|
brew_install pkg-config
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning slowrx..."
|
||||||
|
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone slowrx"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/slowrx"
|
||||||
|
info "Compiling slowrx..."
|
||||||
|
mkdir -p build && cd build
|
||||||
|
local cmake_log make_log
|
||||||
|
cmake_log=$(cmake .. 2>&1) || {
|
||||||
|
warn "cmake failed for slowrx:"
|
||||||
|
echo "$cmake_log" | tail -20
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
make_log=$(make 2>&1) || {
|
||||||
|
warn "make failed for slowrx:"
|
||||||
|
echo "$make_log" | tail -20
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install to /usr/local/bin
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
install -m 0755 slowrx /usr/local/bin/slowrx
|
||||||
|
else
|
||||||
|
sudo install -m 0755 slowrx /usr/local/bin/slowrx
|
||||||
|
fi
|
||||||
|
ok "slowrx installed successfully from source"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_multimon_ng_from_source_macos() {
|
install_multimon_ng_from_source_macos() {
|
||||||
info "multimon-ng not available via Homebrew. Building from source..."
|
info "multimon-ng not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
@@ -286,7 +441,7 @@ install_multimon_ng_from_source_macos() {
|
|||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
info "Cloning multimon-ng..."
|
info "Cloning multimon-ng..."
|
||||||
git clone --depth 1 https://github.com/EliasOewornal/multimon-ng.git "$tmp_dir/multimon-ng" >/dev/null 2>&1 \
|
git clone --depth 1 https://github.com/EliasOenal/multimon-ng.git "$tmp_dir/multimon-ng" >/dev/null 2>&1 \
|
||||||
|| { fail "Failed to clone multimon-ng"; exit 1; }
|
|| { fail "Failed to clone multimon-ng"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/multimon-ng"
|
cd "$tmp_dir/multimon-ng"
|
||||||
@@ -306,7 +461,7 @@ install_multimon_ng_from_source_macos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=13
|
TOTAL_STEPS=16
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -323,18 +478,49 @@ install_macos_packages() {
|
|||||||
ok "multimon-ng already installed"
|
ok "multimon-ng already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
progress "Installing direwolf (APRS decoder)"
|
||||||
|
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||||
|
|
||||||
|
progress "Installing slowrx (SSTV decoder)"
|
||||||
|
if ! cmd_exists slowrx; then
|
||||||
|
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
|
||||||
|
else
|
||||||
|
ok "slowrx already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing ffmpeg"
|
progress "Installing ffmpeg"
|
||||||
brew_install ffmpeg
|
brew_install ffmpeg
|
||||||
|
|
||||||
progress "Installing rtl_433"
|
progress "Installing rtl_433"
|
||||||
brew_install rtl_433
|
brew_install rtl_433
|
||||||
|
|
||||||
|
progress "Installing rtlamr (optional)"
|
||||||
|
# rtlamr is optional - used for utility meter monitoring
|
||||||
|
if ! cmd_exists rtlamr; then
|
||||||
|
echo
|
||||||
|
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
|
||||||
|
if ask_yes_no "Do you want to install rtlamr?"; then
|
||||||
|
install_rtlamr_from_source
|
||||||
|
else
|
||||||
|
warn "Skipping rtlamr installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "rtlamr already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing dump1090"
|
progress "Installing dump1090"
|
||||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||||
|
|
||||||
progress "Installing acarsdec"
|
progress "Installing acarsdec"
|
||||||
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||||
|
|
||||||
|
progress "Installing AIS-catcher"
|
||||||
|
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||||
|
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
|
||||||
|
else
|
||||||
|
ok "AIS-catcher already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing aircrack-ng"
|
progress "Installing aircrack-ng"
|
||||||
brew_install aircrack-ng
|
brew_install aircrack-ng
|
||||||
|
|
||||||
@@ -347,6 +533,19 @@ install_macos_packages() {
|
|||||||
progress "Installing gpsd"
|
progress "Installing gpsd"
|
||||||
brew_install gpsd
|
brew_install gpsd
|
||||||
|
|
||||||
|
progress "Installing Ubertooth tools (optional)"
|
||||||
|
if ! cmd_exists ubertooth-btle; then
|
||||||
|
echo
|
||||||
|
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||||
|
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||||
|
brew_install ubertooth || warn "Ubertooth not available via Homebrew"
|
||||||
|
else
|
||||||
|
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Ubertooth already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||||
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
||||||
echo
|
echo
|
||||||
@@ -379,6 +578,15 @@ apt_try_install_any() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apt_install_if_missing() {
|
||||||
|
local pkg="$1"
|
||||||
|
if dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
|
||||||
|
ok "apt: ${pkg} already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
apt_install "$pkg"
|
||||||
|
}
|
||||||
|
|
||||||
install_dump1090_from_source_debian() {
|
install_dump1090_from_source_debian() {
|
||||||
info "dump1090 not available via APT. Building from source (required)..."
|
info "dump1090 not available via APT. Building from source (required)..."
|
||||||
|
|
||||||
@@ -396,6 +604,8 @@ install_dump1090_from_source_debian() {
|
|||||||
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
|
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
|
# Remove -Werror to prevent build failures on newer GCC versions
|
||||||
|
sed -i 's/-Werror//g' Makefile 2>/dev/null || sed -i '' 's/-Werror//g' Makefile
|
||||||
info "Compiling FlightAware dump1090..."
|
info "Compiling FlightAware dump1090..."
|
||||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
@@ -403,17 +613,17 @@ install_dump1090_from_source_debian() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
warn "FlightAware build failed. Falling back to antirez/dump1090..."
|
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
||||||
rm -rf "$tmp_dir/dump1090"
|
rm -rf "$tmp_dir/dump1090"
|
||||||
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||||
|| { fail "Failed to clone antirez dump1090"; exit 1; }
|
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
info "Compiling antirez dump1090..."
|
info "Compiling readsb..."
|
||||||
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
|
make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; }
|
||||||
|
|
||||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
||||||
ok "dump1090 installed successfully (antirez)."
|
ok "dump1090 installed successfully (via readsb)."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +655,140 @@ install_acarsdec_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_aiscatcher_from_source_debian() {
|
||||||
|
info "AIS-catcher not available via APT. Building from source..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake pkg-config \
|
||||||
|
librtlsdr-dev libusb-1.0-0-dev libcurl4-openssl-dev zlib1g-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning AIS-catcher..."
|
||||||
|
git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone AIS-catcher"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/AIS-catcher"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling AIS-catcher..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||||
|
ok "AIS-catcher installed successfully."
|
||||||
|
else
|
||||||
|
warn "Failed to build AIS-catcher from source. AIS vessel tracking will not be available."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
install_slowrx_from_source_debian() {
|
||||||
|
info "slowrx not available via APT. Building from source..."
|
||||||
|
|
||||||
|
# slowrx uses a simple Makefile, not CMake
|
||||||
|
apt_install build-essential git pkg-config \
|
||||||
|
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning slowrx..."
|
||||||
|
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone slowrx"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/slowrx"
|
||||||
|
|
||||||
|
info "Compiling slowrx..."
|
||||||
|
local make_log
|
||||||
|
make_log=$(make 2>&1) || {
|
||||||
|
warn "make failed for slowrx:"
|
||||||
|
echo "$make_log" | tail -20
|
||||||
|
warn "ISS SSTV decoding will not be available."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
|
||||||
|
ok "slowrx installed successfully."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
install_ubertooth_from_source_debian() {
|
||||||
|
info "Building Ubertooth from source..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config libbluetooth-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning Ubertooth..."
|
||||||
|
git clone --depth 1 https://github.com/greatscottgadgets/ubertooth.git "$tmp_dir/ubertooth" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone Ubertooth"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/ubertooth/host"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling Ubertooth..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
$SUDO ldconfig
|
||||||
|
ok "Ubertooth installed successfully from source."
|
||||||
|
else
|
||||||
|
warn "Failed to build Ubertooth from source."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
install_rtlsdr_blog_drivers_debian() {
|
||||||
|
# The RTL-SDR Blog drivers provide better support for:
|
||||||
|
# - RTL-SDR Blog V4 (R828D tuner)
|
||||||
|
# - RTL-SDR Blog V3 with bias-t improvements
|
||||||
|
# - Better overall compatibility with all RTL-SDR devices
|
||||||
|
# These drivers are backward compatible with standard RTL-SDR devices.
|
||||||
|
|
||||||
|
info "Installing RTL-SDR Blog drivers (improved V4 support)..."
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning RTL-SDR Blog driver fork..."
|
||||||
|
git clone https://github.com/rtlsdrblog/rtl-sdr-blog.git "$tmp_dir/rtl-sdr-blog" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone RTL-SDR Blog drivers"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/rtl-sdr-blog"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling RTL-SDR Blog drivers..."
|
||||||
|
if cmake .. -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
$SUDO ldconfig
|
||||||
|
|
||||||
|
# Copy udev rules if they exist
|
||||||
|
if [[ -f ../rtl-sdr.rules ]]; then
|
||||||
|
$SUDO cp ../rtl-sdr.rules /etc/udev/rules.d/20-rtlsdr-blog.rules
|
||||||
|
$SUDO udevadm control --reload-rules || true
|
||||||
|
$SUDO udevadm trigger || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "RTL-SDR Blog drivers installed successfully."
|
||||||
|
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
||||||
|
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
||||||
|
else
|
||||||
|
warn "Failed to build RTL-SDR Blog drivers. Using stock drivers."
|
||||||
|
warn "If you have an RTL-SDR Blog V4, you may need to install drivers manually."
|
||||||
|
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
setup_udev_rules_debian() {
|
setup_udev_rules_debian() {
|
||||||
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
||||||
|
|
||||||
@@ -493,28 +837,97 @@ EOF
|
|||||||
install_debian_packages() {
|
install_debian_packages() {
|
||||||
need_sudo
|
need_sudo
|
||||||
|
|
||||||
# Suppress needrestart prompts (Ubuntu Server 22.04+)
|
# Keep APT interactive when a TTY is available.
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
if $NON_INTERACTIVE; then
|
||||||
export NEEDRESTART_MODE=a
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
export NEEDRESTART_MODE=a
|
||||||
|
elif [[ -t 0 ]]; then
|
||||||
|
export DEBIAN_FRONTEND=readline
|
||||||
|
export NEEDRESTART_MODE=a
|
||||||
|
else
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
export NEEDRESTART_MODE=a
|
||||||
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=17
|
TOTAL_STEPS=21
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
$SUDO apt-get update -y >/dev/null
|
$SUDO apt-get update -y >/dev/null
|
||||||
|
|
||||||
progress "Installing RTL-SDR"
|
progress "Installing RTL-SDR"
|
||||||
apt_install rtl-sdr
|
if ! $IS_DRAGONOS; then
|
||||||
|
# Handle package conflict between librtlsdr0 and librtlsdr2
|
||||||
|
# The newer librtlsdr0 (2.0.2) conflicts with older librtlsdr2 (2.0.1)
|
||||||
|
if dpkg -l | grep -q "librtlsdr2"; then
|
||||||
|
info "Detected librtlsdr2 conflict - upgrading to librtlsdr0..."
|
||||||
|
|
||||||
|
# Remove packages that depend on librtlsdr2, then remove librtlsdr2
|
||||||
|
# These will be reinstalled with librtlsdr0 support
|
||||||
|
$SUDO apt-get remove -y dump1090-mutability libgnuradio-osmosdr0.2.0t64 rtl-433 librtlsdr2 rtl-sdr 2>/dev/null || true
|
||||||
|
$SUDO apt-get autoremove -y 2>/dev/null || true
|
||||||
|
|
||||||
|
ok "Removed conflicting librtlsdr2 packages"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If rtl-sdr is in broken state, remove it completely first
|
||||||
|
if dpkg -l | grep -q "^.[^i].*rtl-sdr" || ! dpkg -l rtl-sdr 2>/dev/null | grep -q "^ii"; then
|
||||||
|
info "Removing broken rtl-sdr package..."
|
||||||
|
$SUDO dpkg --remove --force-remove-reinstreq rtl-sdr 2>/dev/null || true
|
||||||
|
$SUDO dpkg --purge --force-remove-reinstreq rtl-sdr 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Force remove librtlsdr2 if it still exists
|
||||||
|
if dpkg -l | grep -q "librtlsdr2"; then
|
||||||
|
info "Force removing librtlsdr2..."
|
||||||
|
$SUDO dpkg --remove --force-all librtlsdr2 2>/dev/null || true
|
||||||
|
$SUDO dpkg --purge --force-all librtlsdr2 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up any partial installations
|
||||||
|
$SUDO dpkg --configure -a 2>/dev/null || true
|
||||||
|
$SUDO apt-get --fix-broken install -y 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt_install_if_missing rtl-sdr
|
||||||
|
|
||||||
|
progress "RTL-SDR Blog drivers"
|
||||||
|
if cmd_exists rtl_test; then
|
||||||
|
ok "RTL-SDR drivers already installed"
|
||||||
|
else
|
||||||
|
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
||||||
|
install_rtlsdr_blog_drivers_debian
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing multimon-ng"
|
progress "Installing multimon-ng"
|
||||||
apt_install multimon-ng
|
apt_install multimon-ng
|
||||||
|
|
||||||
|
progress "Installing direwolf (APRS decoder)"
|
||||||
|
apt_install direwolf || true
|
||||||
|
|
||||||
|
progress "Installing slowrx (SSTV decoder)"
|
||||||
|
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian
|
||||||
|
|
||||||
progress "Installing ffmpeg"
|
progress "Installing ffmpeg"
|
||||||
apt_install ffmpeg
|
apt_install ffmpeg
|
||||||
|
|
||||||
progress "Installing rtl_433"
|
progress "Installing rtl_433"
|
||||||
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
||||||
|
|
||||||
|
progress "Installing rtlamr (optional)"
|
||||||
|
# rtlamr is optional - used for utility meter monitoring
|
||||||
|
if ! cmd_exists rtlamr; then
|
||||||
|
echo
|
||||||
|
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
|
||||||
|
if ask_yes_no "Do you want to install rtlamr?"; then
|
||||||
|
install_rtlamr_from_source
|
||||||
|
else
|
||||||
|
warn "Skipping rtlamr installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "rtlamr already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing aircrack-ng"
|
progress "Installing aircrack-ng"
|
||||||
apt_install aircrack-ng || true
|
apt_install aircrack-ng || true
|
||||||
|
|
||||||
@@ -527,6 +940,19 @@ install_debian_packages() {
|
|||||||
progress "Installing Bluetooth tools"
|
progress "Installing Bluetooth tools"
|
||||||
apt_install bluez bluetooth || true
|
apt_install bluez bluetooth || true
|
||||||
|
|
||||||
|
progress "Installing Ubertooth tools (optional)"
|
||||||
|
if ! cmd_exists ubertooth-btle; then
|
||||||
|
echo
|
||||||
|
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||||
|
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||||
|
apt_install libubertooth-dev ubertooth || install_ubertooth_from_source_debian
|
||||||
|
else
|
||||||
|
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Ubertooth already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing SoapySDR"
|
progress "Installing SoapySDR"
|
||||||
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
||||||
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
||||||
@@ -545,13 +971,12 @@ install_debian_packages() {
|
|||||||
|
|
||||||
progress "Installing dump1090"
|
progress "Installing dump1090"
|
||||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||||
#export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||||
fi
|
fi
|
||||||
if ! cmd_exists dump1090; then
|
if ! cmd_exists dump1090; then
|
||||||
if cmd_exists dump1090-mutability; then
|
if cmd_exists dump1090-mutability; then
|
||||||
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||||
|
|
||||||
@@ -561,11 +986,31 @@ install_debian_packages() {
|
|||||||
fi
|
fi
|
||||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||||
|
|
||||||
|
progress "Installing AIS-catcher"
|
||||||
|
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||||
|
install_aiscatcher_from_source_debian
|
||||||
|
else
|
||||||
|
ok "AIS-catcher already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Configuring udev rules"
|
progress "Configuring udev rules"
|
||||||
setup_udev_rules_debian
|
setup_udev_rules_debian
|
||||||
|
|
||||||
progress "Blacklisting conflicting kernel drivers"
|
progress "Kernel driver configuration"
|
||||||
blacklist_kernel_drivers_debian
|
if $IS_DRAGONOS; then
|
||||||
|
info "DragonOS already has RTL-SDR drivers configured correctly."
|
||||||
|
elif [[ -f /etc/modprobe.d/blacklist-rtlsdr.conf ]]; then
|
||||||
|
ok "DVB kernel drivers already blacklisted"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
|
||||||
|
echo "Blacklisting them allows rtl_sdr tools to access the device."
|
||||||
|
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
|
||||||
|
blacklist_kernel_drivers_debian
|
||||||
|
else
|
||||||
|
warn "Skipped kernel driver blacklist. RTL-SDR may not work without manual config."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -599,11 +1044,42 @@ final_summary_and_hard_fail() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Pre-flight summary
|
||||||
|
# ----------------------------
|
||||||
|
show_install_summary() {
|
||||||
|
info "Installation Summary:"
|
||||||
|
echo
|
||||||
|
echo " OS: $OS"
|
||||||
|
$IS_DRAGONOS && echo " DragonOS: Yes (safe mode enabled)"
|
||||||
|
echo
|
||||||
|
echo " This script will:"
|
||||||
|
echo " - Install missing SDR tools (rtl-sdr, multimon-ng, etc.)"
|
||||||
|
echo " - Install Python dependencies in a virtual environment"
|
||||||
|
echo
|
||||||
|
if ! $IS_DRAGONOS; then
|
||||||
|
echo " You will be prompted before:"
|
||||||
|
echo " - Installing RTL-SDR Blog drivers (replaces existing)"
|
||||||
|
echo " - Blacklisting kernel DVB drivers"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
if $NON_INTERACTIVE; then
|
||||||
|
info "Non-interactive mode: continuing without prompt."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if ! ask_yes_no "Continue with installation?" "y"; then
|
||||||
|
info "Installation cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# MAIN
|
# MAIN
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
main() {
|
main() {
|
||||||
detect_os
|
detect_os
|
||||||
|
detect_dragonos
|
||||||
|
show_install_summary
|
||||||
|
|
||||||
if [[ "$OS" == "macos" ]]; then
|
if [[ "$OS" == "macos" ]]; then
|
||||||
install_macos_packages
|
install_macos_packages
|
||||||
@@ -617,3 +1093,6 @@ main() {
|
|||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|
||||||
|
# Clear traps before exiting to prevent spurious errors during cleanup
|
||||||
|
trap - ERR EXIT
|
||||||
|
exit 0
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
--bg-dark: #0a0c10;
|
||||||
|
--bg-panel: #0f1218;
|
||||||
|
--bg-card: #141a24;
|
||||||
|
--border-color: #1f2937;
|
||||||
|
--border-glow: rgba(74, 158, 255, 0.6);
|
||||||
|
--text-primary: #e8eaed;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-dim: #4b5563;
|
||||||
|
--accent-cyan: #4a9eff;
|
||||||
|
--accent-green: #22c55e;
|
||||||
|
--accent-amber: #d4a853;
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanline {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
animation: scan 6s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan {
|
||||||
|
0% { top: -4px; }
|
||||||
|
100% { top: 100vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 16px 18px 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(120deg, rgba(15, 18, 24, 0.95), rgba(20, 26, 36, 0.95));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 0 18px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
box-shadow: 0 0 12px rgba(75, 85, 99, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 14px rgba(34, 197, 94, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sessionNotice {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-controls select {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn.stop {
|
||||||
|
background: var(--accent-amber);
|
||||||
|
color: #0a0c10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 0 14px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.3px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input,
|
||||||
|
.control-group select {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border: none;
|
||||||
|
color: #0a0c10;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--accent-amber);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-table th,
|
||||||
|
.aircraft-table td {
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-bottom: 1px solid rgba(31, 41, 55, 0.6);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-table th {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-row:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row td,
|
||||||
|
.empty-row {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#altitudeChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#speedChart,
|
||||||
|
#headingChart,
|
||||||
|
#verticalChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 18, 24, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-list {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(5, 8, 15, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
width: min(820px, 92vw);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.2fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-photo {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
min-height: 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-photo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-fallback {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(20, 26, 36, 0.6);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input,
|
||||||
|
.control-group select {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/*
|
||||||
|
* Agents Management CSS
|
||||||
|
* Styles for the remote agent management interface
|
||||||
|
* Inherits CSS variables from core/variables.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Agent indicator in navigation */
|
||||||
|
.agent-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-dot.remote {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 6px var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-dot.multiple {
|
||||||
|
background: var(--accent-orange);
|
||||||
|
box-shadow: 0 0 6px var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-count {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent selector dropdown */
|
||||||
|
.agent-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-manage {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-manage:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item.selected {
|
||||||
|
background: rgba(0, 212, 255, 0.15);
|
||||||
|
border-left: 3px solid var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item.local {
|
||||||
|
border-left: 3px solid var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-status.online {
|
||||||
|
background: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-status.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-url {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-check {
|
||||||
|
color: var(--accent-green);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item.selected .agent-selector-item-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent badge in data displays */
|
||||||
|
.agent-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-badge.local,
|
||||||
|
.agent-badge.agent-local {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-badge.agent-remote {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WiFi table agent column */
|
||||||
|
.wifi-networks-table .col-agent {
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-networks-table th.col-agent {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bluetooth table agent column */
|
||||||
|
.bt-devices-table .col-agent {
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-badge-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent column in data tables */
|
||||||
|
.data-table .agent-col {
|
||||||
|
width: 120px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-agent stream indicator */
|
||||||
|
.multi-agent-indicator {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-agent-indicator.active {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-agent-indicator-pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent connection status toast */
|
||||||
|
.agent-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1001;
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-toast.connected {
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-toast.disconnected {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.agent-indicator {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-dropdown {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
/**
|
||||||
|
* Activity Timeline Component
|
||||||
|
* Reusable, configuration-driven timeline visualization
|
||||||
|
* Supports visual modes: compact, enriched, summary
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CSS VARIABLES (with fallbacks)
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline {
|
||||||
|
--timeline-bg: var(--bg-card, #1a1a1a);
|
||||||
|
--timeline-border: var(--border-color, #333);
|
||||||
|
--timeline-bg-secondary: var(--bg-secondary, #252525);
|
||||||
|
--timeline-bg-elevated: var(--bg-elevated, #2a2a2a);
|
||||||
|
--timeline-text-primary: var(--text-primary, #fff);
|
||||||
|
--timeline-text-secondary: var(--text-secondary, #888);
|
||||||
|
--timeline-text-dim: var(--text-dim, #666);
|
||||||
|
--timeline-accent: var(--accent-cyan, #4a9eff);
|
||||||
|
--timeline-status-new: var(--signal-new, #3b82f6);
|
||||||
|
--timeline-status-baseline: var(--signal-baseline, #6b7280);
|
||||||
|
--timeline-status-burst: var(--signal-burst, #f59e0b);
|
||||||
|
--timeline-status-flagged: var(--signal-emergency, #ef4444);
|
||||||
|
--timeline-status-gone: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TIMELINE CONTAINER
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline {
|
||||||
|
background: var(--timeline-bg);
|
||||||
|
border: 1px solid var(--timeline-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline.collapsed .activity-timeline-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline.collapsed .activity-timeline-header {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline.collapsed .activity-timeline-collapse-icon {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HEADER
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-header:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-collapse-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--timeline-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-header-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-header-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-header-stat .stat-value {
|
||||||
|
color: var(--timeline-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BODY
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-body {
|
||||||
|
padding: 0 12px 12px 12px;
|
||||||
|
border-top: 1px solid var(--timeline-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CONTROLS
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-btn {
|
||||||
|
background: var(--timeline-bg-secondary);
|
||||||
|
border: 1px solid var(--timeline-border);
|
||||||
|
color: var(--timeline-text-secondary);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-btn:hover {
|
||||||
|
background: var(--timeline-bg-elevated);
|
||||||
|
color: var(--timeline-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-btn.active {
|
||||||
|
background: var(--timeline-accent);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--timeline-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-window {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-window-select {
|
||||||
|
background: var(--timeline-bg-secondary);
|
||||||
|
border: 1px solid var(--timeline-border);
|
||||||
|
color: var(--timeline-text-primary);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TIME AXIS
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-axis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 50px 0 140px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-axis-label {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-axis-label::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--timeline-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LANES CONTAINER
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-lanes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lanes::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lanes::-webkit-scrollbar-track {
|
||||||
|
background: var(--timeline-bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lanes::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--timeline-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lanes::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--timeline-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
INDIVIDUAL LANE
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-lane {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 32px;
|
||||||
|
background: var(--timeline-bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lane:hover {
|
||||||
|
background: var(--timeline-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lane.expanded {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lane.baseline {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lane.baseline:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator strip */
|
||||||
|
.activity-timeline-status {
|
||||||
|
width: 4px;
|
||||||
|
min-width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-status[data-status="new"] {
|
||||||
|
background: var(--timeline-status-new);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-status[data-status="baseline"] {
|
||||||
|
background: var(--timeline-status-baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-status[data-status="burst"] {
|
||||||
|
background: var(--timeline-status-burst);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-status[data-status="flagged"] {
|
||||||
|
background: var(--timeline-status-flagged);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-status[data-status="gone"] {
|
||||||
|
background: var(--timeline-status-gone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label section */
|
||||||
|
.activity-timeline-label {
|
||||||
|
width: 130px;
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
border-right: 1px solid var(--timeline-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-id {
|
||||||
|
color: var(--timeline-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-name {
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRACK (where bars are drawn)
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-track {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-track-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SIGNAL BARS
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 14px;
|
||||||
|
min-width: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strength variants */
|
||||||
|
.activity-timeline-bar[data-strength="1"] { height: 5px; }
|
||||||
|
.activity-timeline-bar[data-strength="2"] { height: 9px; }
|
||||||
|
.activity-timeline-bar[data-strength="3"] { height: 13px; }
|
||||||
|
.activity-timeline-bar[data-strength="4"] { height: 17px; }
|
||||||
|
.activity-timeline-bar[data-strength="5"] { height: 21px; }
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
.activity-timeline-bar[data-status="new"],
|
||||||
|
.activity-timeline-bar[data-status="repeated"] {
|
||||||
|
background: var(--timeline-status-new);
|
||||||
|
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-bar[data-status="baseline"] {
|
||||||
|
background: var(--timeline-status-baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-bar[data-status="burst"] {
|
||||||
|
background: var(--timeline-status-burst);
|
||||||
|
box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-bar[data-status="flagged"] {
|
||||||
|
background: var(--timeline-status-flagged);
|
||||||
|
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||||
|
animation: timeline-flagged-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes timeline-flagged-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lane:hover .activity-timeline-bar {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EXPANDED VIEW (tick marks)
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-ticks {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px solid var(--timeline-border);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-lane.expanded .activity-timeline-ticks {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-tick {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--timeline-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-tick[data-strength="1"] { height: 4px; }
|
||||||
|
.activity-timeline-tick[data-strength="2"] { height: 8px; }
|
||||||
|
.activity-timeline-tick[data-strength="3"] { height: 12px; }
|
||||||
|
.activity-timeline-tick[data-strength="4"] { height: 16px; }
|
||||||
|
.activity-timeline-tick[data-strength="5"] { height: 20px; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS COLUMN
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-stats {
|
||||||
|
width: 45px;
|
||||||
|
min-width: 45px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
border-left: 1px solid var(--timeline-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-stat-count {
|
||||||
|
color: var(--timeline-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-stat-label {
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ANNOTATIONS
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-annotations {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid var(--timeline-border);
|
||||||
|
max-height: 80px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-annotation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--timeline-text-secondary);
|
||||||
|
background: var(--timeline-bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-annotation-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-annotation[data-type="new"] {
|
||||||
|
border-left: 2px solid var(--timeline-status-new);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-annotation[data-type="burst"] {
|
||||||
|
border-left: 2px solid var(--timeline-status-burst);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-annotation[data-type="pattern"] {
|
||||||
|
border-left: 2px solid var(--timeline-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-annotation[data-type="flagged"] {
|
||||||
|
border-left: 2px solid var(--timeline-status-flagged);
|
||||||
|
color: var(--timeline-status-flagged);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-annotation[data-type="gone"] {
|
||||||
|
border-left: 2px solid var(--timeline-status-gone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOOLTIP
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
background: var(--timeline-bg-elevated);
|
||||||
|
border: 1px solid var(--timeline-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--timeline-text-primary);
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 240px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-tooltip-header {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--timeline-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--timeline-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-tooltip-row span:last-child {
|
||||||
|
color: var(--timeline-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LEGEND
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid var(--timeline-border);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-legend-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-legend-dot.new { background: var(--timeline-status-new); }
|
||||||
|
.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); }
|
||||||
|
.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); }
|
||||||
|
.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EMPTY STATE
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-empty-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More indicator */
|
||||||
|
.activity-timeline-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--timeline-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VISUAL MODE: COMPACT
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline--compact .activity-timeline-lanes {
|
||||||
|
max-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-lane {
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-label {
|
||||||
|
width: 100px;
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-id {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--timeline-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-track {
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-bar {
|
||||||
|
height: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; }
|
||||||
|
.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; }
|
||||||
|
.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; }
|
||||||
|
.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; }
|
||||||
|
.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; }
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-stats {
|
||||||
|
width: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-stat-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-legend {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--compact .activity-timeline-axis {
|
||||||
|
padding-left: 110px;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VISUAL MODE: SUMMARY
|
||||||
|
============================================ */
|
||||||
|
.activity-timeline--summary .activity-timeline-lanes {
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-lane {
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-label {
|
||||||
|
width: 80px;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-id,
|
||||||
|
.activity-timeline--summary .activity-timeline-name {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-status {
|
||||||
|
width: 3px;
|
||||||
|
min-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-track {
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-bar {
|
||||||
|
height: 8px !important;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-stats {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-ticks {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-annotations {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-legend {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline--summary .activity-timeline-axis {
|
||||||
|
padding-left: 90px;
|
||||||
|
padding-right: 10px;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BACKWARD COMPATIBILITY NOTE
|
||||||
|
The old signal-timeline.css is still loaded
|
||||||
|
for existing TSCM code that uses those classes.
|
||||||
|
New code should use activity-timeline classes.
|
||||||
|
============================================ */
|
||||||
@@ -0,0 +1,879 @@
|
|||||||
|
/**
|
||||||
|
* Device Cards Component CSS
|
||||||
|
* Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CSS VARIABLES
|
||||||
|
============================================ */
|
||||||
|
:root {
|
||||||
|
/* Protocol colors */
|
||||||
|
--proto-ble: #3b82f6;
|
||||||
|
--proto-ble-bg: rgba(59, 130, 246, 0.15);
|
||||||
|
--proto-classic: #8b5cf6;
|
||||||
|
--proto-classic-bg: rgba(139, 92, 246, 0.15);
|
||||||
|
|
||||||
|
/* Range band colors */
|
||||||
|
--range-very-close: #ef4444;
|
||||||
|
--range-close: #f97316;
|
||||||
|
--range-nearby: #eab308;
|
||||||
|
--range-far: #6b7280;
|
||||||
|
--range-unknown: #374151;
|
||||||
|
|
||||||
|
/* Heuristic badge colors */
|
||||||
|
--heuristic-new: #3b82f6;
|
||||||
|
--heuristic-persistent: #22c55e;
|
||||||
|
--heuristic-beacon: #f59e0b;
|
||||||
|
--heuristic-strong: #ef4444;
|
||||||
|
--heuristic-random: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DEVICE CARD BASE
|
||||||
|
============================================ */
|
||||||
|
.device-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card:hover {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card:active {
|
||||||
|
transform: scale(0.995);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DEVICE IDENTITY
|
||||||
|
============================================ */
|
||||||
|
.device-identity {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-address {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-address .address-value {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-address .address-type {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PROTOCOL BADGES
|
||||||
|
============================================ */
|
||||||
|
.signal-proto-badge.device-protocol {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HEURISTIC BADGES
|
||||||
|
============================================ */
|
||||||
|
.device-heuristic-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||||
|
color: var(--badge-color);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.new {
|
||||||
|
--badge-color: var(--heuristic-new);
|
||||||
|
animation: heuristicPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.persistent {
|
||||||
|
--badge-color: var(--heuristic-persistent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.beacon_like {
|
||||||
|
--badge-color: var(--heuristic-beacon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.strong_stable {
|
||||||
|
--badge-color: var(--heuristic-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.random_address {
|
||||||
|
--badge-color: var(--heuristic-random);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heuristicPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SIGNAL ROW & RSSI DISPLAY
|
||||||
|
============================================ */
|
||||||
|
.device-signal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-current {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RSSI SPARKLINE
|
||||||
|
============================================ */
|
||||||
|
.rssi-sparkline,
|
||||||
|
.rssi-sparkline-svg {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-sparkline-empty {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-sparkline-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-current-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline-dot {
|
||||||
|
animation: sparklinePulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sparklinePulse {
|
||||||
|
0%, 100% { r: 2; opacity: 1; }
|
||||||
|
50% { r: 3; opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RANGE BAND INDICATOR
|
||||||
|
============================================ */
|
||||||
|
.device-range-band {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: color-mix(in srgb, var(--range-color) 15%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--range-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band .range-label {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--range-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band .range-estimate {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band .range-confidence {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MANUFACTURER INFO
|
||||||
|
============================================ */
|
||||||
|
.device-manufacturer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-manufacturer .mfr-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-manufacturer .mfr-name {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
META ROW
|
||||||
|
============================================ */
|
||||||
|
.device-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-seen-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-seen-count .seen-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-timestamp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SERVICE UUIDS
|
||||||
|
============================================ */
|
||||||
|
.device-uuids {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-uuid {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HEURISTICS DETAIL VIEW
|
||||||
|
============================================ */
|
||||||
|
.device-heuristics-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item.active {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item .heuristic-name {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item .heuristic-status {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item.active .heuristic-status {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item:not(.active) .heuristic-status {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MESSAGE CARDS
|
||||||
|
============================================ */
|
||||||
|
.message-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--message-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
animation: messageSlideIn 0.25s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes messageSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card.message-card-hiding {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--message-color);
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--message-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-icon svg.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-title {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-details {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-dismiss svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn:hover {
|
||||||
|
background: var(--bg-tertiary, #252525);
|
||||||
|
border-color: var(--border-light, #444);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn.primary {
|
||||||
|
background: color-mix(in srgb, var(--message-color) 20%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--message-color) 40%, transparent);
|
||||||
|
color: var(--message-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn.primary:hover {
|
||||||
|
background: color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DEVICE FILTER BAR
|
||||||
|
============================================ */
|
||||||
|
.device-filter-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-filter-bar .signal-filter-btn .filter-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE ADJUSTMENTS
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.device-signal-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-display {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristics-detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-title {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-text {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BLUETOOTH DEVICE LIST CONTAINER
|
||||||
|
============================================ */
|
||||||
|
#btDeviceListContent {
|
||||||
|
display: block !important;
|
||||||
|
padding: 10px !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pure inline-styled cards - ensure no interference */
|
||||||
|
#btDeviceListContent > div[data-bt-device-id] {
|
||||||
|
display: block !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy card support */
|
||||||
|
#btDeviceListContent .device-card,
|
||||||
|
#btDeviceListContent .signal-card {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure card body is visible */
|
||||||
|
.device-card .signal-card-body,
|
||||||
|
.signal-card .signal-card-body {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card .device-identity,
|
||||||
|
.signal-card .device-identity {
|
||||||
|
display: block !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card .device-signal-row,
|
||||||
|
.signal-card .device-signal-row {
|
||||||
|
display: flex !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card .device-meta-row,
|
||||||
|
.signal-card .device-meta-row {
|
||||||
|
display: flex !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ENHANCED MODAL STYLES
|
||||||
|
============================================ */
|
||||||
|
.signal-details-modal-header .modal-header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-details-modal-subtitle {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-details-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-details-copy-addr-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-secondary, #252525);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-details-copy-addr-btn:hover {
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Header Section */
|
||||||
|
.modal-device-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Sections */
|
||||||
|
.modal-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal Display */
|
||||||
|
.modal-signal-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-rssi-large {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-rssi-large .rssi-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-sparkline {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal Stats Grid */
|
||||||
|
.modal-signal-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-signal-stats .stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-signal-stats .stat-label {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-signal-stats .stat-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Grid */
|
||||||
|
.modal-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info-grid .info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info-grid .info-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info-grid .info-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info-grid .info-value.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UUID List */
|
||||||
|
.modal-uuid-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-uuid {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heuristics Grid */
|
||||||
|
.modal-heuristics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-check.active {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-indicator {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-check.active .heuristic-indicator {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE MODAL
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.modal-signal-stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-signal-display {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-sparkline {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-device-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DARK MODE OVERRIDES (if needed)
|
||||||
|
============================================ */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.device-card {
|
||||||
|
--bg-secondary: #1a1a1a;
|
||||||
|
--bg-tertiary: #141414;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
/* Function Strip (Action Bar) - Shared across modes
|
||||||
|
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.function-strip {
|
||||||
|
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow: visible;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.function-strip .strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-stat:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-label {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal stat coloring */
|
||||||
|
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||||
|
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||||
|
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.function-strip .strip-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-select {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-select:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input:hover,
|
||||||
|
.function-strip .strip-input:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wider input for frequency values */
|
||||||
|
.function-strip .strip-input.wide {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Status Indicators */
|
||||||
|
.function-strip .strip-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-tool {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 59, 48, 0.2);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-tool.ok {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border-color: rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-tool.warn {
|
||||||
|
background: rgba(255, 193, 7, 0.2);
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
border-color: rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.function-strip .strip-btn {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
border-color: rgba(74, 158, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.stop {
|
||||||
|
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator */
|
||||||
|
.function-strip .strip-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.inactive {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.active,
|
||||||
|
.function-strip .status-dot.scanning,
|
||||||
|
.function-strip .status-dot.decoding {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.listening,
|
||||||
|
.function-strip .status-dot.tracking,
|
||||||
|
.function-strip .status-dot.receiving {
|
||||||
|
background: var(--accent-green);
|
||||||
|
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.sweeping {
|
||||||
|
background: var(--accent-orange);
|
||||||
|
animation: strip-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.error {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes strip-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||||
|
50% { opacity: 0.6; box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time display */
|
||||||
|
.function-strip .strip-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode-specific accent colors */
|
||||||
|
.function-strip.pager-strip .strip-stat {
|
||||||
|
background: rgba(255, 193, 7, 0.05);
|
||||||
|
border-color: rgba(255, 193, 7, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.pager-strip .strip-stat:hover {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border-color: rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.pager-strip .strip-value {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.sensor-strip .strip-stat {
|
||||||
|
background: rgba(0, 255, 136, 0.05);
|
||||||
|
border-color: rgba(0, 255, 136, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.sensor-strip .strip-stat:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
border-color: rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.sensor-strip .strip-value {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.bt-strip .strip-stat {
|
||||||
|
background: rgba(0, 122, 255, 0.05);
|
||||||
|
border-color: rgba(0, 122, 255, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.bt-strip .strip-stat:hover {
|
||||||
|
background: rgba(0, 122, 255, 0.1);
|
||||||
|
border-color: rgba(0, 122, 255, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.bt-strip .strip-value {
|
||||||
|
color: #0a84ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.wifi-strip .strip-stat {
|
||||||
|
background: rgba(255, 149, 0, 0.05);
|
||||||
|
border-color: rgba(255, 149, 0, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.wifi-strip .strip-stat:hover {
|
||||||
|
background: rgba(255, 149, 0, 0.1);
|
||||||
|
border-color: rgba(255, 149, 0, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.wifi-strip .strip-value {
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.tscm-strip {
|
||||||
|
margin-top: 4px; /* Extra clearance to prevent top clipping */
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.tscm-strip .strip-stat {
|
||||||
|
background: rgba(255, 59, 48, 0.15);
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.4);
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-stat:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.25);
|
||||||
|
border-color: rgba(255, 59, 48, 0.6);
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-value {
|
||||||
|
color: #ef4444; /* Explicit red color */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-label {
|
||||||
|
color: #9ca3af; /* Explicit light gray */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-select {
|
||||||
|
color: #e8eaed; /* Explicit white for selects */
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-btn {
|
||||||
|
color: #e8eaed; /* Explicit white for buttons */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-tool {
|
||||||
|
color: #e8eaed; /* Explicit white for tool indicators */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-time,
|
||||||
|
.function-strip.tscm-strip .strip-status span {
|
||||||
|
color: #9ca3af; /* Explicit gray for status/time */
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.rtlamr-strip .strip-stat {
|
||||||
|
background: rgba(175, 82, 222, 0.05);
|
||||||
|
border-color: rgba(175, 82, 222, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.rtlamr-strip .strip-stat:hover {
|
||||||
|
background: rgba(175, 82, 222, 0.1);
|
||||||
|
border-color: rgba(175, 82, 222, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.rtlamr-strip .strip-value {
|
||||||
|
color: #af52de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.listening-strip .strip-stat {
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
border-color: rgba(74, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.listening-strip .strip-stat:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.listening-strip .strip-value {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Threat-colored stats for TSCM */
|
||||||
|
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
|
||||||
|
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
|
||||||
|
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* Proximity Visualization Components
|
||||||
|
* Styles for radar and timeline heatmap
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PROXIMITY RADAR
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.proximity-radar-svg {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-device {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform-origin: center center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-device:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invisible larger hit area to prevent hover flicker */
|
||||||
|
.radar-device-hitarea {
|
||||||
|
fill: transparent;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-dot-pulse circle:first-child {
|
||||||
|
animation: radar-pulse 1.5s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes radar-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(2);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-sweep {
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radar filter buttons */
|
||||||
|
.bt-radar-filter-btn {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-radar-filter-btn:hover {
|
||||||
|
background: var(--bg-hover, #333) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-radar-filter-btn.active {
|
||||||
|
background: #00d4ff !important;
|
||||||
|
color: #000 !important;
|
||||||
|
border-color: #00d4ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btRadarPauseBtn.active {
|
||||||
|
background: #f97316 !important;
|
||||||
|
color: #000 !important;
|
||||||
|
border-color: #f97316 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TIMELINE HEATMAP
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.timeline-heatmap-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-control-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-select {
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-select:hover {
|
||||||
|
border-color: var(--accent-color, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-btn {
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim, #888);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-btn:hover {
|
||||||
|
background: var(--bg-hover, #252525);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-btn.active {
|
||||||
|
background: #f97316;
|
||||||
|
color: #000;
|
||||||
|
border-color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-heatmap-content {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-loading,
|
||||||
|
.heatmap-empty,
|
||||||
|
.heatmap-error {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-row:hover:not(.heatmap-header) {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-row.selected {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-header {
|
||||||
|
cursor: default;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-label {
|
||||||
|
width: 120px;
|
||||||
|
min-width: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding-right: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-label .device-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-label .device-rssi {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cells {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell {
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell:hover {
|
||||||
|
transform: scale(1.5);
|
||||||
|
z-index: 10;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-time-label {
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-align: center;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color, #333);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ZONE SUMMARY
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
#btZoneSummary {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btZoneSummary > div {
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE ADJUSTMENTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline-heatmap-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-control-group {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proximity-radar-svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btRadarControls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btZoneSummary {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
/**
|
||||||
|
* Signal Activity Timeline Component
|
||||||
|
* Lightweight visualization for RF signal presence over time
|
||||||
|
* Used for TSCM sweeps and investigative analysis
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TIMELINE CONTAINER
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline {
|
||||||
|
background: var(--bg-card, #1a1a1a);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline.collapsed .signal-timeline-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline.collapsed .signal-timeline-header {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-header:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-body {
|
||||||
|
padding: 0 12px 12px 12px;
|
||||||
|
border-top: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-collapse-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline.collapsed .signal-timeline-collapse-icon {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-header-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-header-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-header-stat .stat-value {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-btn {
|
||||||
|
background: var(--bg-secondary, #252525);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-btn:hover {
|
||||||
|
background: var(--bg-elevated, #2a2a2a);
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-btn.active {
|
||||||
|
background: var(--accent-cyan, #4a9eff);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time window selector */
|
||||||
|
.signal-timeline-window {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-window select {
|
||||||
|
background: var(--bg-secondary, #252525);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TIME AXIS
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-axis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 80px 0 100px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-axis-label {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-axis-label::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SWIMLANES
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-lanes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lanes::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lanes::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary, #252525);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lanes::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color, #444);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lanes::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lane {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 36px;
|
||||||
|
background: var(--bg-secondary, #252525);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lane:hover {
|
||||||
|
background: var(--bg-elevated, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lane.expanded {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lane.baseline {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lane.baseline:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal label */
|
||||||
|
.signal-timeline-label {
|
||||||
|
width: 130px;
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
border-right: 1px solid var(--border-color, #333);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-freq {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-name {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator */
|
||||||
|
.signal-timeline-status {
|
||||||
|
width: 4px;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-status[data-status="new"] {
|
||||||
|
background: var(--signal-new, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-status[data-status="baseline"] {
|
||||||
|
background: var(--signal-baseline, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-status[data-status="burst"] {
|
||||||
|
background: var(--signal-burst, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-status[data-status="flagged"] {
|
||||||
|
background: var(--signal-emergency, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-status[data-status="gone"] {
|
||||||
|
background: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRACK (where bars are drawn)
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-track {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-track-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid lines */
|
||||||
|
.signal-timeline-grid {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--border-color, #333);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SIGNAL BARS
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 16px;
|
||||||
|
min-width: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strength variants (height) */
|
||||||
|
.signal-timeline-bar[data-strength="1"] { height: 6px; }
|
||||||
|
.signal-timeline-bar[data-strength="2"] { height: 10px; }
|
||||||
|
.signal-timeline-bar[data-strength="3"] { height: 14px; }
|
||||||
|
.signal-timeline-bar[data-strength="4"] { height: 18px; }
|
||||||
|
.signal-timeline-bar[data-strength="5"] { height: 22px; }
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
.signal-timeline-bar[data-status="new"] {
|
||||||
|
background: var(--signal-new, #3b82f6);
|
||||||
|
box-shadow: 0 0 6px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-bar[data-status="baseline"] {
|
||||||
|
background: var(--signal-baseline, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-bar[data-status="burst"] {
|
||||||
|
background: var(--signal-burst, #f59e0b);
|
||||||
|
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-bar[data-status="flagged"] {
|
||||||
|
background: var(--signal-emergency, #ef4444);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||||
|
animation: flaggedPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flaggedPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lane:hover .signal-timeline-bar {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EXPANDED VIEW (tick marks)
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-ticks {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px solid var(--border-color, #333);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-lane.expanded .signal-timeline-ticks {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-tick {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-tick[data-strength="1"] { height: 4px; }
|
||||||
|
.signal-timeline-tick[data-strength="2"] { height: 8px; }
|
||||||
|
.signal-timeline-tick[data-strength="3"] { height: 12px; }
|
||||||
|
.signal-timeline-tick[data-strength="4"] { height: 16px; }
|
||||||
|
.signal-timeline-tick[data-strength="5"] { height: 20px; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ANNOTATIONS
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-annotations {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid var(--border-color, #333);
|
||||||
|
max-height: 60px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-annotation {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-annotation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
background: var(--bg-secondary, #252525);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-annotation-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-annotation[data-type="new"] {
|
||||||
|
border-left: 2px solid var(--signal-new, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-annotation[data-type="burst"] {
|
||||||
|
border-left: 2px solid var(--signal-burst, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-annotation[data-type="pattern"] {
|
||||||
|
border-left: 2px solid var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-annotation[data-type="flagged"] {
|
||||||
|
border-left: 2px solid var(--signal-emergency, #ef4444);
|
||||||
|
color: var(--signal-emergency, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOOLTIP
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-elevated, #2a2a2a);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-tooltip-header {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-tooltip-row span:last-child {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS ROW
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-stats {
|
||||||
|
width: 50px;
|
||||||
|
min-width: 50px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
border-left: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-stat-count {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-stat-label {
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EMPTY STATE
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px 20px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-empty-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LEGEND - compact inline version
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-legend {
|
||||||
|
display: none; /* Hide by default - status colors are self-explanatory */
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-legend-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); }
|
||||||
|
.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); }
|
||||||
|
.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); }
|
||||||
|
.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
NOW MARKER
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-now {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-now::after {
|
||||||
|
content: 'NOW';
|
||||||
|
position: absolute;
|
||||||
|
top: -14px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MARKER (first seen indicator)
|
||||||
|
============================================ */
|
||||||
|
.signal-timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-bottom: 8px solid var(--signal-new, #3b82f6);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-timeline-marker::after {
|
||||||
|
content: attr(data-label);
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--signal-new, #3b82f6);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
/**
|
||||||
|
* Toast Notification System
|
||||||
|
* Reusable toast notifications for update alerts and other messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOAST CONTAINER
|
||||||
|
============================================ */
|
||||||
|
#toastContainer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 10001;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toastContainer > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UPDATE TOAST
|
||||||
|
============================================ */
|
||||||
|
.update-toast {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-card, #121620);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
max-width: 340px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-indicator {
|
||||||
|
width: 4px;
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-icon {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: -4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-close:hover {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-body strong {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-primary {
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-primary:hover {
|
||||||
|
background: #34d673;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-secondary {
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-secondary:hover {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
border-color: var(--border-light, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UPDATE MODAL
|
||||||
|
============================================ */
|
||||||
|
.update-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10002;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-overlay.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal {
|
||||||
|
background: var(--bg-card, #121620);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-overlay.show .update-modal {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-icon {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-icon svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-close:hover {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Info */
|
||||||
|
.update-version-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-current,
|
||||||
|
.update-version-latest {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-new {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-arrow {
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-arrow svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.update-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes h2,
|
||||||
|
.update-release-notes h3,
|
||||||
|
.update-release-notes h4 {
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes h2:first-child,
|
||||||
|
.update-release-notes h3:first-child,
|
||||||
|
.update-release-notes h4:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes ul {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes li {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning */
|
||||||
|
.update-warning {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-icon {
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-text strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-text p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options */
|
||||||
|
.update-options {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-option input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.update-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-progress-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color, #1f2937);
|
||||||
|
border-top-color: var(--accent-cyan, #4a9eff);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: updateSpin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes updateSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
.update-result {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-text code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-success {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-success .update-result-icon {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-success .update-result-text {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error .update-result-icon {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error .update-result-text {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error .update-result-text strong {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning .update-result-icon {
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning .update-result-text {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning .update-result-text strong {
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-info {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-info .update-result-icon {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-info .update-result-text {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.update-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border-color, #1f2937);
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-link:hover {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-primary {
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-primary:hover:not(:disabled) {
|
||||||
|
background: #34d673;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-secondary {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-elevated, #1a202c);
|
||||||
|
border-color: var(--border-light, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#toastContainer {
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal {
|
||||||
|
width: 95%;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-link {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Base Styles
|
||||||
|
* Reset, typography, and foundational element styles
|
||||||
|
* Requires: variables.css to be imported first
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CSS RESET
|
||||||
|
============================================ */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================ */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--text-4xl); }
|
||||||
|
h2 { font-size: var(--text-3xl); }
|
||||||
|
h3 { font-size: var(--text-2xl); }
|
||||||
|
h4 { font-size: var(--text-xl); }
|
||||||
|
h5 { font-size: var(--text-lg); }
|
||||||
|
h6 { font-size: var(--text-base); }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-cyan-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong, b {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, pre, samp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM ELEMENTS
|
||||||
|
============================================ */
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-cyan-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TABLES
|
||||||
|
============================================ */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LISTS
|
||||||
|
============================================ */
|
||||||
|
ul, ol {
|
||||||
|
padding-left: var(--space-6);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UTILITY CLASSES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
.text-primary { color: var(--text-primary); }
|
||||||
|
.text-secondary { color: var(--text-secondary); }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-cyan { color: var(--accent-cyan); }
|
||||||
|
.text-green { color: var(--accent-green); }
|
||||||
|
.text-red { color: var(--accent-red); }
|
||||||
|
.text-orange { color: var(--accent-orange); }
|
||||||
|
.text-amber { color: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Font utilities */
|
||||||
|
.font-mono { font-family: var(--font-mono); }
|
||||||
|
.font-medium { font-weight: var(--font-medium); }
|
||||||
|
.font-semibold { font-weight: var(--font-semibold); }
|
||||||
|
.font-bold { font-weight: var(--font-bold); }
|
||||||
|
|
||||||
|
/* Text sizes */
|
||||||
|
.text-xs { font-size: var(--text-xs); }
|
||||||
|
.text-sm { font-size: var(--text-sm); }
|
||||||
|
.text-base { font-size: var(--text-base); }
|
||||||
|
.text-lg { font-size: var(--text-lg); }
|
||||||
|
.text-xl { font-size: var(--text-xl); }
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.inline-flex { display: inline-flex; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
|
||||||
|
/* Flexbox */
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.gap-1 { gap: var(--space-1); }
|
||||||
|
.gap-2 { gap: var(--space-2); }
|
||||||
|
.gap-3 { gap: var(--space-3); }
|
||||||
|
.gap-4 { gap: var(--space-4); }
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
.m-0 { margin: 0; }
|
||||||
|
.mt-2 { margin-top: var(--space-2); }
|
||||||
|
.mt-4 { margin-top: var(--space-4); }
|
||||||
|
.mb-2 { margin-bottom: var(--space-2); }
|
||||||
|
.mb-4 { margin-bottom: var(--space-4); }
|
||||||
|
.p-2 { padding: var(--space-2); }
|
||||||
|
.p-3 { padding: var(--space-3); }
|
||||||
|
.p-4 { padding: var(--space-4); }
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
.rounded { border-radius: var(--radius-md); }
|
||||||
|
.rounded-lg { border-radius: var(--radius-lg); }
|
||||||
|
.border { border: 1px solid var(--border-color); }
|
||||||
|
|
||||||
|
/* Truncate text */
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SCROLLBAR STYLING
|
||||||
|
============================================ */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-light);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-light) var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SELECTION
|
||||||
|
============================================ */
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UX POLISH - TRANSITIONS & INTERACTIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Smooth page transitions */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better focus ring for all interactive elements */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove focus ring for mouse users */
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state feedback */
|
||||||
|
button:active:not(:disabled),
|
||||||
|
a:active,
|
||||||
|
[role="button"]:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for all interactive elements */
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
[role="button"] {
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast),
|
||||||
|
opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle hover lift effect for cards and panels */
|
||||||
|
.card:hover,
|
||||||
|
.panel:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link underline on hover */
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip link for accessibility */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
z-index: 9999;
|
||||||
|
transition: top var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
:root {
|
||||||
|
--border-color: #4b5563;
|
||||||
|
--text-secondary: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT UI Components
|
||||||
|
* Reusable component styles for buttons, cards, badges, etc.
|
||||||
|
* Requires: variables.css and base.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BUTTONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Base button */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-cyan-hover);
|
||||||
|
border-color: var(--accent-cyan-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--accent-red);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background: #16a34a;
|
||||||
|
border-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button sizes */
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button */
|
||||||
|
.btn-icon {
|
||||||
|
padding: var(--space-2);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.btn-sm {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CARDS / PANELS
|
||||||
|
============================================ */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel variant (used in dashboards) */
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-indicator.active {
|
||||||
|
background: var(--status-online);
|
||||||
|
box-shadow: 0 0 8px var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BADGES
|
||||||
|
============================================ */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: var(--accent-green-dim);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATUS INDICATORS
|
||||||
|
============================================ */
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--status-offline);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online,
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--status-online);
|
||||||
|
box-shadow: 0 0 6px var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.warning {
|
||||||
|
background: var(--status-warning);
|
||||||
|
box-shadow: 0 0 6px var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error,
|
||||||
|
.status-dot.offline {
|
||||||
|
background: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.inactive {
|
||||||
|
background: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for active status */
|
||||||
|
.status-dot.pulse {
|
||||||
|
animation: statusPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes statusPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EMPTY STATE
|
||||||
|
============================================ */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-description {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-action {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LOADING STATES
|
||||||
|
============================================ */
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-lg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loader */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--bg-tertiary) 25%,
|
||||||
|
var(--bg-elevated) 50%,
|
||||||
|
var(--bg-tertiary) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS STRIP
|
||||||
|
============================================ */
|
||||||
|
.stats-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
height: var(--stats-strip-height);
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM GROUPS
|
||||||
|
============================================ */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline checkbox/radio */
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ALERTS / TOASTS
|
||||||
|
============================================ */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: var(--accent-green-dim);
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
border-color: var(--accent-orange);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOOLTIPS
|
||||||
|
============================================ */
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||||
|
z-index: var(--z-tooltip);
|
||||||
|
pointer-events: none;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ICONS
|
||||||
|
============================================ */
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--lg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SECTION HEADERS
|
||||||
|
============================================ */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DIVIDERS
|
||||||
|
============================================ */
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-vertical {
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UX POLISH - ENHANCED INTERACTIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Button hover lift */
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card/Panel hover effects */
|
||||||
|
.card,
|
||||||
|
.panel {
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-base),
|
||||||
|
border-color var(--transition-base),
|
||||||
|
transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.panel:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats strip value highlight on hover */
|
||||||
|
.strip-stat {
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stat:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot pulse animation */
|
||||||
|
.status-dot.online,
|
||||||
|
.status-dot.active {
|
||||||
|
animation: statusGlow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes statusGlow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 6px var(--status-online);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge hover effect */
|
||||||
|
.badge {
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert entrance animation */
|
||||||
|
.alert {
|
||||||
|
animation: alertSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes alertSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner smooth appearance */
|
||||||
|
.spinner {
|
||||||
|
animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input focus glow */
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav item active indicator */
|
||||||
|
.mode-nav-btn.active::after,
|
||||||
|
.mobile-nav-btn.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60%;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth tooltip appearance */
|
||||||
|
[data-tooltip]::after {
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-fast),
|
||||||
|
visibility var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
transform: translateX(-50%) translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state with better visual feedback */
|
||||||
|
:disabled,
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(30%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Layout Styles
|
||||||
|
* Global layout structure: header, navigation, sidebar, main content
|
||||||
|
* Requires: variables.css, base.css, components.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
APP SHELL
|
||||||
|
============================================ */
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLOBAL HEADER
|
||||||
|
============================================ */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.app-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-tagline {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page title in header */
|
||||||
|
.app-header-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-subtitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header utilities */
|
||||||
|
.header-utilities {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-clock-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLOBAL NAVIGATION
|
||||||
|
============================================ */
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
height: var(--nav-height);
|
||||||
|
gap: var(--space-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav groups */
|
||||||
|
.nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown trigger */
|
||||||
|
.nav-dropdown-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-arrow {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group.open .nav-dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown menu */
|
||||||
|
.nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--space-1);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group.open .nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav items */
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav divider */
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav utilities (right side) */
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOBILE NAVIGATION
|
||||||
|
============================================ */
|
||||||
|
.mobile-nav {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn:hover,
|
||||||
|
.mobile-nav-btn.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger button */
|
||||||
|
.hamburger-btn {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(4px, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(4px, -4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CONTENT LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Main content with optional sidebar */
|
||||||
|
.content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.app-sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content-full {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DASHBOARD LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Full-screen dashboard (maps, etc.) */
|
||||||
|
.dashboard-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-logo {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-logo span {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-normal);
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PAGE LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
.page-container {
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE BREAKPOINTS
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.app-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-utilities {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
OVERLAY (for mobile drawers)
|
||||||
|
============================================ */
|
||||||
|
.drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: calc(var(--z-fixed) - 1);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BACK LINK
|
||||||
|
============================================ */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODE NAVIGATION (from index.css)
|
||||||
|
Used by nav.html partial across all pages
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Mode Navigation Bar */
|
||||||
|
.mode-nav {
|
||||||
|
display: none;
|
||||||
|
background: #151a23 !important; /* Explicit color - forced to ensure consistency */
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mode-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active .nav-icon {
|
||||||
|
filter: brightness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn:hover {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Navigation */
|
||||||
|
.mode-nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||||
|
font-size: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
|
filter: brightness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Bar Utilities (clock, theme, tools) */
|
||||||
|
.nav-utilities {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-time {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle icon states in nav bar */
|
||||||
|
.nav-tool-btn .icon-sun,
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects toggle icon states */
|
||||||
|
.nav-tool-btn .icon-effects-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Design Tokens
|
||||||
|
* Single source of truth for colors, spacing, typography, and effects
|
||||||
|
* Import this file FIRST in any stylesheet that needs design tokens
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ============================================
|
||||||
|
COLOR PALETTE - Dark Theme (Default)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Backgrounds - layered depth system */
|
||||||
|
--bg-primary: #0a0c10;
|
||||||
|
--bg-secondary: #0f1218;
|
||||||
|
--bg-tertiary: #151a23;
|
||||||
|
--bg-card: #121620;
|
||||||
|
--bg-elevated: #1a202c;
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
/* Background aliases for components */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
|
/* Accent colors */
|
||||||
|
--accent-cyan: #4a9eff;
|
||||||
|
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
|
||||||
|
--accent-cyan-hover: #6bb3ff;
|
||||||
|
--accent-green: #22c55e;
|
||||||
|
--accent-green-dim: rgba(34, 197, 94, 0.15);
|
||||||
|
--accent-red: #ef4444;
|
||||||
|
--accent-red-dim: rgba(239, 68, 68, 0.15);
|
||||||
|
--accent-orange: #f59e0b;
|
||||||
|
--accent-orange-dim: rgba(245, 158, 11, 0.15);
|
||||||
|
--accent-amber: #d4a853;
|
||||||
|
--accent-amber-dim: rgba(212, 168, 83, 0.15);
|
||||||
|
--accent-yellow: #eab308;
|
||||||
|
--accent-purple: #a855f7;
|
||||||
|
|
||||||
|
/* Text hierarchy */
|
||||||
|
--text-primary: #e8eaed;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-dim: #4b5563;
|
||||||
|
--text-muted: #374151;
|
||||||
|
--text-inverse: #0a0c10;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-color: #1f2937;
|
||||||
|
--border-light: #374151;
|
||||||
|
--border-glow: rgba(74, 158, 255, 0.2);
|
||||||
|
--border-focus: var(--accent-cyan);
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
--status-online: #22c55e;
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-error: #ef4444;
|
||||||
|
--status-offline: #6b7280;
|
||||||
|
--status-info: #3b82f6;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SPACING SCALE
|
||||||
|
============================================ */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================ */
|
||||||
|
--font-sans: 'Space Mono', ui-monospace, 'SF Mono', monospace;
|
||||||
|
--font-mono: 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', monospace;
|
||||||
|
|
||||||
|
/* Font sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
--text-4xl: 30px;
|
||||||
|
|
||||||
|
/* Font weights */
|
||||||
|
--font-normal: 400;
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
--font-bold: 700;
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BORDERS & RADIUS
|
||||||
|
============================================ */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SHADOWS
|
||||||
|
============================================ */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15);
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRANSITIONS
|
||||||
|
============================================ */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
--transition-slow: 300ms ease;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Z-INDEX SCALE
|
||||||
|
============================================ */
|
||||||
|
--z-base: 0;
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-fixed: 300;
|
||||||
|
--z-modal-backdrop: 400;
|
||||||
|
--z-modal: 500;
|
||||||
|
--z-toast: 600;
|
||||||
|
--z-tooltip: 700;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LAYOUT
|
||||||
|
============================================ */
|
||||||
|
--header-height: 60px;
|
||||||
|
--nav-height: 44px;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--stats-strip-height: 36px;
|
||||||
|
--content-max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIGHT THEME OVERRIDES
|
||||||
|
============================================ */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #f8fafc;
|
||||||
|
--bg-secondary: #f1f5f9;
|
||||||
|
--bg-tertiary: #e2e8f0;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-elevated: #f8fafc;
|
||||||
|
--bg-overlay: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
/* Background aliases for components */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
|
--accent-cyan: #2563eb;
|
||||||
|
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
|
||||||
|
--accent-cyan-hover: #1d4ed8;
|
||||||
|
--accent-green: #16a34a;
|
||||||
|
--accent-green-dim: rgba(22, 163, 74, 0.1);
|
||||||
|
--accent-red: #dc2626;
|
||||||
|
--accent-red-dim: rgba(220, 38, 38, 0.1);
|
||||||
|
--accent-orange: #d97706;
|
||||||
|
--accent-orange-dim: rgba(217, 119, 6, 0.1);
|
||||||
|
--accent-amber: #b45309;
|
||||||
|
--accent-amber-dim: rgba(180, 83, 9, 0.1);
|
||||||
|
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--text-muted: #cbd5e1;
|
||||||
|
--text-inverse: #f8fafc;
|
||||||
|
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--border-light: #cbd5e1;
|
||||||
|
--border-glow: rgba(37, 99, 235, 0.15);
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||||
|
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
REDUCED MOTION
|
||||||
|
============================================ */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--transition-fast: 0ms;
|
||||||
|
--transition-base: 0ms;
|
||||||
|
--transition-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* Local font declarations for offline mode */
|
||||||
|
|
||||||
|
/* Space Mono - Console font */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
/* ============================================
|
||||||
|
Global Navigation Styles
|
||||||
|
Shared across all pages using nav.html
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Icon base (kept lightweight for nav usage) */
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Navigation Bar */
|
||||||
|
.mode-nav {
|
||||||
|
display: none;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
|
||||||
|
border-bottom: 1px solid var(--border-color, #202833);
|
||||||
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mode-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color, #202833);
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.8);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active .nav-icon {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(24, 31, 44, 0.85);
|
||||||
|
border: 1px solid var(--border-light, #2b3645);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.95);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Navigation */
|
||||||
|
.mode-nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.8);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: rgba(16, 22, 32, 0.98);
|
||||||
|
border: 1px solid var(--border-color, #202833);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
|
background: rgba(27, 36, 51, 0.95);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Bar Utilities */
|
||||||
|
.nav-utilities {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #8a97a8);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-time {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(20, 33, 53, 0.6);
|
||||||
|
border: 1px solid rgba(77, 125, 191, 0.12);
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position relative needed for absolute positioned icon children */
|
||||||
|
.nav-tool-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:focus-visible,
|
||||||
|
.mode-nav-dropdown-btn:focus-visible,
|
||||||
|
.nav-action-btn:focus-visible,
|
||||||
|
.nav-tool-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-cyan, #4d7dbf);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav tool button SVG sizing and styling */
|
||||||
|
.nav-tool-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle icon states */
|
||||||
|
.nav-tool-btn .icon-sun,
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects/animations toggle icon states */
|
||||||
|
.nav-tool-btn .icon-effects-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Dashboard Button in Nav */
|
||||||
|
a.nav-dashboard-btn,
|
||||||
|
a.nav-dashboard-btn:link,
|
||||||
|
a.nav-dashboard-btn:visited {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(20, 33, 53, 0.6) !important;
|
||||||
|
border: 1px solid rgba(77, 125, 191, 0.12) !important;
|
||||||
|
color: #b7c1cf !important;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.nav-dashboard-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.9) !important;
|
||||||
|
border-color: #4d7dbf !important;
|
||||||
|
color: #4d7dbf !important;
|
||||||
|
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dashboard-btn .icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dashboard-btn .icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dashboard-btn .nav-label {
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Help Modal Styles
|
||||||
|
* Shared across all pages that include the help modal partial
|
||||||
|
*/
|
||||||
|
|
||||||
|
.help-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 10000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content h2 {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content h3 {
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
margin: 25px 0 15px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-close:hover {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary, #0a0c10);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .desc {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
position: relative;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li::before {
|
||||||
|
content: '\203A';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary, #0a0c10);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab:not(:last-child) {
|
||||||
|
border-right: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab:hover {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab.active {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure code tags are styled */
|
||||||
|
.help-modal code {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/* Container Layout */
|
||||||
|
.landing-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Stack logo, title, box vertically */
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Effects */
|
||||||
|
.landing-scanline {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; width: 100%; height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
animation: scanlineMove 5s linear infinite;
|
||||||
|
opacity: 0.4;
|
||||||
|
z-index: 1; /* Behind content */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanlineMove {
|
||||||
|
0% { top: 0; }
|
||||||
|
100% { top: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
.landing-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 20px 0 5px 0;
|
||||||
|
text-indent: 0.4em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-tagline {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Login Box */
|
||||||
|
.login-box {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 380px;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 20px var(--accent-cyan-dim);
|
||||||
|
box-sizing: border-box; /* Ensures padding doesn't hide inputs */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hacker Style Error */
|
||||||
|
.flash-error {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-red);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-left: 3px solid var(--accent-red);
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-prefix { font-weight: 700; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box; /* Crucial for visibility */
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-enter-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
padding: 15px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-version {
|
||||||
|
margin-top: 25px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* ACARS Sidebar Styles */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
50% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main ACARS Sidebar (Collapsible) */
|
||||||
|
.main-acars-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.main-acars-collapse-btn {
|
||||||
|
width: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.main-acars-collapse-btn:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
.main-acars-collapse-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; }
|
||||||
|
.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; }
|
||||||
|
#mainAcarsCollapseIcon {
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
.main-acars-sidebar.collapsed #mainAcarsCollapseIcon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.main-acars-content {
|
||||||
|
width: 196px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.3s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.main-acars-sidebar.collapsed .main-acars-content {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.main-acars-messages {
|
||||||
|
max-height: 350px;
|
||||||
|
}
|
||||||
|
.main-acars-msg {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
animation: fadeInMsg 0.3s ease;
|
||||||
|
}
|
||||||
|
.main-acars-msg:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
}
|
||||||
|
@keyframes fadeInMsg {
|
||||||
|
from { opacity: 0; transform: translateY(-3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ACARS Status Indicator */
|
||||||
|
.acars-status-dot.listening {
|
||||||
|
background: var(--accent-cyan) !important;
|
||||||
|
animation: acars-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.acars-status-dot.receiving {
|
||||||
|
background: var(--accent-green) !important;
|
||||||
|
}
|
||||||
|
.acars-status-dot.error {
|
||||||
|
background: var(--accent-red) !important;
|
||||||
|
}
|
||||||
|
@keyframes acars-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||||
|
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/* APRS Function Bar (Stats Strip) Styles */
|
||||||
|
.aprs-strip {
|
||||||
|
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.aprs-strip-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 55px;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-stat:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-label {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
/* Signal stat coloring */
|
||||||
|
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||||
|
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||||
|
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.aprs-strip .strip-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-select {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-select:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-input-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-input {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-input:hover,
|
||||||
|
.aprs-strip .strip-input:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Status Indicators */
|
||||||
|
.aprs-strip .strip-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-tool {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 59, 48, 0.2);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-tool.ok {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border-color: rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.aprs-strip .strip-btn {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
border-color: rgba(74, 158, 255, 0.4);
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-btn.primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-btn.stop {
|
||||||
|
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.aprs-strip .strip-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator */
|
||||||
|
.aprs-strip .strip-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.aprs-strip .status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
.aprs-strip .status-dot.listening {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.aprs-strip .status-dot.tracking {
|
||||||
|
background: var(--accent-green);
|
||||||
|
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.aprs-strip .status-dot.error {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
@keyframes aprs-strip-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||||
|
50% { opacity: 0.6; box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time display */
|
||||||
|
.aprs-strip .strip-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* APRS Status Bar Styles (Sidebar - legacy) */
|
||||||
|
.aprs-status-bar {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.aprs-status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.aprs-status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
.aprs-status-dot.standby { background: var(--text-muted); }
|
||||||
|
.aprs-status-dot.listening {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
animation: aprs-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.aprs-status-dot.tracking { background: var(--accent-green); }
|
||||||
|
.aprs-status-dot.error { background: var(--accent-red); }
|
||||||
|
@keyframes aprs-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||||
|
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
||||||
|
}
|
||||||
|
.aprs-status-text {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.aprs-status-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.aprs-stat {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.aprs-stat-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal Meter Styles */
|
||||||
|
.aprs-signal-meter {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.aprs-meter-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.aprs-meter-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.aprs-meter-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
.aprs-meter-burst {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
background: rgba(255, 193, 7, 0.2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: burst-flash 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes burst-flash {
|
||||||
|
0% { opacity: 1; transform: scale(1.1); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
.aprs-meter-bar-container {
|
||||||
|
position: relative;
|
||||||
|
height: 16px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.aprs-meter-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--accent-green) 0%,
|
||||||
|
var(--accent-cyan) 50%,
|
||||||
|
var(--accent-yellow) 75%,
|
||||||
|
var(--accent-red) 100%
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.1s ease-out;
|
||||||
|
}
|
||||||
|
.aprs-meter-bar.no-signal {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.aprs-meter-ticks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.aprs-meter-status {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.aprs-meter-status.active {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
.aprs-meter-status.no-signal {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* Spy Stations Mode Styles
|
||||||
|
* Number stations and diplomatic HF networks
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MAIN LAYOUT
|
||||||
|
============================================ */
|
||||||
|
.spy-stations-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-stations-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-stations-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-stations-title svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-stations-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATION GRID
|
||||||
|
============================================ */
|
||||||
|
.spy-stations-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATION CARD
|
||||||
|
============================================ */
|
||||||
|
.spy-station-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-card:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header */
|
||||||
|
.spy-station-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-flag {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-nickname {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type Badge */
|
||||||
|
.spy-station-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-badge-number {
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-badge-diplomatic {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Body */
|
||||||
|
.spy-station-body {
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-meta-label {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-meta-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-meta-mode {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frequencies */
|
||||||
|
.spy-station-freqs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-list {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-item {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Description */
|
||||||
|
.spy-station-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Footer */
|
||||||
|
.spy-station-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frequency Selector Group */
|
||||||
|
.spy-tune-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-select {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 120px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-select:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable frequency items in details modal */
|
||||||
|
.spy-freq-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-clickable:hover {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tune Button */
|
||||||
|
.spy-tune-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #000;
|
||||||
|
background: var(--accent-green);
|
||||||
|
border: none;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-tune-btn:hover {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-tune-btn svg {
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details Button */
|
||||||
|
.spy-details-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-details-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EMPTY STATE
|
||||||
|
============================================ */
|
||||||
|
.spy-station-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-empty p {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODE VISIBILITY - Ensure sidebar shows when active
|
||||||
|
============================================ */
|
||||||
|
#spystationsMode.active {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FILTER CHECKBOX STYLING
|
||||||
|
============================================ */
|
||||||
|
#spystationsMode .inline-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spystationsMode .inline-checkbox input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
#spystationsMode .inline-checkbox:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Large desktop (1200px+) */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.spy-stations-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop/Tablet landscape (1024px) */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.spy-stations-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet portrait (768px) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.spy-stations-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small tablet / large phone (640px) */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.spy-station-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-tune-btn,
|
||||||
|
.spy-details-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-tune-group {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile (480px) */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.spy-stations-container {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-stations-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-station-desc {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch device compliance */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.spy-tune-btn,
|
||||||
|
.spy-details-btn,
|
||||||
|
.spy-freq-select {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spy-freq-clickable {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,876 @@
|
|||||||
|
/**
|
||||||
|
* SSTV Mode Styles
|
||||||
|
* ISS Slow-Scan Television decoder interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODE VISIBILITY
|
||||||
|
============================================ */
|
||||||
|
#sstvMode.active {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VISUALS CONTAINER
|
||||||
|
============================================ */
|
||||||
|
.sstv-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MAIN ROW (Live Decode + Gallery)
|
||||||
|
============================================ */
|
||||||
|
.sstv-main-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS STRIP
|
||||||
|
============================================ */
|
||||||
|
.sstv-stats-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot.idle {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot.listening {
|
||||||
|
background: var(--accent-yellow);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot.decoding {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 6px var(--accent-cyan);
|
||||||
|
animation: pulse 0.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-status-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.start {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.start:hover {
|
||||||
|
background: var(--accent-cyan-bright, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.stop {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.stop:hover {
|
||||||
|
background: #ff1a53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-value.accent-cyan {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location inputs in strip */
|
||||||
|
.sstv-strip-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-loc-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-loc-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.gps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.gps:hover {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.update-tle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.update-tle:hover {
|
||||||
|
background: var(--accent-orange);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIVE DECODE SECTION
|
||||||
|
============================================ */
|
||||||
|
.sstv-live-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-title svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-canvas-container {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sstvCanvas {
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-decode-info {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-mode-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-progress-bar .progress {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-status-message {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Idle state */
|
||||||
|
.sstv-idle-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-idle-state svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-idle-state h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-idle-state p {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GALLERY SECTION
|
||||||
|
============================================ */
|
||||||
|
.sstv-gallery-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1.5;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-card:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #000;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-info {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-mode {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-timestamp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty gallery state */
|
||||||
|
.sstv-gallery-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-empty svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOP ROW (Map + Countdown)
|
||||||
|
============================================ */
|
||||||
|
.sstv-top-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
height: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ISS MAP ROW
|
||||||
|
============================================ */
|
||||||
|
.sstv-map-row {
|
||||||
|
flex: 1.5;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-container {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0a1628;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffcc00;
|
||||||
|
background: rgba(255, 204, 0, 0.2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-coords {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-alt {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-pass-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-pass-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-pass-value {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ISS MAP MARKER
|
||||||
|
============================================ */
|
||||||
|
.sstv-iss-marker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-dot {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #ffcc00;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
|
||||||
|
animation: iss-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffcc00;
|
||||||
|
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes iss-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 25px rgba(255, 204, 0, 1), 0 0 50px rgba(255, 204, 0, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Leaflet default marker styles */
|
||||||
|
.leaflet-marker-icon.sstv-iss-marker {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
COUNTDOWN PANEL
|
||||||
|
============================================ */
|
||||||
|
.sstv-countdown-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-header svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-timer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value.imminent {
|
||||||
|
color: var(--accent-green);
|
||||||
|
text-shadow: 0 0 20px rgba(0, 255, 136, 0.4);
|
||||||
|
animation: countdown-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value.active {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
text-shadow: 0 0 20px rgba(255, 204, 0, 0.4);
|
||||||
|
animation: countdown-pulse 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes countdown-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 6px 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-detail-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-detail-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status .sstv-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status.has-pass .sstv-status-dot {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status.imminent .sstv-status-dot {
|
||||||
|
background: var(--accent-green);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status.active .sstv-status-dot {
|
||||||
|
background: var(--accent-yellow);
|
||||||
|
box-shadow: 0 0 8px var(--accent-yellow);
|
||||||
|
animation: pulse 0.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
IMAGE MODAL
|
||||||
|
============================================ */
|
||||||
|
.sstv-image-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-modal.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-modal img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.sstv-main-row {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-section {
|
||||||
|
max-width: none;
|
||||||
|
min-height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-section {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.sstv-top-row {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-row {
|
||||||
|
flex: none;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-panel {
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-map {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-overlay {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sstv-stats-strip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-location {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-loc-input {
|
||||||
|
width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-map {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-info {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-overlay {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
/* ============================================
|
||||||
|
RESPONSIVE UTILITIES - iNTERCEPT
|
||||||
|
Shared responsive foundation for all pages
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* ============== CSS VARIABLES ============== */
|
||||||
|
:root {
|
||||||
|
/* Touch targets */
|
||||||
|
--touch-min: 44px;
|
||||||
|
--touch-comfortable: 48px;
|
||||||
|
|
||||||
|
/* Responsive spacing */
|
||||||
|
--spacing-xs: clamp(4px, 1vw, 8px);
|
||||||
|
--spacing-sm: clamp(8px, 2vw, 12px);
|
||||||
|
--spacing-md: clamp(12px, 3vw, 20px);
|
||||||
|
--spacing-lg: clamp(16px, 4vw, 32px);
|
||||||
|
|
||||||
|
/* Responsive typography */
|
||||||
|
--font-xs: clamp(10px, 2.5vw, 11px);
|
||||||
|
--font-sm: clamp(11px, 2.8vw, 12px);
|
||||||
|
--font-base: clamp(13px, 3vw, 14px);
|
||||||
|
--font-md: clamp(14px, 3.5vw, 16px);
|
||||||
|
--font-lg: clamp(16px, 4vw, 20px);
|
||||||
|
--font-xl: clamp(20px, 5vw, 28px);
|
||||||
|
--font-2xl: clamp(24px, 6vw, 40px);
|
||||||
|
|
||||||
|
/* Header height for calculations */
|
||||||
|
--header-height: 52px;
|
||||||
|
--nav-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--header-height: 60px;
|
||||||
|
--nav-height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
:root {
|
||||||
|
--header-height: 96px;
|
||||||
|
--nav-height: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== VIEWPORT HEIGHT FIX ============== */
|
||||||
|
/* Handles iOS Safari address bar and dynamic viewport */
|
||||||
|
.full-height {
|
||||||
|
height: 100dvh;
|
||||||
|
height: 100vh; /* Fallback */
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.full-height {
|
||||||
|
height: -webkit-fill-available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== HAMBURGER BUTTON ============== */
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: var(--touch-min);
|
||||||
|
height: var(--touch-min);
|
||||||
|
padding: 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1001;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn:hover {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
border-color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn span {
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent-cyan, #4a9eff);
|
||||||
|
margin: 2px 0;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.active span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(4px, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.active span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.active span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(4px, -4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide hamburger on desktop */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.hamburger-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== MOBILE DRAWER ============== */
|
||||||
|
.mobile-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: min(320px, 85vw);
|
||||||
|
height: 100dvh;
|
||||||
|
height: 100vh; /* Fallback */
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
border-right: 1px solid var(--border-color, #1f2937);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-drawer.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show sidebar normally on desktop */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mobile-drawer {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding-top: 0;
|
||||||
|
z-index: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== DRAWER OVERLAY ============== */
|
||||||
|
.drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide overlay on desktop */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.drawer-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== TOUCH TARGETS ============== */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
/* Ensure minimum touch target size for interactive elements */
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.preset-btn,
|
||||||
|
.mode-nav-btn,
|
||||||
|
.control-btn,
|
||||||
|
.nav-action-btn,
|
||||||
|
.icon-btn {
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
min-width: var(--touch-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="search"] {
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label,
|
||||||
|
.radio-group label {
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== RESPONSIVE UTILITIES ============== */
|
||||||
|
/* Hide on mobile */
|
||||||
|
.hide-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.hide-mobile {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide on tablet and up */
|
||||||
|
.show-mobile-only {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.show-mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide on desktop */
|
||||||
|
.hide-desktop {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.hide-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show only on desktop */
|
||||||
|
.show-desktop-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.show-desktop-only {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== SCROLLABLE AREAS ============== */
|
||||||
|
.scroll-x {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-x::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-x::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color, #1f2937);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar on mobile for cleaner look */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.scroll-x-mobile-hidden {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-x-mobile-hidden::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== MOBILE NAVIGATION BAR ============== */
|
||||||
|
.mobile-nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-card, #121620);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn:hover,
|
||||||
|
.mobile-nav-btn.active {
|
||||||
|
background: var(--bg-elevated, #1a202c);
|
||||||
|
border-color: var(--accent-cyan, #4a9eff);
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide mobile nav bar on desktop */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mobile-nav-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== RESPONSIVE GRID UTILITIES ============== */
|
||||||
|
.grid-responsive {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1 column base */
|
||||||
|
.grid-1-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.grid-1-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2-3 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid-2-3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== TYPOGRAPHY RESPONSIVE ============== */
|
||||||
|
.text-responsive-xs { font-size: var(--font-xs); }
|
||||||
|
.text-responsive-sm { font-size: var(--font-sm); }
|
||||||
|
.text-responsive-base { font-size: var(--font-base); }
|
||||||
|
.text-responsive-md { font-size: var(--font-md); }
|
||||||
|
.text-responsive-lg { font-size: var(--font-lg); }
|
||||||
|
.text-responsive-xl { font-size: var(--font-xl); }
|
||||||
|
.text-responsive-2xl { font-size: var(--font-2xl); }
|
||||||
|
|
||||||
|
/* Ensure minimum readable sizes for tiny text */
|
||||||
|
.text-min-readable {
|
||||||
|
font-size: max(10px, var(--font-xs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== MOBILE LAYOUT FIXES ============== */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
/* Fix main content to allow scrolling on mobile */
|
||||||
|
.main-content {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: calc(100dvh - var(--header-height) - var(--nav-height));
|
||||||
|
overflow-y: auto !important;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container should not clip content */
|
||||||
|
.container {
|
||||||
|
overflow: visible;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout containers need to stack vertically on mobile */
|
||||||
|
.wifi-layout-container,
|
||||||
|
.bt-layout-container {
|
||||||
|
flex-direction: column !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual panels should be scrollable, not clipped */
|
||||||
|
.wifi-visuals,
|
||||||
|
.bt-visuals {
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device lists should have reasonable height on mobile */
|
||||||
|
.wifi-device-list,
|
||||||
|
.bt-device-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual panels should stack in single column on mobile when visible */
|
||||||
|
.wifi-visuals,
|
||||||
|
.bt-visuals {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
|
||||||
|
#aircraftVisuals[style*="grid"] {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* APRS visuals - only when visible */
|
||||||
|
#aprsVisuals[style*="flex"] {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-visual-panel {
|
||||||
|
grid-column: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== MOBILE MAP FIXES ============== */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
/* Aircraft map container needs explicit height on mobile */
|
||||||
|
.aircraft-map-container {
|
||||||
|
height: 300px !important;
|
||||||
|
min-height: 300px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aircraftMap {
|
||||||
|
height: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* APRS map container */
|
||||||
|
#aprsMap {
|
||||||
|
min-height: 300px !important;
|
||||||
|
height: 300px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Satellite embed */
|
||||||
|
.satellite-dashboard-embed {
|
||||||
|
height: 400px !important;
|
||||||
|
min-height: 400px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map panels should be full width */
|
||||||
|
.wifi-visual-panel[style*="grid-column: span 2"] {
|
||||||
|
grid-column: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make map container full width when it has ACARS sidebar */
|
||||||
|
.wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ACARS sidebar should be below map on mobile */
|
||||||
|
.main-acars-sidebar {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-top: 1px solid var(--border-color, #1f2937) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-acars-sidebar.collapsed {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-acars-content {
|
||||||
|
max-height: 200px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== LEAFLET MOBILE TOUCH FIXES ============== */
|
||||||
|
.leaflet-container {
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-zoom {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-zoom a {
|
||||||
|
min-width: var(--touch-min, 44px) !important;
|
||||||
|
min-height: var(--touch-min, 44px) !important;
|
||||||
|
line-height: var(--touch-min, 44px) !important;
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== MOBILE HEADER STATS ============== */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.header-stats {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simplify header on mobile */
|
||||||
|
header h1 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 .tagline,
|
||||||
|
header h1 .version-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .subtitle {
|
||||||
|
font-size: 10px !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .logo svg {
|
||||||
|
width: 30px !important;
|
||||||
|
height: 30px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== MOBILE MODE PANELS ============== */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
/* Mode panel grids should be single column */
|
||||||
|
.data-grid,
|
||||||
|
.stats-grid,
|
||||||
|
.sensor-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers should be easier to tap */
|
||||||
|
.section h3 {
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables need horizontal scroll */
|
||||||
|
.message-table,
|
||||||
|
.sensor-table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure messages list is scrollable */
|
||||||
|
#messageList,
|
||||||
|
#sensorGrid,
|
||||||
|
.aprs-list {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== WELCOME PAGE MOBILE ============== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.welcome-container {
|
||||||
|
padding: 15px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-header {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-logo svg {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
font-size: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card {
|
||||||
|
padding: 12px 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-icon {
|
||||||
|
font-size: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-name {
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-desc {
|
||||||
|
font-size: 9px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-release {
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== TSCM MODE MOBILE ============== */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.tscm-layout {
|
||||||
|
flex-direction: column !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tscm-spectrum-panel,
|
||||||
|
.tscm-detection-panel {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== LISTENING POST MOBILE ============== */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.radio-controls-section {
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knobs-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-module-box {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #151a23;
|
--bg-card: #151a23;
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -93,7 +95,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
@@ -115,12 +117,34 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile header adjustments */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-badges {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stat-badge {
|
.stat-badge {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +164,45 @@ body {
|
|||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-status-dot.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
box-shadow: 0 0 6px var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item {
|
.status-item {
|
||||||
@@ -189,6 +248,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid */
|
/* Main dashboard grid */
|
||||||
|
/* Header ~52px + Nav 44px = ~96px, using 100px for safety */
|
||||||
.dashboard {
|
.dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -196,7 +256,7 @@ body {
|
|||||||
grid-template-columns: 1fr 1fr 340px;
|
grid-template-columns: 1fr 1fr 340px;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100vh - 60px);
|
height: calc(100vh - 100px);
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +495,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-value {
|
.telemetry-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -521,7 +581,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pass-time {
|
.pass-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom controls bar */
|
/* Bottom controls bar */
|
||||||
@@ -557,7 +617,7 @@ body {
|
|||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,10 +664,7 @@ body {
|
|||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-tile-pane,
|
/* Using actual dark tiles now - no filter needed */
|
||||||
.leaflet-container .leaflet-tile-pane {
|
|
||||||
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-control-zoom a {
|
.leaflet-control-zoom a {
|
||||||
background: var(--bg-panel) !important;
|
background: var(--bg-panel) !important;
|
||||||
@@ -674,24 +731,28 @@ body {
|
|||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
grid-template-columns: 1fr;
|
display: flex;
|
||||||
grid-template-rows: auto auto auto auto;
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.polar-container,
|
.polar-container,
|
||||||
.map-container {
|
.map-container {
|
||||||
grid-column: 1;
|
min-height: 250px;
|
||||||
min-height: 300px;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
grid-column: 1;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-bar {
|
.controls-bar {
|
||||||
grid-row: 4;
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
/* Settings Modal Styles */
|
||||||
|
|
||||||
|
.settings-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 10000;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal.active {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
background: var(--bg-dark, #0a0a0f);
|
||||||
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 .icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-close:hover {
|
||||||
|
color: var(--accent-red, #ff4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Tabs */
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab:hover {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Sections */
|
||||||
|
.settings-section {
|
||||||
|
display: none;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Row */
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: var(--text-muted, #666);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
|
background-color: var(--accent-cyan, #00d4ff);
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:focus + .toggle-slider {
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select Dropdown */
|
||||||
|
.settings-select {
|
||||||
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
min-width: 160px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Input */
|
||||||
|
.settings-input {
|
||||||
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input::placeholder {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Asset Status */
|
||||||
|
.asset-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-secondary, #0f0f1a);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-status-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-name {
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge.available {
|
||||||
|
background: rgba(0, 255, 136, 0.15);
|
||||||
|
color: var(--accent-green, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge.missing {
|
||||||
|
background: rgba(255, 68, 68, 0.15);
|
||||||
|
color: var(--accent-red, #ff4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge.checking {
|
||||||
|
background: rgba(255, 170, 0, 0.15);
|
||||||
|
color: var(--accent-orange, #ffaa00);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check Assets Button */
|
||||||
|
.check-assets-btn {
|
||||||
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-assets-btn:hover {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-assets-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS Detection Spinner */
|
||||||
|
.detecting-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: detecting-spin 0.8s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes detecting-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About Section */
|
||||||
|
.about-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info a {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Donate Button */
|
||||||
|
.donate-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #000;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tile Provider Custom URL */
|
||||||
|
.custom-url-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-url-row .settings-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Callout */
|
||||||
|
.settings-info {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info strong {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-modal.active {
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select,
|
||||||
|
.settings-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 694 KiB |
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* WiFi Channel Utilization Chart Component
|
||||||
|
*
|
||||||
|
* Displays channel utilization as a bar chart with recommendations.
|
||||||
|
* Shows AP count, client count, and utilization score per channel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ChannelChart = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Configuration
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
height: 120,
|
||||||
|
barWidth: 14,
|
||||||
|
barSpacing: 2,
|
||||||
|
padding: { top: 15, right: 10, bottom: 25, left: 30 },
|
||||||
|
colors: {
|
||||||
|
low: '#22c55e', // Green - low utilization
|
||||||
|
medium: '#eab308', // Yellow - medium
|
||||||
|
high: '#ef4444', // Red - high
|
||||||
|
recommended: '#3b82f6', // Blue - recommended
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
low: 0.3,
|
||||||
|
medium: 0.6,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2.4 GHz non-overlapping channels
|
||||||
|
const CHANNELS_2_4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||||
|
const NON_OVERLAPPING_2_4 = [1, 6, 11];
|
||||||
|
|
||||||
|
// 5 GHz channels (non-DFS)
|
||||||
|
const CHANNELS_5 = [36, 40, 44, 48, 149, 153, 157, 161, 165];
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// State
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
let container = null;
|
||||||
|
let currentBand = '2.4';
|
||||||
|
let channelStats = [];
|
||||||
|
let recommendations = [];
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Initialization
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
function init(containerId, options = {}) {
|
||||||
|
container = document.getElementById(containerId);
|
||||||
|
if (!container) {
|
||||||
|
console.warn('[ChannelChart] Container not found:', containerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(CONFIG, options);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Update
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
function update(stats, recs) {
|
||||||
|
channelStats = stats || [];
|
||||||
|
recommendations = recs || [];
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBand(band) {
|
||||||
|
currentBand = band;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Rendering
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const channels = currentBand === '2.4' ? CHANNELS_2_4 : CHANNELS_5;
|
||||||
|
const nonOverlapping = currentBand === '2.4' ? NON_OVERLAPPING_2_4 : CHANNELS_5;
|
||||||
|
|
||||||
|
// Build stats map
|
||||||
|
const statsMap = {};
|
||||||
|
channelStats.forEach(s => {
|
||||||
|
statsMap[s.channel] = s;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build recommendations map
|
||||||
|
const recsMap = {};
|
||||||
|
recommendations.forEach((r, i) => {
|
||||||
|
recsMap[r.channel] = { rank: i + 1, ...r };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
const width = channels.length * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.padding.left + CONFIG.padding.right;
|
||||||
|
const height = CONFIG.height + CONFIG.padding.top + CONFIG.padding.bottom;
|
||||||
|
const chartHeight = CONFIG.height;
|
||||||
|
|
||||||
|
// Find max values for scaling
|
||||||
|
let maxApCount = 1;
|
||||||
|
channelStats.forEach(s => {
|
||||||
|
if (s.ap_count > maxApCount) maxApCount = s.ap_count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build SVG with viewBox for responsive scaling
|
||||||
|
let svg = `
|
||||||
|
<svg viewBox="0 0 ${width} ${height}" class="channel-chart-svg" style="width: 100%; height: auto; max-height: ${height}px;">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="utilGradientLow" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.9" />
|
||||||
|
<stop offset="100%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.5" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="utilGradientMed" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.9" />
|
||||||
|
<stop offset="100%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.5" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="utilGradientHigh" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.9" />
|
||||||
|
<stop offset="100%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.5" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Y-axis label -->
|
||||||
|
<text x="10" y="${height / 2}" fill="#666" font-size="10" transform="rotate(-90, 10, ${height / 2})" text-anchor="middle">APs</text>
|
||||||
|
|
||||||
|
<!-- Y-axis ticks -->
|
||||||
|
${renderYAxis(chartHeight, maxApCount)}
|
||||||
|
|
||||||
|
<!-- Bars -->
|
||||||
|
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top})">
|
||||||
|
${channels.map((ch, i) => {
|
||||||
|
const stats = statsMap[ch] || { ap_count: 0, utilization_score: 0 };
|
||||||
|
const rec = recsMap[ch];
|
||||||
|
const isNonOverlapping = nonOverlapping.includes(ch);
|
||||||
|
return renderBar(i, ch, stats, rec, isNonOverlapping, chartHeight, maxApCount);
|
||||||
|
}).join('')}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- X-axis labels -->
|
||||||
|
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top + chartHeight + 5})">
|
||||||
|
${channels.map((ch, i) => {
|
||||||
|
const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2;
|
||||||
|
const isNonOverlapping = nonOverlapping.includes(ch);
|
||||||
|
return `<text x="${x}" y="12" fill="${isNonOverlapping ? '#fff' : '#666'}" font-size="9" text-anchor="middle">${ch}</text>`;
|
||||||
|
}).join('')}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add legend
|
||||||
|
svg += renderLegend();
|
||||||
|
|
||||||
|
// Add recommendations
|
||||||
|
if (recommendations.length > 0) {
|
||||||
|
svg += renderRecommendations();
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderYAxis(chartHeight, maxApCount) {
|
||||||
|
const ticks = [];
|
||||||
|
const tickCount = Math.min(5, maxApCount);
|
||||||
|
const step = Math.ceil(maxApCount / tickCount);
|
||||||
|
|
||||||
|
for (let i = 0; i <= maxApCount; i += step) {
|
||||||
|
const y = CONFIG.padding.top + chartHeight - (i / maxApCount * chartHeight);
|
||||||
|
ticks.push(`
|
||||||
|
<line x1="${CONFIG.padding.left - 5}" y1="${y}" x2="${CONFIG.padding.left}" y2="${y}" stroke="#444" />
|
||||||
|
<text x="${CONFIG.padding.left - 8}" y="${y + 3}" fill="#666" font-size="9" text-anchor="end">${i}</text>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBar(index, channel, stats, rec, isNonOverlapping, chartHeight, maxApCount) {
|
||||||
|
const x = index * (CONFIG.barWidth + CONFIG.barSpacing);
|
||||||
|
const barHeight = (stats.ap_count / maxApCount) * chartHeight;
|
||||||
|
const y = chartHeight - barHeight;
|
||||||
|
|
||||||
|
// Determine color based on utilization
|
||||||
|
let gradient = 'utilGradientLow';
|
||||||
|
if (stats.utilization_score >= CONFIG.thresholds.medium) {
|
||||||
|
gradient = 'utilGradientHigh';
|
||||||
|
} else if (stats.utilization_score >= CONFIG.thresholds.low) {
|
||||||
|
gradient = 'utilGradientMed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommended channel indicator
|
||||||
|
const isRecommended = rec && rec.rank <= 3;
|
||||||
|
const recIndicator = isRecommended ?
|
||||||
|
`<circle cx="${x + CONFIG.barWidth / 2}" cy="${chartHeight + 20}" r="4" fill="${CONFIG.colors.recommended}" />
|
||||||
|
<text x="${x + CONFIG.barWidth / 2}" y="${chartHeight + 23}" fill="#fff" font-size="7" text-anchor="middle">${rec.rank}</text>` : '';
|
||||||
|
|
||||||
|
// Non-overlapping channel marker
|
||||||
|
const channelMarker = isNonOverlapping ?
|
||||||
|
`<rect x="${x}" y="${chartHeight}" width="${CONFIG.barWidth}" height="2" fill="#3b82f6" />` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<g class="channel-bar" data-channel="${channel}">
|
||||||
|
<!-- Bar background -->
|
||||||
|
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
||||||
|
fill="#1a1a2e" rx="2" />
|
||||||
|
|
||||||
|
<!-- Utilization bar -->
|
||||||
|
<rect x="${x}" y="${y}" width="${CONFIG.barWidth}" height="${barHeight}"
|
||||||
|
fill="url(#${gradient})" rx="2" />
|
||||||
|
|
||||||
|
<!-- AP count label -->
|
||||||
|
${stats.ap_count > 0 ? `
|
||||||
|
<text x="${x + CONFIG.barWidth / 2}" y="${y - 4}" fill="#fff" font-size="9" text-anchor="middle">
|
||||||
|
${stats.ap_count}
|
||||||
|
</text>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${channelMarker}
|
||||||
|
${recIndicator}
|
||||||
|
|
||||||
|
<!-- Hover area -->
|
||||||
|
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
||||||
|
fill="transparent" class="channel-hover" />
|
||||||
|
</g>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend() {
|
||||||
|
return `
|
||||||
|
<div class="channel-chart-legend" style="display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 10px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.low}; border-radius: 2px;"></span>
|
||||||
|
<span style="color: #888;">Low</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.medium}; border-radius: 2px;"></span>
|
||||||
|
<span style="color: #888;">Medium</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.high}; border-radius: 2px;"></span>
|
||||||
|
<span style="color: #888;">High</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="width: 12px; height: 3px; background: #3b82f6; border-radius: 1px;"></span>
|
||||||
|
<span style="color: #888;">Non-overlapping</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecommendations() {
|
||||||
|
const topRecs = recommendations.slice(0, 3);
|
||||||
|
if (topRecs.length === 0) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="channel-chart-recommendations" style="margin-top: 12px; padding: 8px; background: #1a1a2e; border-radius: 4px;">
|
||||||
|
<div style="font-size: 10px; color: #888; margin-bottom: 6px;">Recommended Channels:</div>
|
||||||
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
${topRecs.map((rec, i) => `
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: ${i === 0 ? 'rgba(59, 130, 246, 0.2)' : '#0d0d1a'}; border-radius: 4px; border: 1px solid ${i === 0 ? '#3b82f6' : '#333'};">
|
||||||
|
<span style="font-size: 11px; font-weight: bold; color: ${i === 0 ? '#3b82f6' : '#666'};">#${i + 1}</span>
|
||||||
|
<span style="font-size: 12px; color: #fff;">Ch ${rec.channel}</span>
|
||||||
|
<span style="font-size: 9px; color: #666;">(${rec.band})</span>
|
||||||
|
${rec.is_dfs ? '<span style="font-size: 8px; color: #ff6b6b; margin-left: 4px;">DFS</span>' : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Public API
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
update,
|
||||||
|
setBand,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Consumption Sparkline Component
|
||||||
|
* SVG-based visualization for meter consumption deltas
|
||||||
|
* Adapted from RSSISparkline pattern
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ConsumptionSparkline = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Default configuration
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
width: 100,
|
||||||
|
height: 28,
|
||||||
|
maxSamples: 20,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
showGradient: true,
|
||||||
|
barMode: true // Use bars instead of line for consumption
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color thresholds for consumption deltas
|
||||||
|
// Green = normal/expected, Yellow = elevated, Red = spike
|
||||||
|
const DELTA_COLORS = {
|
||||||
|
normal: '#22c55e', // Green
|
||||||
|
elevated: '#eab308', // Yellow
|
||||||
|
spike: '#ef4444' // Red
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a delta value relative to the average
|
||||||
|
* @param {number} delta - The delta value
|
||||||
|
* @param {number} avgDelta - Average delta for comparison
|
||||||
|
* @returns {string} - 'normal', 'elevated', or 'spike'
|
||||||
|
*/
|
||||||
|
function classifyDelta(delta, avgDelta) {
|
||||||
|
if (avgDelta === 0 || isNaN(avgDelta)) {
|
||||||
|
return delta === 0 ? 'normal' : 'elevated';
|
||||||
|
}
|
||||||
|
const ratio = Math.abs(delta) / Math.abs(avgDelta);
|
||||||
|
if (ratio <= 1.5) return 'normal';
|
||||||
|
if (ratio <= 3) return 'elevated';
|
||||||
|
return 'spike';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for a delta value
|
||||||
|
*/
|
||||||
|
function getDeltaColor(delta, avgDelta) {
|
||||||
|
const classification = classifyDelta(delta, avgDelta);
|
||||||
|
return DELTA_COLORS[classification];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline SVG for consumption deltas
|
||||||
|
* @param {Array<{timestamp, delta}>} deltas - Array of delta objects
|
||||||
|
* @param {Object} config - Configuration options
|
||||||
|
* @returns {string} - SVG HTML string
|
||||||
|
*/
|
||||||
|
function createSparklineSvg(deltas, config = {}) {
|
||||||
|
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
const { width, height, strokeWidth, showGradient, barMode } = cfg;
|
||||||
|
|
||||||
|
if (!deltas || deltas.length < 1) {
|
||||||
|
return createEmptySparkline(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the delta values
|
||||||
|
const values = deltas.map(d => d.delta);
|
||||||
|
|
||||||
|
// Calculate statistics for color classification
|
||||||
|
const avgDelta = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
const maxDelta = Math.max(...values.map(Math.abs), 1);
|
||||||
|
|
||||||
|
if (barMode) {
|
||||||
|
return createBarSparkline(values, avgDelta, maxDelta, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createLineSparkline(values, avgDelta, maxDelta, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create bar-style sparkline (better for discrete readings)
|
||||||
|
*/
|
||||||
|
function createBarSparkline(values, avgDelta, maxDelta, cfg) {
|
||||||
|
const { width, height } = cfg;
|
||||||
|
const barCount = Math.min(values.length, cfg.maxSamples);
|
||||||
|
const displayValues = values.slice(-barCount);
|
||||||
|
|
||||||
|
const barWidth = Math.max(3, (width / barCount) - 1);
|
||||||
|
const barGap = 1;
|
||||||
|
|
||||||
|
let bars = '';
|
||||||
|
displayValues.forEach((val, i) => {
|
||||||
|
const normalizedHeight = (Math.abs(val) / maxDelta) * (height - 4);
|
||||||
|
const barHeight = Math.max(2, normalizedHeight);
|
||||||
|
const x = i * (barWidth + barGap);
|
||||||
|
const y = height - barHeight - 2;
|
||||||
|
const color = getDeltaColor(val, avgDelta);
|
||||||
|
|
||||||
|
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
|
||||||
|
width="${barWidth.toFixed(1)}" height="${barHeight.toFixed(1)}"
|
||||||
|
fill="${color}" rx="1" opacity="0.85"/>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<line x1="0" y1="${height - 2}" x2="${width}" y2="${height - 2}"
|
||||||
|
stroke="#333" stroke-width="1" opacity="0.3"/>
|
||||||
|
${bars}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create line-style sparkline
|
||||||
|
*/
|
||||||
|
function createLineSparkline(values, avgDelta, maxDelta, cfg) {
|
||||||
|
const { width, height, strokeWidth, showGradient } = cfg;
|
||||||
|
const displayValues = values.slice(-cfg.maxSamples);
|
||||||
|
|
||||||
|
if (displayValues.length < 2) {
|
||||||
|
return createEmptySparkline(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize values to 0-1 range
|
||||||
|
const normalized = displayValues.map(v => Math.abs(v) / maxDelta);
|
||||||
|
|
||||||
|
// Calculate path
|
||||||
|
const stepX = width / (normalized.length - 1);
|
||||||
|
let pathD = '';
|
||||||
|
let areaD = '';
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
normalized.forEach((val, i) => {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = height - (val * (height - 4)) - 2;
|
||||||
|
points.push({ x, y, value: displayValues[i] });
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
} else {
|
||||||
|
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
areaD += ` L${width},${height} Z`;
|
||||||
|
|
||||||
|
// Get color based on latest value
|
||||||
|
const latestValue = displayValues[displayValues.length - 1];
|
||||||
|
const strokeColor = getDeltaColor(latestValue, avgDelta);
|
||||||
|
const gradientId = `consumption-gradient-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
let gradientDef = '';
|
||||||
|
if (showGradient) {
|
||||||
|
gradientDef = `
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
|
||||||
|
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
${gradientDef}
|
||||||
|
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
|
||||||
|
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
|
||||||
|
r="2.5" fill="${strokeColor}" class="sparkline-dot" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create empty sparkline placeholder
|
||||||
|
*/
|
||||||
|
function createEmptySparkline(width, height) {
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg consumption-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
|
||||||
|
stroke="#444" stroke-width="1" stroke-dasharray="3,3" />
|
||||||
|
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
|
||||||
|
fill="#555" font-size="9" font-family="monospace">Collecting...</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline with summary stats
|
||||||
|
* @param {Array} deltas - Delta history
|
||||||
|
* @param {Object} options - Display options
|
||||||
|
* @returns {string} - HTML string
|
||||||
|
*/
|
||||||
|
function createSparklineWithStats(deltas, options = {}) {
|
||||||
|
const svg = createSparklineSvg(deltas, options);
|
||||||
|
|
||||||
|
if (!deltas || deltas.length < 2) {
|
||||||
|
return `<div class="consumption-sparkline-wrapper">${svg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trend
|
||||||
|
const recentDeltas = deltas.slice(-5);
|
||||||
|
const avgRecent = recentDeltas.reduce((a, d) => a + d.delta, 0) / recentDeltas.length;
|
||||||
|
const trend = avgRecent > 0 ? 'up' : avgRecent < 0 ? 'down' : 'stable';
|
||||||
|
const trendIcon = trend === 'up' ? '↑' : trend === 'down' ? '↓' : '↔';
|
||||||
|
const trendColor = trend === 'up' ? '#22c55e' : trend === 'down' ? '#ef4444' : '#888';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="consumption-sparkline-wrapper">
|
||||||
|
${svg}
|
||||||
|
<span class="consumption-trend" style="color: ${trendColor}" title="Recent trend">
|
||||||
|
${trendIcon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createSparklineSvg,
|
||||||
|
createEmptySparkline,
|
||||||
|
createSparklineWithStats,
|
||||||
|
classifyDelta,
|
||||||
|
getDeltaColor,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
DELTA_COLORS
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.ConsumptionSparkline = ConsumptionSparkline;
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
/**
|
||||||
|
* Device Card Component
|
||||||
|
* Unified device display for Bluetooth and TSCM modes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DeviceCard = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Range band configuration
|
||||||
|
const RANGE_BANDS = {
|
||||||
|
very_close: { label: 'Very Close', color: '#ef4444', description: '< 3m' },
|
||||||
|
close: { label: 'Close', color: '#f97316', description: '3-10m' },
|
||||||
|
nearby: { label: 'Nearby', color: '#eab308', description: '10-20m' },
|
||||||
|
far: { label: 'Far', color: '#6b7280', description: '> 20m' },
|
||||||
|
unknown: { label: 'Unknown', color: '#374151', description: 'N/A' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Protocol badge colors
|
||||||
|
const PROTOCOL_COLORS = {
|
||||||
|
ble: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
|
||||||
|
classic: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', border: 'rgba(139, 92, 246, 0.3)' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Heuristic badge configuration
|
||||||
|
const HEURISTIC_BADGES = {
|
||||||
|
new: { label: 'New', color: '#3b82f6', description: 'Not in baseline' },
|
||||||
|
persistent: { label: 'Persistent', color: '#22c55e', description: 'Continuously present' },
|
||||||
|
beacon_like: { label: 'Beacon', color: '#f59e0b', description: 'Regular advertising' },
|
||||||
|
strong_stable: { label: 'Strong', color: '#ef4444', description: 'Strong stable signal' },
|
||||||
|
random_address: { label: 'Random', color: '#6b7280', description: 'Privacy address' }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time
|
||||||
|
*/
|
||||||
|
function formatRelativeTime(isoString) {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (diff < 10) return 'Just now';
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create RSSI sparkline SVG
|
||||||
|
*/
|
||||||
|
function createSparkline(rssiHistory, options = {}) {
|
||||||
|
if (!rssiHistory || rssiHistory.length < 2) {
|
||||||
|
return '<span class="rssi-sparkline-empty">--</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = options.width || 60;
|
||||||
|
const height = options.height || 20;
|
||||||
|
const samples = rssiHistory.slice(-20); // Last 20 samples
|
||||||
|
|
||||||
|
// Normalize RSSI values (-100 to -30 range)
|
||||||
|
const minRssi = -100;
|
||||||
|
const maxRssi = -30;
|
||||||
|
const normalizedValues = samples.map(s => {
|
||||||
|
const rssi = s.rssi || s;
|
||||||
|
const normalized = (rssi - minRssi) / (maxRssi - minRssi);
|
||||||
|
return Math.max(0, Math.min(1, normalized));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate path
|
||||||
|
const stepX = width / (normalizedValues.length - 1);
|
||||||
|
let pathD = '';
|
||||||
|
normalizedValues.forEach((val, i) => {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = height - (val * height);
|
||||||
|
pathD += i === 0 ? `M${x},${y}` : ` L${x},${y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine color based on latest value
|
||||||
|
const latestRssi = samples[samples.length - 1].rssi || samples[samples.length - 1];
|
||||||
|
let strokeColor = '#6b7280';
|
||||||
|
if (latestRssi > -50) strokeColor = '#22c55e';
|
||||||
|
else if (latestRssi > -65) strokeColor = '#f59e0b';
|
||||||
|
else if (latestRssi > -80) strokeColor = '#f97316';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="rssi-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create heuristic badges HTML
|
||||||
|
*/
|
||||||
|
function createHeuristicBadges(flags) {
|
||||||
|
if (!flags || flags.length === 0) return '';
|
||||||
|
|
||||||
|
return flags.map(flag => {
|
||||||
|
const config = HEURISTIC_BADGES[flag];
|
||||||
|
if (!config) return '';
|
||||||
|
return `
|
||||||
|
<span class="device-heuristic-badge ${flag}"
|
||||||
|
style="--badge-color: ${config.color}"
|
||||||
|
title="${escapeHtml(config.description)}">
|
||||||
|
${escapeHtml(config.label)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create range band indicator
|
||||||
|
*/
|
||||||
|
function createRangeBand(band, confidence) {
|
||||||
|
const config = RANGE_BANDS[band] || RANGE_BANDS.unknown;
|
||||||
|
const confidencePercent = Math.round((confidence || 0) * 100);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="device-range-band" style="--range-color: ${config.color}">
|
||||||
|
<span class="range-label">${escapeHtml(config.label)}</span>
|
||||||
|
<span class="range-estimate">${escapeHtml(config.description)}</span>
|
||||||
|
${confidence > 0 ? `<span class="range-confidence" title="Confidence">${confidencePercent}%</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create protocol badge
|
||||||
|
*/
|
||||||
|
function createProtocolBadge(protocol) {
|
||||||
|
const config = PROTOCOL_COLORS[protocol] || PROTOCOL_COLORS.ble;
|
||||||
|
const label = protocol === 'classic' ? 'Classic' : 'BLE';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<span class="signal-proto-badge device-protocol"
|
||||||
|
style="background: ${config.bg}; color: ${config.color}; border-color: ${config.border}">
|
||||||
|
${escapeHtml(label)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Bluetooth device card
|
||||||
|
*/
|
||||||
|
function createDeviceCard(device, options = {}) {
|
||||||
|
// Debug: log received device data
|
||||||
|
console.log('[DeviceCard] Creating card for:', device.address, device);
|
||||||
|
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'signal-card device-card';
|
||||||
|
card.dataset.deviceId = device.device_id || '';
|
||||||
|
card.dataset.protocol = device.protocol || 'ble';
|
||||||
|
card.dataset.address = device.address || '';
|
||||||
|
|
||||||
|
// Add status classes
|
||||||
|
if (device.heuristic_flags && device.heuristic_flags.includes('new')) {
|
||||||
|
card.dataset.status = 'new';
|
||||||
|
} else if (device.in_baseline) {
|
||||||
|
card.dataset.status = 'baseline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store full device data for details modal
|
||||||
|
try {
|
||||||
|
card.dataset.deviceData = JSON.stringify(device);
|
||||||
|
} catch (e) {
|
||||||
|
card.dataset.deviceData = '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeTime = formatRelativeTime(device.last_seen) || 'Unknown';
|
||||||
|
const sparkline = createSparkline(device.rssi_history) || '';
|
||||||
|
const heuristicBadges = createHeuristicBadges(device.heuristic_flags) || '';
|
||||||
|
const rangeBand = createRangeBand(device.range_band, device.range_confidence) || '';
|
||||||
|
const protocolBadge = createProtocolBadge(device.protocol) || '';
|
||||||
|
|
||||||
|
// Build card with explicit defaults for all values
|
||||||
|
const deviceName = device.name || device.device_id || 'Unknown Device';
|
||||||
|
const deviceAddress = device.address || 'Unknown';
|
||||||
|
const addressType = device.address_type || 'unknown';
|
||||||
|
const rssiDisplay = (device.rssi_current !== null && device.rssi_current !== undefined)
|
||||||
|
? device.rssi_current + ' dBm' : '--';
|
||||||
|
const seenCount = device.seen_count || 0;
|
||||||
|
const inBaseline = device.in_baseline || false;
|
||||||
|
const mfrName = device.manufacturer_name || '';
|
||||||
|
|
||||||
|
// Build the HTML parts separately to avoid template issues
|
||||||
|
const headerHtml = '<div class="signal-card-header">' +
|
||||||
|
'<div class="signal-card-badges">' + protocolBadge + heuristicBadges + '</div>' +
|
||||||
|
'<span class="signal-status-pill" data-status="' + (inBaseline ? 'baseline' : 'new') + '">' +
|
||||||
|
'<span class="status-dot"></span>' + (inBaseline ? 'Known' : 'New') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
const identityHtml = '<div class="device-identity">' +
|
||||||
|
'<div class="device-name">' + escapeHtml(deviceName) + '</div>' +
|
||||||
|
'<div class="device-address">' +
|
||||||
|
'<span class="address-value">' + escapeHtml(deviceAddress) + '</span>' +
|
||||||
|
'<span class="address-type">(' + escapeHtml(addressType) + ')</span>' +
|
||||||
|
'</div></div>';
|
||||||
|
|
||||||
|
const signalHtml = '<div class="device-signal-row">' +
|
||||||
|
'<div class="rssi-display">' +
|
||||||
|
'<span class="rssi-current" title="Current RSSI">' + rssiDisplay + '</span>' +
|
||||||
|
sparkline + '</div>' + rangeBand + '</div>';
|
||||||
|
|
||||||
|
const mfrHtml = mfrName ?
|
||||||
|
'<div class="device-manufacturer">' +
|
||||||
|
'<span class="mfr-icon">🏭</span>' +
|
||||||
|
'<span class="mfr-name">' + escapeHtml(mfrName) + '</span></div>' : '';
|
||||||
|
|
||||||
|
const metaHtml = '<div class="device-meta-row">' +
|
||||||
|
'<span class="device-seen-count" title="Observation count">' +
|
||||||
|
'<span class="seen-icon">👁</span>' + seenCount + '×</span>' +
|
||||||
|
'<span class="device-timestamp" data-timestamp="' + escapeHtml(device.last_seen || '') + '">' +
|
||||||
|
escapeHtml(relativeTime) + '</span></div>';
|
||||||
|
|
||||||
|
const bodyHtml = '<div class="signal-card-body">' +
|
||||||
|
identityHtml + signalHtml + mfrHtml + metaHtml + '</div>';
|
||||||
|
|
||||||
|
card.innerHTML = headerHtml + bodyHtml;
|
||||||
|
|
||||||
|
// Make card clickable - opens modal with full details
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
showDeviceDetails(device);
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create advanced panel content
|
||||||
|
*/
|
||||||
|
function createAdvancedPanel(device) {
|
||||||
|
return `
|
||||||
|
<div class="signal-advanced-content">
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Device Details</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Address</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(device.address)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Address Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(device.address_type)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Protocol</span>
|
||||||
|
<span class="signal-advanced-value">${device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'}</span>
|
||||||
|
</div>
|
||||||
|
${device.manufacturer_id ? `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Manufacturer ID</span>
|
||||||
|
<span class="signal-advanced-value">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Signal Statistics</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Current RSSI</span>
|
||||||
|
<span class="signal-advanced-value">${device.rssi_current !== null ? device.rssi_current + ' dBm' : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Median RSSI</span>
|
||||||
|
<span class="signal-advanced-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Min/Max</span>
|
||||||
|
<span class="signal-advanced-value">${device.rssi_min || 'N/A'} / ${device.rssi_max || 'N/A'} dBm</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Confidence</span>
|
||||||
|
<span class="signal-advanced-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Observation Times</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">First Seen</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.first_seen))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Last Seen</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.last_seen))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Seen Count</span>
|
||||||
|
<span class="signal-advanced-value">${device.seen_count} observations</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Rate</span>
|
||||||
|
<span class="signal-advanced-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${device.service_uuids && device.service_uuids.length > 0 ? `
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Service UUIDs</div>
|
||||||
|
<div class="device-uuids">
|
||||||
|
${device.service_uuids.map(uuid => `<span class="device-uuid">${escapeHtml(uuid)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${device.heuristics ? `
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Behavioral Analysis</div>
|
||||||
|
<div class="device-heuristics-detail">
|
||||||
|
${Object.entries(device.heuristics).map(([key, value]) => `
|
||||||
|
<div class="heuristic-item ${value ? 'active' : ''}">
|
||||||
|
<span class="heuristic-name">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
||||||
|
<span class="heuristic-status">${value ? '✓' : '−'}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show device details in modal
|
||||||
|
*/
|
||||||
|
function showDeviceDetails(device) {
|
||||||
|
let modal = document.getElementById('deviceDetailsModal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'deviceDetailsModal';
|
||||||
|
modal.className = 'signal-details-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="signal-details-modal-backdrop"></div>
|
||||||
|
<div class="signal-details-modal-content">
|
||||||
|
<div class="signal-details-modal-header">
|
||||||
|
<div class="modal-header-info">
|
||||||
|
<span class="signal-details-modal-title"></span>
|
||||||
|
<span class="signal-details-modal-subtitle"></span>
|
||||||
|
</div>
|
||||||
|
<button class="signal-details-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="signal-details-modal-body"></div>
|
||||||
|
<div class="signal-details-modal-footer">
|
||||||
|
<button class="signal-details-copy-btn">Copy JSON</button>
|
||||||
|
<button class="signal-details-copy-addr-btn">Copy Address</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Close handlers
|
||||||
|
modal.querySelector('.signal-details-modal-backdrop').addEventListener('click', () => {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
});
|
||||||
|
modal.querySelector('.signal-details-modal-close').addEventListener('click', () => {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
});
|
||||||
|
// Escape key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update copy button handlers with current device
|
||||||
|
const copyBtn = modal.querySelector('.signal-details-copy-btn');
|
||||||
|
const copyAddrBtn = modal.querySelector('.signal-details-copy-addr-btn');
|
||||||
|
|
||||||
|
copyBtn.onclick = () => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => {
|
||||||
|
copyBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyBtn.textContent = 'Copy JSON'; }, 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
copyAddrBtn.onclick = () => {
|
||||||
|
navigator.clipboard.writeText(device.address).then(() => {
|
||||||
|
copyAddrBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyAddrBtn.textContent = 'Copy Address'; }, 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Populate modal header
|
||||||
|
modal.querySelector('.signal-details-modal-title').textContent = device.name || 'Unknown Device';
|
||||||
|
modal.querySelector('.signal-details-modal-subtitle').textContent = device.address;
|
||||||
|
|
||||||
|
// Populate modal body with enhanced content
|
||||||
|
modal.querySelector('.signal-details-modal-body').innerHTML = createModalContent(device);
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enhanced modal content
|
||||||
|
*/
|
||||||
|
function createModalContent(device) {
|
||||||
|
const protocolLabel = device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth';
|
||||||
|
const sparkline = createSparkline(device.rssi_history, { width: 120, height: 30 });
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="modal-device-header">
|
||||||
|
<div class="modal-badges">
|
||||||
|
${createProtocolBadge(device.protocol)}
|
||||||
|
${createHeuristicBadges(device.heuristic_flags)}
|
||||||
|
</div>
|
||||||
|
${createRangeBand(device.range_band, device.range_confidence)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Signal Strength</div>
|
||||||
|
<div class="modal-signal-display">
|
||||||
|
<div class="modal-rssi-large">${device.rssi_current !== null ? device.rssi_current : '--'}<span class="rssi-unit">dBm</span></div>
|
||||||
|
<div class="modal-sparkline">${sparkline}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-signal-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Median</span>
|
||||||
|
<span class="stat-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Min</span>
|
||||||
|
<span class="stat-value">${device.rssi_min !== null ? device.rssi_min + ' dBm' : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Max</span>
|
||||||
|
<span class="stat-value">${device.rssi_max !== null ? device.rssi_max + ' dBm' : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Confidence</span>
|
||||||
|
<span class="stat-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Device Information</div>
|
||||||
|
<div class="modal-info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Address</span>
|
||||||
|
<span class="info-value mono">${escapeHtml(device.address)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Address Type</span>
|
||||||
|
<span class="info-value">${escapeHtml(device.address_type)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Protocol</span>
|
||||||
|
<span class="info-value">${protocolLabel}</span>
|
||||||
|
</div>
|
||||||
|
${device.manufacturer_name ? `
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Manufacturer</span>
|
||||||
|
<span class="info-value">${escapeHtml(device.manufacturer_name)}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${device.manufacturer_id ? `
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Manufacturer ID</span>
|
||||||
|
<span class="info-value mono">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Observation Timeline</div>
|
||||||
|
<div class="modal-info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">First Seen</span>
|
||||||
|
<span class="info-value">${formatRelativeTime(device.first_seen)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Last Seen</span>
|
||||||
|
<span class="info-value">${formatRelativeTime(device.last_seen)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Observations</span>
|
||||||
|
<span class="info-value">${device.seen_count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Rate</span>
|
||||||
|
<span class="info-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${device.service_uuids && device.service_uuids.length > 0 ? `
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Service UUIDs</div>
|
||||||
|
<div class="modal-uuid-list">
|
||||||
|
${device.service_uuids.map(uuid => `<span class="modal-uuid">${escapeHtml(uuid)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${device.heuristics ? `
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Behavioral Analysis</div>
|
||||||
|
<div class="modal-heuristics-grid">
|
||||||
|
${Object.entries(device.heuristics).map(([key, value]) => `
|
||||||
|
<div class="heuristic-check ${value ? 'active' : ''}">
|
||||||
|
<span class="heuristic-indicator">${value ? '✓' : '−'}</span>
|
||||||
|
<span class="heuristic-label">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle advanced panel
|
||||||
|
*/
|
||||||
|
function toggleAdvanced(button) {
|
||||||
|
const card = button.closest('.signal-card');
|
||||||
|
const panel = card.querySelector('.signal-advanced-panel');
|
||||||
|
button.classList.toggle('open');
|
||||||
|
panel.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy address to clipboard
|
||||||
|
*/
|
||||||
|
function copyAddress(address) {
|
||||||
|
navigator.clipboard.writeText(address).then(() => {
|
||||||
|
if (typeof SignalCards !== 'undefined') {
|
||||||
|
SignalCards.showToast('Address copied');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Investigate device (placeholder for future implementation)
|
||||||
|
*/
|
||||||
|
function investigate(deviceId) {
|
||||||
|
console.log('Investigate device:', deviceId);
|
||||||
|
// Could open service discovery, detailed analysis, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all device timestamps
|
||||||
|
*/
|
||||||
|
function updateTimestamps(container) {
|
||||||
|
container.querySelectorAll('.device-timestamp[data-timestamp]').forEach(el => {
|
||||||
|
const timestamp = el.dataset.timestamp;
|
||||||
|
if (timestamp) {
|
||||||
|
el.textContent = formatRelativeTime(timestamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create device filter bar for Bluetooth mode
|
||||||
|
*/
|
||||||
|
function createDeviceFilterBar(container, options = {}) {
|
||||||
|
const filterBar = document.createElement('div');
|
||||||
|
filterBar.className = 'signal-filter-bar device-filter-bar';
|
||||||
|
filterBar.id = 'btDeviceFilterBar';
|
||||||
|
|
||||||
|
filterBar.innerHTML = `
|
||||||
|
<button class="signal-filter-btn active" data-filter="status" data-value="all">
|
||||||
|
All
|
||||||
|
<span class="signal-filter-count" data-count="all">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="signal-filter-btn" data-filter="status" data-value="new">
|
||||||
|
<span class="filter-dot" style="background: var(--signal-new)"></span>
|
||||||
|
New
|
||||||
|
<span class="signal-filter-count" data-count="new">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="signal-filter-btn" data-filter="status" data-value="baseline">
|
||||||
|
<span class="filter-dot" style="background: var(--signal-baseline)"></span>
|
||||||
|
Known
|
||||||
|
<span class="signal-filter-count" data-count="baseline">0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="signal-filter-divider"></span>
|
||||||
|
|
||||||
|
<span class="signal-filter-label">Protocol</span>
|
||||||
|
<button class="signal-filter-btn protocol-btn active" data-filter="protocol" data-value="all">All</button>
|
||||||
|
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="ble">BLE</button>
|
||||||
|
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="classic">Classic</button>
|
||||||
|
|
||||||
|
<span class="signal-filter-divider"></span>
|
||||||
|
|
||||||
|
<span class="signal-filter-label">Range</span>
|
||||||
|
<button class="signal-filter-btn range-btn active" data-filter="range" data-value="all">All</button>
|
||||||
|
<button class="signal-filter-btn range-btn" data-filter="range" data-value="close">Close</button>
|
||||||
|
<button class="signal-filter-btn range-btn" data-filter="range" data-value="far">Far</button>
|
||||||
|
|
||||||
|
<div class="signal-search-container">
|
||||||
|
<input type="text" class="signal-search-input" id="btSearchInput" placeholder="Search name or address..." />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const filters = { status: 'all', protocol: 'all', range: 'all', search: '' };
|
||||||
|
|
||||||
|
// Apply filters function
|
||||||
|
const applyFilters = () => {
|
||||||
|
const cards = container.querySelectorAll('.device-card');
|
||||||
|
const counts = { all: 0, new: 0, baseline: 0 };
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
const cardStatus = card.dataset.status || 'baseline';
|
||||||
|
const cardProtocol = card.dataset.protocol;
|
||||||
|
const deviceData = JSON.parse(card.dataset.deviceData || '{}');
|
||||||
|
const cardName = (deviceData.name || '').toLowerCase();
|
||||||
|
const cardAddress = (deviceData.address || '').toLowerCase();
|
||||||
|
const cardRange = deviceData.range_band || 'unknown';
|
||||||
|
|
||||||
|
counts.all++;
|
||||||
|
if (cardStatus === 'new') counts.new++;
|
||||||
|
else counts.baseline++;
|
||||||
|
|
||||||
|
// Check filters
|
||||||
|
const statusMatch = filters.status === 'all' || cardStatus === filters.status;
|
||||||
|
const protocolMatch = filters.protocol === 'all' || cardProtocol === filters.protocol;
|
||||||
|
const rangeMatch = filters.range === 'all' ||
|
||||||
|
(filters.range === 'close' && ['very_close', 'close'].includes(cardRange)) ||
|
||||||
|
(filters.range === 'far' && ['nearby', 'far', 'unknown'].includes(cardRange));
|
||||||
|
const searchMatch = !filters.search ||
|
||||||
|
cardName.includes(filters.search) ||
|
||||||
|
cardAddress.includes(filters.search);
|
||||||
|
|
||||||
|
if (statusMatch && protocolMatch && rangeMatch && searchMatch) {
|
||||||
|
card.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
card.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
Object.keys(counts).forEach(key => {
|
||||||
|
const badge = filterBar.querySelector(`[data-count="${key}"]`);
|
||||||
|
if (badge) badge.textContent = counts[key];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status filter handlers
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filters.status = btn.dataset.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protocol filter handlers
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filters.protocol = btn.dataset.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Range filter handlers
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filters.range = btn.dataset.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search handler
|
||||||
|
const searchInput = filterBar.querySelector('#btSearchInput');
|
||||||
|
let searchTimeout;
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
filters.search = e.target.value.toLowerCase();
|
||||||
|
applyFilters();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
filterBar.applyFilters = applyFilters;
|
||||||
|
return filterBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createDeviceCard,
|
||||||
|
createSparkline,
|
||||||
|
createHeuristicBadges,
|
||||||
|
createRangeBand,
|
||||||
|
createDeviceFilterBar,
|
||||||
|
showDeviceDetails,
|
||||||
|
toggleAdvanced,
|
||||||
|
copyAddress,
|
||||||
|
investigate,
|
||||||
|
updateTimestamps,
|
||||||
|
escapeHtml,
|
||||||
|
formatRelativeTime,
|
||||||
|
RANGE_BANDS,
|
||||||
|
HEURISTIC_BADGES
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.DeviceCard = DeviceCard;
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Message Card Component
|
||||||
|
* Status and alert messages for Bluetooth and TSCM modes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MessageCard = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Message types and their styling
|
||||||
|
const MESSAGE_TYPES = {
|
||||||
|
info: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#3b82f6',
|
||||||
|
bgColor: 'rgba(59, 130, 246, 0.1)'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#22c55e',
|
||||||
|
bgColor: 'rgba(34, 197, 94, 0.1)'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#f59e0b',
|
||||||
|
bgColor: 'rgba(245, 158, 11, 0.1)'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#ef4444',
|
||||||
|
bgColor: 'rgba(239, 68, 68, 0.1)'
|
||||||
|
},
|
||||||
|
scanning: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#06b6d4',
|
||||||
|
bgColor: 'rgba(6, 182, 212, 0.1)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a message card
|
||||||
|
*/
|
||||||
|
function createMessageCard(options) {
|
||||||
|
const {
|
||||||
|
type = 'info',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
actions,
|
||||||
|
dismissible = true,
|
||||||
|
autoHide = 0,
|
||||||
|
id
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const config = MESSAGE_TYPES[type] || MESSAGE_TYPES.info;
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `message-card message-card-${type}`;
|
||||||
|
if (id) card.id = id;
|
||||||
|
card.style.setProperty('--message-color', config.color);
|
||||||
|
card.style.setProperty('--message-bg', config.bgColor);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="message-card-icon">
|
||||||
|
${config.icon}
|
||||||
|
</div>
|
||||||
|
<div class="message-card-content">
|
||||||
|
${title ? `<div class="message-card-title">${escapeHtml(title)}</div>` : ''}
|
||||||
|
${message ? `<div class="message-card-text">${escapeHtml(message)}</div>` : ''}
|
||||||
|
${details ? `<div class="message-card-details">${escapeHtml(details)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${dismissible ? `
|
||||||
|
<button class="message-card-dismiss" title="Dismiss">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${actions && actions.length > 0 ? `
|
||||||
|
<div class="message-card-actions">
|
||||||
|
${actions.map(action => `
|
||||||
|
<button class="message-action-btn ${action.primary ? 'primary' : ''}"
|
||||||
|
${action.id ? `id="${escapeHtml(action.id)}"` : ''}>
|
||||||
|
${escapeHtml(action.label)}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Dismiss handler
|
||||||
|
if (dismissible) {
|
||||||
|
card.querySelector('.message-card-dismiss').addEventListener('click', () => {
|
||||||
|
card.classList.add('message-card-hiding');
|
||||||
|
setTimeout(() => card.remove(), 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action handlers
|
||||||
|
if (actions && actions.length > 0) {
|
||||||
|
actions.forEach(action => {
|
||||||
|
if (action.handler) {
|
||||||
|
const btn = action.id
|
||||||
|
? card.querySelector(`#${action.id}`)
|
||||||
|
: card.querySelector('.message-action-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
action.handler(e, card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-hide
|
||||||
|
if (autoHide > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (card.parentElement) {
|
||||||
|
card.classList.add('message-card-hiding');
|
||||||
|
setTimeout(() => card.remove(), 200);
|
||||||
|
}
|
||||||
|
}, autoHide);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a scanning status card
|
||||||
|
*/
|
||||||
|
function createScanningCard(options = {}) {
|
||||||
|
const {
|
||||||
|
backend = 'auto',
|
||||||
|
adapter = 'hci0',
|
||||||
|
deviceCount = 0,
|
||||||
|
elapsed = 0,
|
||||||
|
remaining = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'scanning',
|
||||||
|
title: 'Scanning for Bluetooth devices...',
|
||||||
|
message: `Backend: ${backend} | Adapter: ${adapter}`,
|
||||||
|
details: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''}` +
|
||||||
|
(remaining !== null ? ` | ${Math.round(remaining)}s remaining` : ''),
|
||||||
|
dismissible: false,
|
||||||
|
id: 'btScanningStatus'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a capability warning card
|
||||||
|
*/
|
||||||
|
function createCapabilityWarning(issues) {
|
||||||
|
if (!issues || issues.length === 0) return null;
|
||||||
|
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Bluetooth Capability Issues',
|
||||||
|
message: issues.join('. '),
|
||||||
|
dismissible: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Retry Check',
|
||||||
|
handler: (e, card) => {
|
||||||
|
card.remove();
|
||||||
|
if (typeof window.checkBtCapabilities === 'function') {
|
||||||
|
window.checkBtCapabilities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a baseline status card
|
||||||
|
*/
|
||||||
|
function createBaselineCard(deviceCount, isSet = true) {
|
||||||
|
if (isSet) {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Baseline Set',
|
||||||
|
message: `${deviceCount} device${deviceCount !== 1 ? 's' : ''} saved as baseline`,
|
||||||
|
details: 'New devices will be highlighted',
|
||||||
|
dismissible: true,
|
||||||
|
autoHide: 5000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'info',
|
||||||
|
title: 'No Baseline',
|
||||||
|
message: 'Set a baseline to track new devices',
|
||||||
|
dismissible: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Set Baseline',
|
||||||
|
primary: true,
|
||||||
|
handler: () => {
|
||||||
|
if (typeof window.setBtBaseline === 'function') {
|
||||||
|
window.setBtBaseline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a scan complete card
|
||||||
|
*/
|
||||||
|
function createScanCompleteCard(deviceCount, duration) {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Scan Complete',
|
||||||
|
message: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''} in ${Math.round(duration)}s`,
|
||||||
|
dismissible: true,
|
||||||
|
autoHide: 5000,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Export Results',
|
||||||
|
handler: () => {
|
||||||
|
window.open('/api/bluetooth/export?format=csv', '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error card
|
||||||
|
*/
|
||||||
|
function createErrorCard(error, retryHandler) {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Scan Error',
|
||||||
|
message: error,
|
||||||
|
dismissible: true,
|
||||||
|
actions: retryHandler ? [
|
||||||
|
{
|
||||||
|
label: 'Retry',
|
||||||
|
primary: true,
|
||||||
|
handler: retryHandler
|
||||||
|
}
|
||||||
|
] : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a message in a container
|
||||||
|
*/
|
||||||
|
function showMessage(container, options) {
|
||||||
|
const card = createMessageCard(options);
|
||||||
|
container.insertBefore(card, container.firstChild);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a message by ID
|
||||||
|
*/
|
||||||
|
function removeMessage(id) {
|
||||||
|
const card = document.getElementById(id);
|
||||||
|
if (card) {
|
||||||
|
card.classList.add('message-card-hiding');
|
||||||
|
setTimeout(() => card.remove(), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update scanning status
|
||||||
|
*/
|
||||||
|
function updateScanningStatus(options) {
|
||||||
|
const existing = document.getElementById('btScanningStatus');
|
||||||
|
if (existing) {
|
||||||
|
const details = existing.querySelector('.message-card-details');
|
||||||
|
if (details) {
|
||||||
|
details.textContent = `Found ${options.deviceCount} device${options.deviceCount !== 1 ? 's' : ''}` +
|
||||||
|
(options.remaining !== null ? ` | ${Math.round(options.remaining)}s remaining` : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createMessageCard,
|
||||||
|
createScanningCard,
|
||||||
|
createCapabilityWarning,
|
||||||
|
createBaselineCard,
|
||||||
|
createScanCompleteCard,
|
||||||
|
createErrorCard,
|
||||||
|
showMessage,
|
||||||
|
removeMessage,
|
||||||
|
updateScanningStatus,
|
||||||
|
MESSAGE_TYPES
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.MessageCard = MessageCard;
|
||||||