Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec316fbe2 | |||
| a407c7708d | |||
| 1466fc2d30 | |||
| 963bcdf9fa | |||
| cfe03317c9 | |||
| 37ba12daaa | |||
| 5c47e9f10a | |||
| 694786d4e0 | |||
| 06a00ca6b5 | |||
| bbc25ddaa0 | |||
| 02a94281c3 | |||
| cbe5faab3b | |||
| cacfbf5713 | |||
| 2faed68af4 | |||
| bec0881018 | |||
| da2a700bcc | |||
| cd3ed9a03b | |||
| f7fad076c2 | |||
| a397271553 | |||
| 83a54ccb20 | |||
| 2e9bab75b1 | |||
| 0dc40bbea3 | |||
| 17f6947648 | |||
| 481651c88d | |||
| ad4903d4ac | |||
| 3a962ca207 | |||
| f29ae3d5a8 | |||
| 37d24a539d | |||
| 622f23c091 | |||
| b70db887b1 | |||
| 7f13af3fcd | |||
| 9afff0f4b2 | |||
| 5a7a6ce522 | |||
| 36b6539044 | |||
| 6c6cd8a280 | |||
| 4df112e712 | |||
| 3d8b8bbfdc | |||
| 076339024f | |||
| e82f0f36d2 | |||
| f4ade209f9 | |||
| b0652595fa | |||
| 332172881e | |||
| e05ac97749 | |||
| 615a83c23f | |||
| d017375f64 | |||
| 0b5235f619 | |||
| 16239c1d31 | |||
| cae7a0586f | |||
| 23f28a8102 | |||
| 34ecec3800 | |||
| d40bd37406 | |||
| 4ed41434e2 | |||
| 6a0b54fa0e | |||
| b83ecfcc19 | |||
| 671bf38083 | |||
| 0f5a414a09 | |||
| 831426948f | |||
| df2c0a0d25 | |||
| d427f69dcd | |||
| cab04e6e2c | |||
| 8969fefe2e | |||
| 5e9fcc5c49 | |||
| 53b23fc2f7 | |||
| eeb3a29ecf | |||
| 4cdfa98a4e | |||
| 9fcec6cbb8 | |||
| a527ac191a | |||
| 8cd3aafd10 | |||
| 5c76a423af | |||
| c80bf99b91 | |||
| 6e5cb0a23e | |||
| ffb98425f1 | |||
| 533e92c711 | |||
| 9f32b05719 | |||
| 2a05aaa4d8 | |||
| 6529febcfa | |||
| bd87d4b4c6 | |||
| 5a0589dd69 | |||
| 5605ae0359 | |||
| 2b3f351ff0 | |||
| 126b9ba2ee | |||
| c0498ebe68 | |||
| 99d52eafe7 | |||
| 2a73318457 | |||
| d8d08a8b1e | |||
| c60769f795 | |||
| 01f8324292 | |||
| c66988cc1c | |||
| fac3d4359b | |||
| d6f10d29ca | |||
| 332735cecf | |||
| b04e335f49 | |||
| 75e50a1cd4 |
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.21.1] - 2026-02-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- BT Locate map first-load rendering race that could cause blank/late map initialization
|
||||||
|
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
|
||||||
|
- BT Locate trail restore startup latency by batching historical GPS point rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.21.0] - 2026-02-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Analytics panels for operational insights and temporal pattern analysis
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Global map theme refresh with improved contrast and cross-dashboard consistency
|
||||||
|
- Cross-app UX refinements for accessibility, mode consistency, and render performance
|
||||||
|
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Weather satellite auto-scheduler and Mercator tracking reliability issues
|
||||||
|
- Bluetooth/WiFi runtime health issues affecting scanner continuity
|
||||||
|
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.15.0] - 2026-02-09
|
## [2.15.0] - 2026-02-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
bluez \
|
bluez \
|
||||||
bluetooth \
|
bluetooth \
|
||||||
# GPS support
|
# GPS support
|
||||||
|
gpsd \
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
# Utilities
|
# Utilities
|
||||||
# APRS
|
# APRS
|
||||||
@@ -94,6 +95,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libfftw3-dev \
|
libfftw3-dev \
|
||||||
liblapack-dev \
|
liblapack-dev \
|
||||||
libcodec2-dev \
|
libcodec2-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libxml2-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 \
|
||||||
@@ -136,10 +139,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||||
&& cd acarsdec \
|
&& cd acarsdec \
|
||||||
&& mkdir build && cd build \
|
&& mkdir build && cd build \
|
||||||
&& cmake .. -Drtl=ON \
|
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
&& make \
|
&& make \
|
||||||
&& cp acarsdec /usr/bin/acarsdec \
|
&& cp acarsdec /usr/bin/acarsdec \
|
||||||
&& rm -rf /tmp/acarsdec \
|
&& rm -rf /tmp/acarsdec \
|
||||||
|
# Build libacars (required by dumpvdl2)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
||||||
|
&& cd libacars \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/libacars \
|
||||||
|
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
||||||
|
&& cd dumpvdl2 \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
|
||||||
|
&& rm -rf /tmp/dumpvdl2 \
|
||||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone https://github.com/windytan/slowrx.git \
|
&& git clone https://github.com/windytan/slowrx.git \
|
||||||
|
|||||||
@@ -1,21 +1,200 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 smittix
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
1. Definitions.
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
the copyright owner that is granting the License.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by the Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding any notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. Please also get an OpenPGP
|
||||||
|
key and encrypt outgoing communications.
|
||||||
|
|
||||||
|
Copyright 2025 smittix
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|||||||
@@ -28,21 +28,27 @@ Support the developer of this open-source project
|
|||||||
|
|
||||||
- **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
|
||||||
|
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
|
||||||
- **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
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
|
- **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
||||||
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump
|
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
||||||
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
|
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
||||||
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
||||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
|
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||||
|
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **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 (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
|
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||||
|
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||||
- **Meshtastic** - LoRa mesh network integration
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
|
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
@@ -224,7 +230,7 @@ This project was developed using AI as a coding partner, combining human directi
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
Apache 2.0 License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
@@ -238,6 +244,9 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[dump1090](https://github.com/flightaware/dump1090) |
|
[dump1090](https://github.com/flightaware/dump1090) |
|
||||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
|
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
||||||
|
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
[SatDump](https://github.com/SatDump/SatDump) |
|
[SatDump](https://github.com/SatDump/SatDump) |
|
||||||
@@ -252,3 +261,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-02-01_ba81b697",
|
"version": "2026-02-15_ae16bb62",
|
||||||
"downloaded": "2026-02-04T17:06:54.806043Z"
|
"downloaded": "2026-02-20T00:29:06.228007Z"
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ from flask import Flask, render_template, jsonify, send_file, Response, request,
|
|||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
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, cleanup_stale_dump1090
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
from utils.cleanup import DataStore, cleanup_manager
|
from utils.cleanup import DataStore, cleanup_manager
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -150,6 +150,11 @@ acars_process = None
|
|||||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
acars_lock = threading.Lock()
|
acars_lock = threading.Lock()
|
||||||
|
|
||||||
|
# VDL2 aircraft datalink
|
||||||
|
vdl2_process = None
|
||||||
|
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
vdl2_lock = threading.Lock()
|
||||||
|
|
||||||
# APRS amateur radio tracking
|
# APRS amateur radio tracking
|
||||||
aprs_process = None
|
aprs_process = None
|
||||||
aprs_rtl_process = None
|
aprs_rtl_process = None
|
||||||
@@ -666,10 +671,66 @@ def _get_dmr_active() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||||
|
"""Return Bluetooth active state and best-effort device count."""
|
||||||
|
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||||
|
scanner_running = False
|
||||||
|
scanner_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
|
if bt_scanner is not None:
|
||||||
|
scanner_running = bool(bt_scanner.is_scanning)
|
||||||
|
scanner_count = int(bt_scanner.device_count)
|
||||||
|
except Exception:
|
||||||
|
scanner_running = False
|
||||||
|
scanner_count = 0
|
||||||
|
|
||||||
|
locate_running = False
|
||||||
|
try:
|
||||||
|
from utils.bt_locate import get_locate_session
|
||||||
|
session = get_locate_session()
|
||||||
|
if session and getattr(session, 'active', False):
|
||||||
|
scanner = getattr(session, '_scanner', None)
|
||||||
|
locate_running = bool(scanner and scanner.is_scanning)
|
||||||
|
except Exception:
|
||||||
|
locate_running = False
|
||||||
|
|
||||||
|
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_wifi_health() -> tuple[bool, int, int]:
|
||||||
|
"""Return WiFi active state and best-effort network/client counts."""
|
||||||
|
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
||||||
|
scanner_running = False
|
||||||
|
scanner_networks = 0
|
||||||
|
scanner_clients = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None:
|
||||||
|
status = wifi_scanner.get_status()
|
||||||
|
scanner_running = bool(status.is_scanning)
|
||||||
|
scanner_networks = int(status.networks_found or 0)
|
||||||
|
scanner_clients = int(status.clients_found or 0)
|
||||||
|
except Exception:
|
||||||
|
scanner_running = False
|
||||||
|
scanner_networks = 0
|
||||||
|
scanner_clients = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
legacy_running or scanner_running,
|
||||||
|
max(len(wifi_networks), scanner_networks),
|
||||||
|
max(len(wifi_clients), scanner_clients),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
import time
|
import time
|
||||||
|
bt_active, bt_device_count = _get_bluetooth_health()
|
||||||
|
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'version': VERSION,
|
'version': VERSION,
|
||||||
@@ -680,9 +741,10 @@ def health_check() -> Response:
|
|||||||
'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),
|
'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),
|
||||||
|
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_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_active,
|
||||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
'bluetooth': bt_active,
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
'dmr': _get_dmr_active(),
|
'dmr': _get_dmr_active(),
|
||||||
'subghz': _get_subghz_active(),
|
'subghz': _get_subghz_active(),
|
||||||
@@ -690,9 +752,9 @@ def health_check() -> Response:
|
|||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
'vessel_count': len(ais_vessels),
|
'vessel_count': len(ais_vessels),
|
||||||
'wifi_networks_count': len(wifi_networks),
|
'wifi_networks_count': wifi_network_count,
|
||||||
'wifi_clients_count': len(wifi_clients),
|
'wifi_clients_count': wifi_client_count,
|
||||||
'bt_devices_count': len(bt_devices),
|
'bt_devices_count': bt_device_count,
|
||||||
'dsc_messages_count': len(dsc_messages),
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -702,6 +764,7 @@ def health_check() -> Response:
|
|||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
|
global vdl2_process
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
global dmr_process, dmr_rtl_process
|
global dmr_process, dmr_rtl_process
|
||||||
|
|
||||||
@@ -714,7 +777,7 @@ def kill_all() -> Response:
|
|||||||
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', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep'
|
'hackrf_transfer', 'hackrf_sweep'
|
||||||
@@ -751,6 +814,10 @@ def kill_all() -> Response:
|
|||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
acars_process = None
|
||||||
|
|
||||||
|
# Reset VDL2 state
|
||||||
|
with vdl2_lock:
|
||||||
|
vdl2_process = None
|
||||||
|
|
||||||
# Reset APRS state
|
# Reset APRS state
|
||||||
with aprs_lock:
|
with aprs_lock:
|
||||||
aprs_process = None
|
aprs_process = None
|
||||||
@@ -877,6 +944,7 @@ def main() -> None:
|
|||||||
|
|
||||||
# Clean up any stale processes from previous runs
|
# Clean up any stale processes from previous runs
|
||||||
cleanup_stale_processes()
|
cleanup_stale_processes()
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
|
||||||
# Initialize database for settings storage
|
# Initialize database for settings storage
|
||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
|
|||||||
@@ -7,10 +7,77 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.16.0"
|
VERSION = "2.21.1"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.21.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"BT Locate map first-load fix with render stabilization retries during initial mode open",
|
||||||
|
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
|
||||||
|
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.21.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Global map theme refresh with improved contrast and cross-dashboard consistency",
|
||||||
|
"Cross-app UX updates for accessibility, mode consistency, and render performance",
|
||||||
|
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
|
||||||
|
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
||||||
|
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
||||||
|
"Analytics enhancements with operational insights and temporal pattern panels",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.20.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
||||||
|
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
||||||
|
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
||||||
|
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
||||||
|
"No SDR hardware required — all data from public APIs with server-side caching",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.19.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
|
||||||
|
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
|
||||||
|
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
|
||||||
|
"Help modal updated with all modes and correct SVG icons",
|
||||||
|
"Setup script overhauled for reliability and macOS compatibility",
|
||||||
|
"GPS fix for preserving satellites across DOP-only SKY messages",
|
||||||
|
"Fix gpsd deadlock causing GPS connect to hang",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.18.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
|
||||||
|
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
|
||||||
|
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
|
||||||
|
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
||||||
|
"GPS: error state indicator and UI refinements",
|
||||||
|
"Proximity radar and signal card UI improvements",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.17.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
|
||||||
|
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
|
||||||
|
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
|
||||||
|
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.16.0",
|
"version": "2.16.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
@@ -18,7 +85,6 @@ CHANGELOG = [
|
|||||||
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
|
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
|
||||||
"Weather satellite auto-scheduler with polar plot and ground track map",
|
"Weather satellite auto-scheduler with polar plot and ground track map",
|
||||||
"SatDump support for local (non-Docker) installs via setup.sh",
|
"SatDump support for local (non-Docker) installs via setup.sh",
|
||||||
"DMR audio output, frequency persistence, and bookmarks",
|
|
||||||
"Shared waterfall UI across SDR modes",
|
"Shared waterfall UI across SDR modes",
|
||||||
"Listening post audio stuttering fix and SDR race condition fixes",
|
"Listening post audio stuttering fix and SDR race condition fixes",
|
||||||
"Multi-arch Docker build support (amd64 + arm64)",
|
"Multi-arch Docker build support (amd64 + arm64)",
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ 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
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
|
||||||
|
- **Protocol decoding** - identify and decode common Sub-GHz protocols
|
||||||
|
- **Signal replay/transmit** capabilities for authorized testing
|
||||||
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
|
- **I/Q capture** - record raw samples for offline analysis
|
||||||
|
|
||||||
## AIS Vessel Tracking
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||||
@@ -84,6 +92,95 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||||
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||||
|
|
||||||
|
## ACARS Messaging
|
||||||
|
|
||||||
|
- **Real-time ACARS decoding** via acarsdec
|
||||||
|
- **Aircraft datalink messages** - operational, weather, and position reports
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
- **Message filtering** - filter by message type, flight, or registration
|
||||||
|
|
||||||
|
## VDL2 (VHF Data Link Mode 2)
|
||||||
|
|
||||||
|
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
|
||||||
|
- **ACARS-over-AVLC** message capture with full frame parsing
|
||||||
|
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
|
||||||
|
- **AVLC frame details** - source/destination addresses, frame type, command/response
|
||||||
|
- **Raw JSON inspection** - expandable raw message data for each frame
|
||||||
|
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
- **CSV/JSON export** - export captured messages for offline analysis
|
||||||
|
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||||
|
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||||
|
- **Cross-module frequency routing** from scanner to decoders
|
||||||
|
- **Customizable frequency presets** and band bookmarks
|
||||||
|
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
|
||||||
|
- **Auto-scheduler** with pass prediction and automatic capture
|
||||||
|
- **Polar plot** - real-time satellite position on azimuth/elevation display
|
||||||
|
- **Ground track map** - orbit path with past/future trajectory
|
||||||
|
- **Image gallery** with timestamped decoded imagery
|
||||||
|
|
||||||
|
## WebSDR
|
||||||
|
|
||||||
|
- **KiwiSDR network integration** for remote HF/shortwave listening
|
||||||
|
- **WebSocket audio streaming** from remote receivers
|
||||||
|
- **Receiver discovery** with automatic caching
|
||||||
|
- **Frequency tuning** with band presets
|
||||||
|
|
||||||
|
## ISS SSTV
|
||||||
|
|
||||||
|
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
|
||||||
|
- **Real-time ISS tracking** with world map and pass predictions
|
||||||
|
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
|
||||||
|
- **Next pass countdown** - time remaining until ISS is overhead
|
||||||
|
- **Image gallery** with timestamped decoded imagery
|
||||||
|
- **TLE updates** - fetch latest ISS orbital elements
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
|
## HF SSTV
|
||||||
|
|
||||||
|
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||||
|
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
|
||||||
|
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
|
||||||
|
- **Image gallery** with decoded transmissions
|
||||||
|
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
- **Amateur packet radio** position reports and telemetry via direwolf
|
||||||
|
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
|
||||||
|
- **Real-time position tracking** on interactive map
|
||||||
|
- **Message and telemetry display** from APRS network
|
||||||
|
|
||||||
|
## Utility Meter Reading
|
||||||
|
|
||||||
|
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
|
||||||
|
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||||
|
- **Multiple meter protocol support** via rtl_tcp integration
|
||||||
|
|
||||||
|
## Space Weather
|
||||||
|
|
||||||
|
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||||
|
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||||
|
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||||
|
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||||
|
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||||
|
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||||
|
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||||
|
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||||
|
- **Aurora forecast** - OVATION aurora oval visualization
|
||||||
|
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||||
|
- **Active solar regions** - Current sunspot region data with location and area
|
||||||
|
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||||
|
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||||
|
|
||||||
## 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
|
||||||
@@ -131,6 +228,52 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **Proximity radar** visualization
|
- **Proximity radar** visualization
|
||||||
- **Device type breakdown** chart
|
- **Device type breakdown** chart
|
||||||
|
|
||||||
|
## BT Locate (SAR Bluetooth Device Location)
|
||||||
|
|
||||||
|
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
||||||
|
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
||||||
|
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
||||||
|
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
||||||
|
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
||||||
|
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
||||||
|
- **Distance estimation** - Log-distance path loss model with environment presets
|
||||||
|
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
||||||
|
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
||||||
|
|
||||||
|
### Environment Presets
|
||||||
|
- **Open Field** (n=2.0) - Free space path loss
|
||||||
|
- **Outdoor** (n=2.2) - Typical outdoor environment
|
||||||
|
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
||||||
|
|
||||||
|
### Map & Trail
|
||||||
|
- Interactive Leaflet map with GPS trail visualization
|
||||||
|
- Trail points color-coded by proximity band
|
||||||
|
- Polyline connecting detection points for path visualization
|
||||||
|
- Supports user-configured tile providers
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Bluetooth adapter (built-in or USB)
|
||||||
|
- GPS receiver (optional, falls back to manual coordinates)
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
Real-time GPS position tracking with live map visualization.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
||||||
|
- **Interactive map** - Current position on Leaflet map with track history
|
||||||
|
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
||||||
|
- **Satellite info** - Number of satellites in view and fix quality
|
||||||
|
- **Track recording** - Record GPS tracks with export capability
|
||||||
|
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- USB GPS receiver connected via gpsd
|
||||||
|
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
||||||
|
|
||||||
## TSCM Counter-Surveillance Mode
|
## TSCM Counter-Surveillance Mode
|
||||||
|
|
||||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||||
|
|||||||
@@ -206,14 +206,24 @@ Extended base for full-screen dashboards (maps, visualizations).
|
|||||||
| `listening` | Listening post |
|
| `listening` | Listening post |
|
||||||
| `spystations` | Spy stations |
|
| `spystations` | Spy stations |
|
||||||
| `meshtastic` | Mesh networking |
|
| `meshtastic` | Mesh networking |
|
||||||
|
| `weathersat` | Weather satellites |
|
||||||
|
| `sstv_general` | HF SSTV |
|
||||||
|
| `gps` | GPS tracking |
|
||||||
|
| `websdr` | WebSDR |
|
||||||
|
| `subghz` | Sub-GHz analyzer |
|
||||||
|
| `bt_locate` | BT Locate |
|
||||||
|
| `analytics` | Analytics dashboard |
|
||||||
|
| `spaceweather` | Space weather |
|
||||||
|
| `dmr` | DMR/P25 digital voice |
|
||||||
|
|
||||||
### Navigation Groups
|
### Navigation Groups
|
||||||
|
|
||||||
The navigation is organized into groups:
|
The navigation is organized into groups:
|
||||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||||
- **Wireless**: WiFi, Bluetooth
|
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||||
- **Security**: TSCM
|
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||||
- **Space**: Satellite, ISS SSTV
|
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic
|
||||||
|
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,48 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- Samsung SmartTag
|
- Samsung SmartTag
|
||||||
- Chipolo
|
- Chipolo
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
1. **Connect HackRF** - Plug in your HackRF One device
|
||||||
|
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
|
||||||
|
3. **Start Capture** - Click "Start Capture" to begin signal analysis
|
||||||
|
4. **View Spectrum** - Real-time spectrum visualization of the selected band
|
||||||
|
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
|
||||||
|
|
||||||
|
### Supported Protocols
|
||||||
|
|
||||||
|
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
||||||
|
|
||||||
|
## VDL2 (Aircraft Datalink)
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
|
||||||
|
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
|
||||||
|
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
|
||||||
|
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
|
||||||
|
7. **Export** - Use CSV or JSON export buttons to save captured messages
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- VDL2 is most active near airports and along flight corridors
|
||||||
|
- Multiple frequencies can be monitored simultaneously for better coverage
|
||||||
|
- VDL2 data is also accessible from the ADS-B dashboard
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency Range** - Define start and end frequencies for scanning
|
||||||
|
3. **Start Scanning** - Click "Start Scan" for wideband sweep
|
||||||
|
4. **View Signals** - Discovered signals are listed with frequency and SNR
|
||||||
|
5. **Tune In** - Click a signal to tune the audio demodulator
|
||||||
|
6. **Listen** - Real-time audio plays in your browser
|
||||||
|
|
||||||
|
### Demodulation Modes
|
||||||
|
|
||||||
|
- **FM** - Narrowband and wideband FM
|
||||||
|
- **SSB** - Upper and lower sideband for amateur radio and shortwave
|
||||||
|
|
||||||
## Aircraft Mode (ADS-B)
|
## Aircraft Mode (ADS-B)
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||||
@@ -84,6 +126,23 @@ The system highlights aircraft transmitting emergency squawks:
|
|||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
|
## ACARS Messaging
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
|
||||||
|
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
|
||||||
|
5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
|
||||||
|
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
|
||||||
|
7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- A vertical polarization antenna works best for ACARS
|
||||||
|
- Quarter-wave dipole: 57 cm per element at 130 MHz
|
||||||
|
- Stock SDR antenna may work at close range near airports
|
||||||
|
- Outdoor placement with clear sky view significantly improves reception
|
||||||
|
|
||||||
## ADS-B History (Optional)
|
## ADS-B History (Optional)
|
||||||
|
|
||||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||||
@@ -165,6 +224,236 @@ If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HIST
|
|||||||
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
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
1. **Set Location** - Enter observer coordinates or use GPS
|
||||||
|
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
|
||||||
|
3. **View Passes** - Upcoming passes shown with polar plot and ground track
|
||||||
|
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
|
||||||
|
5. **View Images** - Decoded imagery appears in the gallery
|
||||||
|
|
||||||
|
### Auto-Scheduler
|
||||||
|
|
||||||
|
Enable the auto-scheduler to automatically capture passes:
|
||||||
|
- Calculates upcoming NOAA and Meteor passes for your location
|
||||||
|
- Starts SatDump at the correct time and frequency
|
||||||
|
- Decoded images are saved with timestamps
|
||||||
|
|
||||||
|
## Space Weather
|
||||||
|
|
||||||
|
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
|
||||||
|
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
|
||||||
|
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
|
||||||
|
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
|
||||||
|
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
|
||||||
|
6. **Alerts** - Review current SWPC space weather alerts and warnings
|
||||||
|
7. **Active Regions** - View solar active region data (number, location, area)
|
||||||
|
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
|
||||||
|
- Check HF band conditions before operating on shortwave frequencies
|
||||||
|
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
|
||||||
|
- D-RAP maps show where HF absorption is highest — useful for path planning
|
||||||
|
- Solar imagery updates approximately every 15 minutes from NASA SDO
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
|
||||||
|
3. **View Map** - Vessels appear on the interactive maritime map
|
||||||
|
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
|
||||||
|
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
|
||||||
|
|
||||||
|
### VHF DSC Channel 70
|
||||||
|
|
||||||
|
Digital Selective Calling monitoring runs alongside AIS:
|
||||||
|
- Distress, Urgency, Safety, and Routine messages
|
||||||
|
- Distress positions plotted with pulsing alert markers
|
||||||
|
- Audio alerts for critical messages
|
||||||
|
|
||||||
|
## WebSDR
|
||||||
|
|
||||||
|
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
|
||||||
|
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
|
||||||
|
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
|
||||||
|
4. **Select Receiver** - Click a receiver from the list to connect
|
||||||
|
5. **Listen** - Audio streams in real-time via WebSocket
|
||||||
|
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
|
||||||
|
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Requires an internet connection to access the KiwiSDR network
|
||||||
|
- Receiver list is cached for 1 hour to reduce API load
|
||||||
|
- Receivers are sorted by distance from your location
|
||||||
|
- Integrated spy station presets allow quick tuning to SIGINT targets
|
||||||
|
|
||||||
|
## ISS SSTV
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
|
||||||
|
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
|
||||||
|
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
|
||||||
|
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
|
||||||
|
7. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||||
|
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- A V-dipole or better antenna is required (stock antenna will not work)
|
||||||
|
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
|
||||||
|
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
|
||||||
|
- Best passes have elevation > 30 degrees above horizon
|
||||||
|
- Doppler shift tracking dramatically improves reception quality
|
||||||
|
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
|
||||||
|
- Outdoor antenna placement with clear sky view is essential
|
||||||
|
|
||||||
|
## HF SSTV
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
|
||||||
|
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
|
||||||
|
5. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||||
|
6. **View Images** - Decoded amateur radio images appear in the gallery
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
|
||||||
|
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
|
||||||
|
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
|
||||||
|
- Weekend activity peaks on most HF bands
|
||||||
|
- Amateur license is not required to receive (listen-only)
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
|
||||||
|
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
|
||||||
|
4. **View Map** - Station positions appear on the interactive map
|
||||||
|
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
|
||||||
|
|
||||||
|
## Utility Meters
|
||||||
|
|
||||||
|
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
|
||||||
|
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
|
||||||
|
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
|
||||||
|
|
||||||
|
## BT Locate (SAR Device Location)
|
||||||
|
|
||||||
|
1. **Set Target** - Enter one or more target identifiers:
|
||||||
|
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
|
||||||
|
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
|
||||||
|
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
|
||||||
|
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
|
||||||
|
2. **Choose Environment** - Select the RF environment preset:
|
||||||
|
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||||
|
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
|
||||||
|
- **Indoor** (n=3.0) - For buildings with walls and obstacles
|
||||||
|
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||||
|
4. **Monitor HUD** - The proximity display shows:
|
||||||
|
- Proximity band (IMMEDIATE / NEAR / FAR)
|
||||||
|
- Estimated distance in meters
|
||||||
|
- Raw RSSI and smoothed RSSI average
|
||||||
|
- Detection count and GPS-tagged points
|
||||||
|
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||||
|
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
|
||||||
|
7. **Review Trail** - Check the map for GPS-tagged detection trail
|
||||||
|
|
||||||
|
### Hand-off from Bluetooth Mode
|
||||||
|
|
||||||
|
1. Open Bluetooth scanning mode and find the target device
|
||||||
|
2. Click the "Locate" button on the device card
|
||||||
|
3. BT Locate opens with the device pre-filled
|
||||||
|
4. Click "Start Locate" to begin tracking
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- For devices with address randomization (iPhones, modern Android), use the IRK method
|
||||||
|
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
|
||||||
|
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
|
||||||
|
- Clear the trail when starting a new search area
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
||||||
|
2. **View Map** - Your position appears on the interactive map with a track trail
|
||||||
|
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
|
||||||
|
4. **Record Track** - Enable track recording to save your path
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
|
||||||
|
- GPS fix may take 30-60 seconds after cold start
|
||||||
|
- Accuracy improves with more satellites in view
|
||||||
|
|
||||||
|
## TSCM (Counter-Surveillance)
|
||||||
|
|
||||||
|
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
|
||||||
|
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
|
||||||
|
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
|
||||||
|
4. **Start Sweep** - Click "Start Sweep" to begin scanning
|
||||||
|
5. **Review Results** - Detected devices are classified and scored by threat level
|
||||||
|
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
|
||||||
|
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
|
||||||
|
|
||||||
|
### Threat Levels
|
||||||
|
|
||||||
|
- **Informational (0-2)** - Known or expected devices
|
||||||
|
- **Needs Review (3-5)** - Unusual devices requiring assessment
|
||||||
|
- **High Interest (6+)** - Multiple indicators warrant investigation
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Record a baseline in a known clean environment before conducting sweeps
|
||||||
|
- Use the meeting window feature to flag new RF signatures during sensitive periods
|
||||||
|
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||||
|
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||||
|
|
||||||
|
## Spy Stations
|
||||||
|
|
||||||
|
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||||
|
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
|
||||||
|
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
|
||||||
|
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
|
||||||
|
5. **View Details** - Click "Details" on a station card for full information
|
||||||
|
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Data sourced from priyom.org (non-profit monitoring community)
|
||||||
|
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
|
||||||
|
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
|
||||||
|
- Legal to monitor in most countries (check local regulations)
|
||||||
|
- No decryption or content decoding is included — this is a reference database
|
||||||
|
|
||||||
|
## Meshtastic
|
||||||
|
|
||||||
|
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||||
|
2. **Start** - Click "Start" to connect to the mesh network
|
||||||
|
3. **View Messages** - Real-time message stream from the mesh
|
||||||
|
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
||||||
|
5. **Send Messages** - Type messages to broadcast on the mesh
|
||||||
|
|
||||||
|
## Offline Mode
|
||||||
|
|
||||||
|
1. **Open Settings** - Click the gear icon in the navigation bar
|
||||||
|
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
|
||||||
|
3. **Configure Sources** - Switch assets and fonts from CDN to local
|
||||||
|
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
|
||||||
|
5. **Check Assets** - Click "Check Assets" to verify all local files are present
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
|
||||||
|
- Assets are stored in the `static/vendor/` directory
|
||||||
|
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
|
||||||
|
- Missing assets fail gracefully with console warnings
|
||||||
|
- Useful for air-gapped environments, field deployments, or reducing latency
|
||||||
|
|
||||||
## Remote Agents (Distributed SIGINT)
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -11,6 +11,7 @@
|
|||||||
<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">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<canvas id="bg-canvas"></canvas>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<a href="#" class="nav-logo">iNTERCEPT</a>
|
<a href="#" class="nav-logo">iNTERCEPT</a>
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">15+</span>
|
<span class="stat-value">25+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -58,97 +59,149 @@
|
|||||||
<h2>Capabilities</h2>
|
<h2>Capabilities</h2>
|
||||||
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||||
|
|
||||||
<div class="features-grid">
|
<div class="carousel-filters">
|
||||||
<div class="feature-card">
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
<div class="feature-icon">📟</div>
|
<button class="filter-btn" data-filter="signals">Signals</button>
|
||||||
<h3>Pager Decoding</h3>
|
<button class="filter-btn" data-filter="tracking">Tracking</button>
|
||||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
<button class="filter-btn" data-filter="space">Space</button>
|
||||||
</div>
|
<button class="filter-btn" data-filter="wireless">Wireless</button>
|
||||||
|
<button class="filter-btn" data-filter="intel">Intel</button>
|
||||||
<div class="feature-card">
|
<button class="filter-btn" data-filter="platform">Platform</button>
|
||||||
<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>
|
||||||
|
|
||||||
|
<div class="carousel-wrapper">
|
||||||
|
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
||||||
|
<div class="carousel-track">
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></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" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></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" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
|
||||||
|
<h3>Sub-GHz Analyzer</h3>
|
||||||
|
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></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" data-category="intel">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||||
|
<h3>WebSDR</h3>
|
||||||
|
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="intel">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></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" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||||
|
<h3>APRS</h3>
|
||||||
|
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||||
|
<h3>Utility Meters</h3>
|
||||||
|
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></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" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
||||||
|
<h3>ACARS</h3>
|
||||||
|
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
||||||
|
<h3>VDL2</h3>
|
||||||
|
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></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" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></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" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
|
||||||
|
<h3>Weather Satellites</h3>
|
||||||
|
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></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 class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></div>
|
||||||
|
<h3>HF SSTV</h3>
|
||||||
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||||
|
<h3>GPS Tracking</h3>
|
||||||
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||||
|
<h3>Space Weather</h3>
|
||||||
|
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></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" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></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" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
|
||||||
|
<h3>BT Locate</h3>
|
||||||
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="intel">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||||
|
<h3>TSCM</h3>
|
||||||
|
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></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" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></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" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></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>
|
||||||
|
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel-indicators" id="carousel-indicators"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -194,6 +247,50 @@
|
|||||||
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||||
<span class="screenshot-label">AIS Vessel Tracking</span>
|
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||||
|
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/spy-stations.png" alt="Spy Stations Database">
|
||||||
|
<span class="screenshot-label">Spy Stations</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/gps.png" alt="GPS Receiver">
|
||||||
|
<span class="screenshot-label">GPS Receiver</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
||||||
|
<span class="screenshot-label">WebSDR</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/aprs.png" alt="APRS Tracker">
|
||||||
|
<span class="screenshot-label">APRS Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
||||||
|
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
||||||
|
<span class="screenshot-label">Weather Satellite</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
|
||||||
|
<span class="screenshot-label">Space Weather</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
|
||||||
|
<span class="screenshot-label">Space Weather — Solar & Aurora</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
||||||
|
<span class="screenshot-label">Satellite Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
|
||||||
|
<span class="screenshot-label">ISS SSTV</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -278,6 +375,36 @@ docker compose up -d</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="support">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Support & Contact</h2>
|
||||||
|
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
|
||||||
|
|
||||||
|
<div class="support-grid">
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
|
||||||
|
<h3>Buy Me a Coffee</h3>
|
||||||
|
<p>Support development with a one-time donation</p>
|
||||||
|
</a>
|
||||||
|
<a href="#" id="email-card" class="support-card" onclick="return false;">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
|
||||||
|
<h3>Email</h3>
|
||||||
|
<p id="email-text">Click to reveal</p>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||||||
|
<h3>Discord</h3>
|
||||||
|
<p>Join the community for help and discussion</p>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||||
|
<h3>Report an Issue</h3>
|
||||||
|
<p>Bug reports and feature requests on GitHub</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
@@ -288,6 +415,8 @@ docker compose up -d</code></pre>
|
|||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||||
|
<a href="#" id="footer-email">Email</a>
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</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/USAGE.md">Documentation</a>
|
||||||
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,6 +465,334 @@ docker compose up -d</code></pre>
|
|||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') closeLightbox();
|
if (e.key === 'Escape') closeLightbox();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Carousel functionality
|
||||||
|
(function() {
|
||||||
|
const track = document.querySelector('.carousel-track');
|
||||||
|
const cards = Array.from(track.querySelectorAll('.feature-card'));
|
||||||
|
const leftArrow = document.querySelector('.carousel-arrow-left');
|
||||||
|
const rightArrow = document.querySelector('.carousel-arrow-right');
|
||||||
|
const filterBtns = document.querySelectorAll('.filter-btn');
|
||||||
|
const indicatorContainer = document.getElementById('carousel-indicators');
|
||||||
|
|
||||||
|
const SCROLL_AMOUNT = 300;
|
||||||
|
|
||||||
|
function updateArrows() {
|
||||||
|
leftArrow.disabled = track.scrollLeft <= 0;
|
||||||
|
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndicators() {
|
||||||
|
const visible = cards.filter(c => !c.classList.contains('hidden'));
|
||||||
|
const totalWidth = visible.length * 300;
|
||||||
|
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
|
||||||
|
indicatorContainer.innerHTML = '';
|
||||||
|
for (let i = 0; i < pages; i++) {
|
||||||
|
const dot = document.createElement('button');
|
||||||
|
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||||||
|
dot.addEventListener('click', () => {
|
||||||
|
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
indicatorContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIndicators() {
|
||||||
|
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
|
||||||
|
if (!dots.length) return;
|
||||||
|
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
|
||||||
|
const idx = Math.round(ratio * (dots.length - 1));
|
||||||
|
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
leftArrow.addEventListener('click', () => {
|
||||||
|
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
rightArrow.addEventListener('click', () => {
|
||||||
|
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
track.addEventListener('scroll', () => {
|
||||||
|
updateArrows();
|
||||||
|
updateIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
filterBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const filter = btn.dataset.filter;
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
if (filter === 'all' || card.dataset.category === filter) {
|
||||||
|
card.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
card.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
track.scrollTo({ left: 0 });
|
||||||
|
buildIndicators();
|
||||||
|
updateArrows();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
buildIndicators();
|
||||||
|
updateArrows();
|
||||||
|
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Obfuscated email - assembled at runtime to defeat scrapers
|
||||||
|
(function() {
|
||||||
|
const p = ['smittix', 'outlook', 'com'];
|
||||||
|
const addr = p[0] + '@' + p[1] + '.' + p[2];
|
||||||
|
const card = document.getElementById('email-card');
|
||||||
|
const text = document.getElementById('email-text');
|
||||||
|
const footerLink = document.getElementById('footer-email');
|
||||||
|
let revealed = false;
|
||||||
|
|
||||||
|
card.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!revealed) {
|
||||||
|
text.textContent = addr;
|
||||||
|
revealed = true;
|
||||||
|
} else {
|
||||||
|
window.location.href = 'mail' + 'to:' + addr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
footerLink.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = 'mail' + 'to:' + addr;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Animated satellite & signal background
|
||||||
|
(function() {
|
||||||
|
const canvas = document.getElementById('bg-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let w, h, dpr;
|
||||||
|
let orbits = [];
|
||||||
|
let pulses = [];
|
||||||
|
let particles = [];
|
||||||
|
let mouse = { x: -1000, y: -1000 };
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
w = window.innerWidth;
|
||||||
|
h = document.documentElement.scrollHeight;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = w + 'px';
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orbital paths with satellites
|
||||||
|
function createOrbits() {
|
||||||
|
orbits = [];
|
||||||
|
const count = Math.max(4, Math.floor(w / 300));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const cx = Math.random() * w;
|
||||||
|
const cy = Math.random() * h;
|
||||||
|
const rx = 120 + Math.random() * 280;
|
||||||
|
const ry = 40 + Math.random() * 100;
|
||||||
|
const tilt = (Math.random() - 0.5) * 1.2;
|
||||||
|
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
|
||||||
|
const sats = [];
|
||||||
|
const satCount = 1 + Math.floor(Math.random() * 2);
|
||||||
|
for (let j = 0; j < satCount; j++) {
|
||||||
|
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
|
||||||
|
}
|
||||||
|
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating signal particles (tiny dots drifting upward)
|
||||||
|
function createParticles() {
|
||||||
|
particles = [];
|
||||||
|
const count = Math.max(30, Math.floor((w * h) / 25000));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
|
vy: -(0.08 + Math.random() * 0.15),
|
||||||
|
vx: (Math.random() - 0.5) * 0.1,
|
||||||
|
size: 0.5 + Math.random() * 1.2,
|
||||||
|
alpha: 0.1 + Math.random() * 0.25,
|
||||||
|
flicker: Math.random() * Math.PI * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPulse(x, y) {
|
||||||
|
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOrbitPath(orbit) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(orbit.cx, orbit.cy);
|
||||||
|
ctx.rotate(orbit.tilt);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSatellite(orbit, sat, dt) {
|
||||||
|
sat.angle += orbit.speed * dt;
|
||||||
|
const cos = Math.cos(orbit.tilt);
|
||||||
|
const sin = Math.sin(orbit.tilt);
|
||||||
|
const ex = orbit.rx * Math.cos(sat.angle);
|
||||||
|
const ey = orbit.ry * Math.sin(sat.angle);
|
||||||
|
const sx = orbit.cx + ex * cos - ey * sin;
|
||||||
|
const sy = orbit.cy + ex * sin + ey * cos;
|
||||||
|
|
||||||
|
// Satellite dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Faint glow
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
|
||||||
|
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
|
||||||
|
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
|
||||||
|
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Periodic signal pulse
|
||||||
|
sat.pulseTimer += dt;
|
||||||
|
if (sat.pulseTimer > 3000 + Math.random() * 500) {
|
||||||
|
sat.pulseTimer = 0;
|
||||||
|
spawnPulse(sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPulses(dt) {
|
||||||
|
for (let i = pulses.length - 1; i >= 0; i--) {
|
||||||
|
const p = pulses[i];
|
||||||
|
p.r += dt * 0.025;
|
||||||
|
p.alpha = 0.35 * (1 - p.r / p.maxR);
|
||||||
|
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Second ring
|
||||||
|
if (p.r > 12) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles(dt, time) {
|
||||||
|
for (const p of particles) {
|
||||||
|
p.y += p.vy * dt * 0.06;
|
||||||
|
p.x += p.vx * dt * 0.06;
|
||||||
|
p.flicker += dt * 0.002;
|
||||||
|
|
||||||
|
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
|
||||||
|
if (p.x < -10) p.x = w + 10;
|
||||||
|
if (p.x > w + 10) p.x = -10;
|
||||||
|
|
||||||
|
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
|
||||||
|
|
||||||
|
// Mouse interaction - subtle brighten
|
||||||
|
const dx = p.x - mouse.x;
|
||||||
|
const dy = p.y - mouse.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faint grid lines (signal grid)
|
||||||
|
function drawGrid(time) {
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const spacing = 120;
|
||||||
|
const offset = (time * 0.005) % spacing;
|
||||||
|
|
||||||
|
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, h);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(w, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = 0;
|
||||||
|
function animate(now) {
|
||||||
|
const dt = last ? Math.min(now - last, 50) : 16;
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
drawGrid(now);
|
||||||
|
|
||||||
|
for (const orbit of orbits) {
|
||||||
|
drawOrbitPath(orbit);
|
||||||
|
for (const sat of orbit.sats) {
|
||||||
|
drawSatellite(orbit, sat, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPulses(dt);
|
||||||
|
drawParticles(dt, now);
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track mouse for particle interaction
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
mouse.x = e.clientX;
|
||||||
|
mouse.y = e.clientY + window.scrollY;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize handling
|
||||||
|
let resizeTimer;
|
||||||
|
function handleResize() {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
resize();
|
||||||
|
createOrbits();
|
||||||
|
createParticles();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep canvas height synced with document
|
||||||
|
const ro = new ResizeObserver(() => { handleResize(); });
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
resize();
|
||||||
|
createOrbits();
|
||||||
|
createParticles();
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,6 +17,22 @@
|
|||||||
--gradient-end: #0088ff;
|
--gradient-end: #0088ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animated background canvas */
|
||||||
|
#bg-canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > *:not(#bg-canvas) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -245,18 +261,74 @@ section h2 {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
/* Category filter tabs */
|
||||||
display: grid;
|
.carousel-filters {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
display: flex;
|
||||||
gap: 24px;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel */
|
||||||
|
.carousel-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
|
flex: 0 0 280px;
|
||||||
|
scroll-snap-align: start;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.hidden {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover {
|
.feature-card:hover {
|
||||||
@@ -266,8 +338,15 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
font-size: 2rem;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card h3 {
|
.feature-card h3 {
|
||||||
@@ -283,6 +362,81 @@ section h2 {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Carousel arrows */
|
||||||
|
.carousel-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:disabled:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel indicators */
|
||||||
|
.carousel-indicators {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot.active {
|
||||||
|
background: var(--accent);
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Screenshots */
|
/* Screenshots */
|
||||||
.screenshot-gallery {
|
.screenshot-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -550,6 +704,72 @@ section h2 {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Support & Contact */
|
||||||
|
.support {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee {
|
||||||
|
border-color: rgba(255, 193, 59, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee:hover {
|
||||||
|
border-color: #ffc13b;
|
||||||
|
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee .support-icon {
|
||||||
|
color: #ffc13b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -641,14 +861,22 @@ section h2 {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.carousel-wrapper {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
padding: 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
flex: 0 0 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-gallery {
|
.screenshot-gallery {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.install-options {
|
.install-options {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -669,14 +897,35 @@ section h2 {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.carousel-wrapper {
|
||||||
grid-template-columns: 1fr;
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-filters {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-gallery {
|
.screenshot-gallery {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2022,7 +2022,7 @@ class ModeManager:
|
|||||||
'agent_gps': gps_manager.position
|
'agent_gps': gps_manager.position
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner.set_on_device_updated(on_device_updated)
|
scanner.add_device_callback(on_device_updated)
|
||||||
|
|
||||||
# Start scanning
|
# Start scanning
|
||||||
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.16.0"
|
version = "2.21.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"
|
||||||
license = {text = "MIT"}
|
license = {text = "Apache-2.0"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Intercept Contributors"}
|
{name = "Intercept Contributors"}
|
||||||
]
|
]
|
||||||
@@ -14,7 +14,7 @@ classifiers = [
|
|||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Framework :: Flask",
|
"Framework :: Flask",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Operating System :: MacOS",
|
"Operating System :: MacOS",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ scapy>=2.4.5
|
|||||||
# QR code generation for Meshtastic channels (optional)
|
# QR code generation for Meshtastic channels (optional)
|
||||||
qrcode[pil]>=7.4
|
qrcode[pil]>=7.4
|
||||||
|
|
||||||
|
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
|
||||||
|
cryptography>=41.0.0
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ def register_blueprints(app):
|
|||||||
from .ais import ais_bp
|
from .ais import ais_bp
|
||||||
from .dsc import dsc_bp
|
from .dsc import dsc_bp
|
||||||
from .acars import acars_bp
|
from .acars import acars_bp
|
||||||
|
from .vdl2 import vdl2_bp
|
||||||
from .aprs import aprs_bp
|
from .aprs import aprs_bp
|
||||||
from .satellite import satellite_bp
|
from .satellite import satellite_bp
|
||||||
from .gps import gps_bp
|
from .gps import gps_bp
|
||||||
@@ -33,6 +34,9 @@ def register_blueprints(app):
|
|||||||
from .alerts import alerts_bp
|
from .alerts import alerts_bp
|
||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
from .subghz import subghz_bp
|
from .subghz import subghz_bp
|
||||||
|
from .bt_locate import bt_locate_bp
|
||||||
|
from .analytics import analytics_bp
|
||||||
|
from .space_weather import space_weather_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -45,6 +49,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(ais_bp)
|
app.register_blueprint(ais_bp)
|
||||||
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||||
app.register_blueprint(acars_bp)
|
app.register_blueprint(acars_bp)
|
||||||
|
app.register_blueprint(vdl2_bp)
|
||||||
app.register_blueprint(aprs_bp)
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
@@ -65,6 +70,9 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
|
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
|
||||||
|
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
@@ -34,11 +35,8 @@ acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
|||||||
|
|
||||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||||
DEFAULT_ACARS_FREQUENCIES = [
|
DEFAULT_ACARS_FREQUENCIES = [
|
||||||
'131.550', # Primary worldwide
|
'131.725', # North America
|
||||||
'130.025', # Secondary USA/Canada
|
'131.825', # North America
|
||||||
'129.125', # USA
|
|
||||||
'131.525', # Europe
|
|
||||||
'131.725', # Europe secondary
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Message counter for statistics
|
# Message counter for statistics
|
||||||
@@ -128,6 +126,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
|
|
||||||
app_module.acars_queue.put(data)
|
app_module.acars_queue.put(data)
|
||||||
|
|
||||||
|
# Feed flight correlator
|
||||||
|
try:
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
get_flight_correlator().add_acars_message(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -250,12 +255,22 @@ def start_acars() -> Response:
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
# Build acarsdec command
|
# Build acarsdec command
|
||||||
# Different forks have different syntax:
|
# Different forks have different syntax:
|
||||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
# Note: gain/ppm must come BEFORE -r
|
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
|
||||||
|
# Note: gain/ppm must come BEFORE -r/-d
|
||||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
cmd = [acarsdec_path]
|
cmd = [acarsdec_path]
|
||||||
if json_flag == '--output':
|
if json_flag == '--output':
|
||||||
@@ -266,21 +281,33 @@ def start_acars() -> Response:
|
|||||||
else:
|
else:
|
||||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
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/-d)
|
||||||
if gain and str(gain) != '0':
|
if gain and str(gain) != '0':
|
||||||
cmd.extend(['-g', str(gain)])
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
# Add PPM correction if specified (must be before -r)
|
# Add PPM correction if specified (must be before -r/-d)
|
||||||
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
|
# Add device and frequencies
|
||||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
if is_soapy:
|
||||||
if json_flag == '--output':
|
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||||
|
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device_str = builder._build_device_string(sdr_device)
|
||||||
|
if json_flag == '--output':
|
||||||
|
cmd.extend(['-m', '256'])
|
||||||
|
cmd.extend(['--soapysdr', device_str])
|
||||||
|
else:
|
||||||
|
cmd.extend(['-d', device_str])
|
||||||
|
elif json_flag == '--output':
|
||||||
|
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
|
||||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
cmd.extend(['-m', '256'])
|
cmd.extend(['-m', '256'])
|
||||||
cmd.extend(['--rtlsdr', str(device)])
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
else:
|
else:
|
||||||
|
# TLeconte fork RTL-SDR: -r <device>
|
||||||
cmd.extend(['-r', str(device)])
|
cmd.extend(['-r', str(device)])
|
||||||
cmd.extend(frequencies)
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
@@ -387,25 +414,19 @@ def stop_acars() -> Response:
|
|||||||
@acars_bp.route('/stream')
|
@acars_bp.route('/stream')
|
||||||
def stream_acars() -> Response:
|
def stream_acars() -> Response:
|
||||||
"""SSE stream for ACARS messages."""
|
"""SSE stream for ACARS messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('acars', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.acars_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='acars',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('acars', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
@@ -417,7 +438,7 @@ def get_frequencies() -> Response:
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||||
'regions': {
|
'regions': {
|
||||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
'north_america': ['131.725', '131.825'],
|
||||||
'europe': ['131.525', '131.725', '131.550'],
|
'europe': ['131.525', '131.725', '131.550'],
|
||||||
'asia_pacific': ['131.550', '131.450'],
|
'asia_pacific': ['131.550', '131.450'],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from config import (
|
|||||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
)
|
)
|
||||||
from utils.logging import adsb_logger as logger
|
from utils.logging import adsb_logger as logger
|
||||||
|
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index, validate_gain,
|
validate_device_index, validate_gain,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
@@ -76,6 +77,11 @@ _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()
|
||||||
|
|
||||||
|
# Per-client SSE queues for ADS-B stream fanout.
|
||||||
|
_adsb_stream_subscribers: set[queue.Queue] = set()
|
||||||
|
_adsb_stream_subscribers_lock = threading.Lock()
|
||||||
|
_ADSB_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||||
|
|
||||||
# Load aircraft database at module init
|
# Load aircraft database at module init
|
||||||
aircraft_db.load_database()
|
aircraft_db.load_database()
|
||||||
|
|
||||||
@@ -202,6 +208,31 @@ def _parse_int_param(value: str | None, default: int, min_value: int | None = No
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
|
||||||
|
"""Fan out a payload to all active ADS-B SSE subscribers."""
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
subscribers = tuple(_adsb_stream_subscribers)
|
||||||
|
|
||||||
|
for subscriber in subscribers:
|
||||||
|
try:
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except queue.Full:
|
||||||
|
# Drop oldest queued event for that client and try once more.
|
||||||
|
try:
|
||||||
|
subscriber.get_nowait()
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
# Client queue remains saturated; skip this payload.
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def _adsb_stream_queue_depth() -> int:
|
||||||
|
"""Best-effort aggregate queue depth across connected ADS-B SSE clients."""
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
subscribers = tuple(_adsb_stream_subscribers)
|
||||||
|
return sum(subscriber.qsize() for subscriber in subscribers)
|
||||||
|
|
||||||
|
|
||||||
def _get_active_session() -> dict[str, Any] | None:
|
def _get_active_session() -> dict[str, Any] | None:
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
@@ -438,6 +469,12 @@ def parse_sbs_stream(service_addr):
|
|||||||
if parts[16]:
|
if parts[16]:
|
||||||
try:
|
try:
|
||||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||||
|
if abs(aircraft['vertical_rate']) > 4000:
|
||||||
|
process_event('adsb', {
|
||||||
|
'type': 'vertical_rate_anomaly', 'icao': icao,
|
||||||
|
'callsign': aircraft.get('callsign', ''),
|
||||||
|
'vertical_rate': aircraft['vertical_rate'],
|
||||||
|
}, 'vertical_rate_anomaly')
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -455,6 +492,14 @@ def parse_sbs_stream(service_addr):
|
|||||||
elif msg_type == '6' and len(parts) > 17:
|
elif msg_type == '6' and len(parts) > 17:
|
||||||
if parts[17]:
|
if parts[17]:
|
||||||
aircraft['squawk'] = parts[17]
|
aircraft['squawk'] = parts[17]
|
||||||
|
sq = parts[17].strip()
|
||||||
|
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
|
||||||
|
if sq in _EMERGENCY_SQUAWKS:
|
||||||
|
process_event('adsb', {
|
||||||
|
'type': 'squawk_emergency', 'icao': icao,
|
||||||
|
'callsign': aircraft.get('callsign', ''),
|
||||||
|
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||||
|
}, 'squawk_emergency')
|
||||||
|
|
||||||
app_module.adsb_aircraft.set(icao, aircraft)
|
app_module.adsb_aircraft.set(icao, aircraft)
|
||||||
pending_updates.add(icao)
|
pending_updates.add(icao)
|
||||||
@@ -466,7 +511,7 @@ def parse_sbs_stream(service_addr):
|
|||||||
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]
|
snapshot = app_module.adsb_aircraft[update_icao]
|
||||||
app_module.adsb_queue.put({
|
_broadcast_adsb_update({
|
||||||
'type': 'aircraft',
|
'type': 'aircraft',
|
||||||
**snapshot
|
**snapshot
|
||||||
})
|
})
|
||||||
@@ -487,6 +532,19 @@ def parse_sbs_stream(service_addr):
|
|||||||
'source_host': service_addr,
|
'source_host': service_addr,
|
||||||
'snapshot': snapshot,
|
'snapshot': snapshot,
|
||||||
})
|
})
|
||||||
|
# Geofence check
|
||||||
|
_gf_lat = snapshot.get('lat')
|
||||||
|
_gf_lon = snapshot.get('lon')
|
||||||
|
if _gf_lat is not None and _gf_lon is not None:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||||
|
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||||
|
):
|
||||||
|
process_event('adsb', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
pending_updates.clear()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
|
|
||||||
@@ -552,7 +610,7 @@ def adsb_status():
|
|||||||
'last_message_time': adsb_last_message_time,
|
'last_message_time': adsb_last_message_time,
|
||||||
'aircraft_count': len(app_module.adsb_aircraft),
|
'aircraft_count': len(app_module.adsb_aircraft),
|
||||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||||
'queue_size': app_module.adsb_queue.qsize(),
|
'queue_size': _adsb_stream_queue_depth(),
|
||||||
'dump1090_path': find_dump1090(),
|
'dump1090_path': find_dump1090(),
|
||||||
'dump1090_running': dump1090_running,
|
'dump1090_running': dump1090_running,
|
||||||
'port_30003_open': check_dump1090_service() is not None
|
'port_30003_open': check_dump1090_service() is not None
|
||||||
@@ -633,6 +691,9 @@ def start_adsb():
|
|||||||
'session': session
|
'session': session
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Kill any stale app-spawned dump1090 from a previous run before checking the port
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
|
||||||
# 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()
|
||||||
if existing_service:
|
if existing_service:
|
||||||
@@ -685,6 +746,7 @@ def start_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("Killed stale ADS-B process")
|
logger.info("Killed stale ADS-B process")
|
||||||
|
|
||||||
# Check if device is available before starting local dump1090
|
# Check if device is available before starting local dump1090
|
||||||
@@ -721,6 +783,7 @@ def start_adsb():
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
start_new_session=True # Create new process group for clean shutdown
|
start_new_session=True # Create new process group for clean shutdown
|
||||||
)
|
)
|
||||||
|
write_dump1090_pid(app_module.adsb_process.pid)
|
||||||
|
|
||||||
time.sleep(DUMP1090_START_WAIT)
|
time.sleep(DUMP1090_START_WAIT)
|
||||||
|
|
||||||
@@ -819,6 +882,7 @@ def stop_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("ADS-B process stopped")
|
logger.info("ADS-B process stopped")
|
||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
@@ -837,23 +901,39 @@ def stop_adsb():
|
|||||||
@adsb_bp.route('/stream')
|
@adsb_bp.route('/stream')
|
||||||
def stream_adsb():
|
def stream_adsb():
|
||||||
"""SSE stream for ADS-B aircraft."""
|
"""SSE stream for ADS-B aircraft."""
|
||||||
|
client_queue: queue.Queue = queue.Queue(maxsize=_ADSB_STREAM_CLIENT_QUEUE_SIZE)
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
_adsb_stream_subscribers.add(client_queue)
|
||||||
|
|
||||||
|
# Prime new clients with current known aircraft so they don't wait for the
|
||||||
|
# next positional update before rendering.
|
||||||
|
for snapshot in list(app_module.adsb_aircraft.values()):
|
||||||
|
try:
|
||||||
|
client_queue.put_nowait({'type': 'aircraft', **snapshot})
|
||||||
|
except queue.Full:
|
||||||
|
break
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
|
||||||
while True:
|
try:
|
||||||
try:
|
while True:
|
||||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
|
||||||
last_keepalive = time.time()
|
|
||||||
try:
|
try:
|
||||||
process_event('adsb', msg, msg.get('type'))
|
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
except Exception:
|
last_keepalive = time.time()
|
||||||
pass
|
try:
|
||||||
yield format_sse(msg)
|
process_event('adsb', msg, msg.get('type'))
|
||||||
except queue.Empty:
|
except Exception:
|
||||||
now = time.time()
|
pass
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
yield format_sse(msg)
|
||||||
yield format_sse({'type': 'keepalive'})
|
except queue.Empty:
|
||||||
last_keepalive = now
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
finally:
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
_adsb_stream_subscribers.discard(client_queue)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
@@ -1096,3 +1176,17 @@ def aircraft_photo(registration: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/aircraft/<icao>/messages')
|
||||||
|
def get_aircraft_messages(icao: str):
|
||||||
|
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||||
|
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
|
||||||
|
|
||||||
|
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
||||||
|
callsign = aircraft.get('callsign') if aircraft else None
|
||||||
|
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
|
||||||
|
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import app as app_module
|
|||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -124,13 +124,27 @@ def parse_ais_stream(port: int):
|
|||||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||||
for mmsi in pending_updates:
|
for mmsi in pending_updates:
|
||||||
if mmsi in app_module.ais_vessels:
|
if mmsi in app_module.ais_vessels:
|
||||||
|
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||||
try:
|
try:
|
||||||
app_module.ais_queue.put_nowait({
|
app_module.ais_queue.put_nowait({
|
||||||
'type': 'vessel',
|
'type': 'vessel',
|
||||||
**app_module.ais_vessels[mmsi]
|
**_vessel_snap
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
# Geofence check
|
||||||
|
_v_lat = _vessel_snap.get('lat')
|
||||||
|
_v_lon = _vessel_snap.get('lon')
|
||||||
|
if _v_lat and _v_lon:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
mmsi, 'vessel', _v_lat, _v_lon,
|
||||||
|
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
||||||
|
):
|
||||||
|
process_event('ais', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
pending_updates.clear()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
|
|
||||||
@@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
|||||||
# Timestamp
|
# Timestamp
|
||||||
vessel['last_seen'] = time.time()
|
vessel['last_seen'] = time.time()
|
||||||
|
|
||||||
|
# Check for DSC DISTRESS matching this MMSI
|
||||||
|
try:
|
||||||
|
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||||
|
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||||
|
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||||
|
vessel['dsc_distress'] = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return vessel
|
return vessel
|
||||||
|
|
||||||
|
|
||||||
@@ -478,30 +502,41 @@ def stop_ais():
|
|||||||
@ais_bp.route('/stream')
|
@ais_bp.route('/stream')
|
||||||
def stream_ais():
|
def stream_ais():
|
||||||
"""SSE stream for AIS vessels."""
|
"""SSE stream for AIS vessels."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('ais', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.ais_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='ais',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('ais', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||||
|
def get_vessel_dsc(mmsi: str):
|
||||||
|
"""Get DSC messages associated with a vessel MMSI."""
|
||||||
|
if not mmsi or not mmsi.isdigit():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
try:
|
||||||
|
for key, msg in app_module.dsc_messages.items():
|
||||||
|
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||||
|
matches.append(dict(msg))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||||
|
|
||||||
|
|
||||||
@ais_bp.route('/dashboard')
|
@ais_bp.route('/dashboard')
|
||||||
def ais_dashboard():
|
def ais_dashboard():
|
||||||
"""Popout AIS dashboard."""
|
"""Popout AIS dashboard."""
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.analytics import (
|
||||||
|
get_activity_tracker,
|
||||||
|
get_cross_mode_summary,
|
||||||
|
get_emergency_squawks,
|
||||||
|
get_mode_health,
|
||||||
|
)
|
||||||
|
from utils.alerts import get_alert_manager
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
from utils.temporal_patterns import get_pattern_detector
|
||||||
|
|
||||||
|
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
||||||
|
|
||||||
|
|
||||||
|
# Map mode names to DataStore attribute(s)
|
||||||
|
MODE_STORES: dict[str, list[str]] = {
|
||||||
|
'adsb': ['adsb_aircraft'],
|
||||||
|
'ais': ['ais_vessels'],
|
||||||
|
'wifi': ['wifi_networks', 'wifi_clients'],
|
||||||
|
'bluetooth': ['bt_devices'],
|
||||||
|
'dsc': ['dsc_messages'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/summary')
|
||||||
|
def analytics_summary():
|
||||||
|
"""Return cross-mode counts, health, and emergency squawks."""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'counts': get_cross_mode_summary(),
|
||||||
|
'health': get_mode_health(),
|
||||||
|
'squawks': get_emergency_squawks(),
|
||||||
|
'flight_messages': {
|
||||||
|
'acars': get_flight_correlator().acars_count,
|
||||||
|
'vdl2': get_flight_correlator().vdl2_count,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/activity')
|
||||||
|
def analytics_activity():
|
||||||
|
"""Return sparkline arrays for each mode."""
|
||||||
|
tracker = get_activity_tracker()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'sparklines': tracker.get_all_sparklines(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/squawks')
|
||||||
|
def analytics_squawks():
|
||||||
|
"""Return current emergency squawk codes from ADS-B."""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'squawks': get_emergency_squawks(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/patterns')
|
||||||
|
def analytics_patterns():
|
||||||
|
"""Return detected temporal patterns."""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'patterns': get_pattern_detector().get_all_patterns(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/target')
|
||||||
|
def analytics_target():
|
||||||
|
"""Search entities across multiple modes for a target-centric view."""
|
||||||
|
query = (request.args.get('q') or '').strip()
|
||||||
|
requested_limit = request.args.get('limit', default=120, type=int) or 120
|
||||||
|
limit = max(1, min(500, requested_limit))
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'query': '',
|
||||||
|
'results': [],
|
||||||
|
'mode_counts': {},
|
||||||
|
})
|
||||||
|
|
||||||
|
needle = query.lower()
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
mode_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
|
||||||
|
if len(results) >= limit:
|
||||||
|
return
|
||||||
|
results.append({
|
||||||
|
'mode': mode,
|
||||||
|
'id': entity_id,
|
||||||
|
'title': title,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
'last_seen': last_seen,
|
||||||
|
})
|
||||||
|
mode_counts[mode] = mode_counts.get(mode, 0) + 1
|
||||||
|
|
||||||
|
# ADS-B
|
||||||
|
for icao, aircraft in app_module.adsb_aircraft.items():
|
||||||
|
if not isinstance(aircraft, dict):
|
||||||
|
continue
|
||||||
|
fields = [
|
||||||
|
icao,
|
||||||
|
aircraft.get('icao'),
|
||||||
|
aircraft.get('hex'),
|
||||||
|
aircraft.get('callsign'),
|
||||||
|
aircraft.get('registration'),
|
||||||
|
aircraft.get('flight'),
|
||||||
|
]
|
||||||
|
if not _matches_query(needle, fields):
|
||||||
|
continue
|
||||||
|
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
|
||||||
|
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
|
||||||
|
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
# AIS
|
||||||
|
if len(results) < limit:
|
||||||
|
for mmsi, vessel in app_module.ais_vessels.items():
|
||||||
|
if not isinstance(vessel, dict):
|
||||||
|
continue
|
||||||
|
fields = [
|
||||||
|
mmsi,
|
||||||
|
vessel.get('mmsi'),
|
||||||
|
vessel.get('name'),
|
||||||
|
vessel.get('shipname'),
|
||||||
|
vessel.get('callsign'),
|
||||||
|
vessel.get('imo'),
|
||||||
|
]
|
||||||
|
if not _matches_query(needle, fields):
|
||||||
|
continue
|
||||||
|
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
|
||||||
|
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
|
||||||
|
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
# WiFi networks and clients
|
||||||
|
if len(results) < limit:
|
||||||
|
for bssid, net in app_module.wifi_networks.items():
|
||||||
|
if not isinstance(net, dict):
|
||||||
|
continue
|
||||||
|
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
|
||||||
|
if not _matches_query(needle, fields):
|
||||||
|
continue
|
||||||
|
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
|
||||||
|
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
|
||||||
|
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(results) < limit:
|
||||||
|
for client_mac, client in app_module.wifi_clients.items():
|
||||||
|
if not isinstance(client, dict):
|
||||||
|
continue
|
||||||
|
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
|
||||||
|
if not _matches_query(needle, fields):
|
||||||
|
continue
|
||||||
|
title = str(client.get('mac') or client_mac or 'WiFi Client')
|
||||||
|
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
|
||||||
|
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Bluetooth
|
||||||
|
if len(results) < limit:
|
||||||
|
for address, dev in app_module.bt_devices.items():
|
||||||
|
if not isinstance(dev, dict):
|
||||||
|
continue
|
||||||
|
fields = [
|
||||||
|
address,
|
||||||
|
dev.get('address'),
|
||||||
|
dev.get('mac'),
|
||||||
|
dev.get('name'),
|
||||||
|
dev.get('manufacturer'),
|
||||||
|
dev.get('vendor'),
|
||||||
|
]
|
||||||
|
if not _matches_query(needle, fields):
|
||||||
|
continue
|
||||||
|
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
|
||||||
|
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
|
||||||
|
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
# DSC recent messages
|
||||||
|
if len(results) < limit:
|
||||||
|
for msg_id, msg in app_module.dsc_messages.items():
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
fields = [
|
||||||
|
msg_id,
|
||||||
|
msg.get('mmsi'),
|
||||||
|
msg.get('from_mmsi'),
|
||||||
|
msg.get('to_mmsi'),
|
||||||
|
msg.get('from_callsign'),
|
||||||
|
msg.get('to_callsign'),
|
||||||
|
msg.get('category'),
|
||||||
|
]
|
||||||
|
if not _matches_query(needle, fields):
|
||||||
|
continue
|
||||||
|
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
|
||||||
|
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
|
||||||
|
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'query': query,
|
||||||
|
'results': results,
|
||||||
|
'mode_counts': mode_counts,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/insights')
|
||||||
|
def analytics_insights():
|
||||||
|
"""Return actionable insight cards and top changes."""
|
||||||
|
counts = get_cross_mode_summary()
|
||||||
|
tracker = get_activity_tracker()
|
||||||
|
sparklines = tracker.get_all_sparklines()
|
||||||
|
squawks = get_emergency_squawks()
|
||||||
|
patterns = get_pattern_detector().get_all_patterns()
|
||||||
|
alerts = get_alert_manager().list_events(limit=120)
|
||||||
|
|
||||||
|
top_changes = _compute_mode_changes(sparklines)
|
||||||
|
busiest_mode, busiest_count = _get_busiest_mode(counts)
|
||||||
|
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
|
||||||
|
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
|
||||||
|
|
||||||
|
cards = []
|
||||||
|
if top_changes:
|
||||||
|
lead = top_changes[0]
|
||||||
|
direction = 'up' if lead['delta'] >= 0 else 'down'
|
||||||
|
cards.append({
|
||||||
|
'id': 'fastest_change',
|
||||||
|
'title': 'Fastest Change',
|
||||||
|
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
|
||||||
|
'label': 'last window vs prior',
|
||||||
|
'severity': 'high' if lead['delta'] > 0 else 'low',
|
||||||
|
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
cards.append({
|
||||||
|
'id': 'fastest_change',
|
||||||
|
'title': 'Fastest Change',
|
||||||
|
'value': 'Insufficient data',
|
||||||
|
'label': 'wait for activity history',
|
||||||
|
'severity': 'low',
|
||||||
|
'detail': 'Sparklines need more samples to score momentum.',
|
||||||
|
})
|
||||||
|
|
||||||
|
cards.append({
|
||||||
|
'id': 'busiest_mode',
|
||||||
|
'title': 'Busiest Mode',
|
||||||
|
'value': f"{busiest_mode} ({busiest_count})",
|
||||||
|
'label': 'current observed entities',
|
||||||
|
'severity': 'medium' if busiest_count > 0 else 'low',
|
||||||
|
'detail': 'Highest live entity count across monitoring modes.',
|
||||||
|
})
|
||||||
|
cards.append({
|
||||||
|
'id': 'critical_alerts',
|
||||||
|
'title': 'Critical Alerts (1h)',
|
||||||
|
'value': str(critical_1h),
|
||||||
|
'label': 'critical/high severities',
|
||||||
|
'severity': 'critical' if critical_1h > 0 else 'low',
|
||||||
|
'detail': 'Prioritize triage if this count is non-zero.',
|
||||||
|
})
|
||||||
|
cards.append({
|
||||||
|
'id': 'emergency_squawks',
|
||||||
|
'title': 'Emergency Squawks',
|
||||||
|
'value': str(len(squawks)),
|
||||||
|
'label': 'active ADS-B emergency codes',
|
||||||
|
'severity': 'critical' if squawks else 'low',
|
||||||
|
'detail': 'Immediate aviation anomalies currently visible.',
|
||||||
|
})
|
||||||
|
cards.append({
|
||||||
|
'id': 'recurring_emitters',
|
||||||
|
'title': 'Recurring Emitters',
|
||||||
|
'value': str(recurring_emitters),
|
||||||
|
'label': 'pattern confidence >= 0.70',
|
||||||
|
'severity': 'medium' if recurring_emitters > 0 else 'low',
|
||||||
|
'detail': 'Potentially stationary or periodic emitters detected.',
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'cards': cards,
|
||||||
|
'top_changes': top_changes[:5],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
|
||||||
|
mode_labels = {
|
||||||
|
'adsb': 'ADS-B',
|
||||||
|
'ais': 'AIS',
|
||||||
|
'wifi': 'WiFi',
|
||||||
|
'bluetooth': 'Bluetooth',
|
||||||
|
'dsc': 'DSC',
|
||||||
|
'acars': 'ACARS',
|
||||||
|
'vdl2': 'VDL2',
|
||||||
|
'aprs': 'APRS',
|
||||||
|
'meshtastic': 'Meshtastic',
|
||||||
|
}
|
||||||
|
rows = []
|
||||||
|
for mode, samples in (sparklines or {}).items():
|
||||||
|
if not isinstance(samples, list) or len(samples) < 4:
|
||||||
|
continue
|
||||||
|
|
||||||
|
window = max(2, min(12, len(samples) // 2))
|
||||||
|
recent = samples[-window:]
|
||||||
|
previous = samples[-(window * 2):-window]
|
||||||
|
if not previous:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recent_avg = sum(recent) / len(recent)
|
||||||
|
prev_avg = sum(previous) / len(previous)
|
||||||
|
delta = round(recent_avg - prev_avg, 1)
|
||||||
|
rows.append({
|
||||||
|
'mode': mode,
|
||||||
|
'mode_label': mode_labels.get(mode, mode.upper()),
|
||||||
|
'delta': delta,
|
||||||
|
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
|
||||||
|
'recent_avg': round(recent_avg, 1),
|
||||||
|
'previous_avg': round(prev_avg, 1),
|
||||||
|
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
|
||||||
|
})
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_query(needle: str, values: list[Any]) -> bool:
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if needle in str(value).lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
count = 0
|
||||||
|
for event in alerts:
|
||||||
|
sev = str(event.get('severity') or '').lower()
|
||||||
|
if sev not in severities:
|
||||||
|
continue
|
||||||
|
created_raw = event.get('created_at')
|
||||||
|
if not created_raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
age = (now - created).total_seconds()
|
||||||
|
if 0 <= age <= max_age_seconds:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
|
||||||
|
mode_labels = {
|
||||||
|
'adsb': 'ADS-B',
|
||||||
|
'ais': 'AIS',
|
||||||
|
'wifi': 'WiFi',
|
||||||
|
'bluetooth': 'Bluetooth',
|
||||||
|
'dsc': 'DSC',
|
||||||
|
'acars': 'ACARS',
|
||||||
|
'vdl2': 'VDL2',
|
||||||
|
'aprs': 'APRS',
|
||||||
|
'meshtastic': 'Meshtastic',
|
||||||
|
}
|
||||||
|
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
|
||||||
|
if not filtered:
|
||||||
|
return ('None', 0)
|
||||||
|
mode = max(filtered, key=filtered.get)
|
||||||
|
return (mode_labels.get(mode, mode.upper()), filtered[mode])
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/export/<mode>')
|
||||||
|
def analytics_export(mode: str):
|
||||||
|
"""Export current DataStore contents as JSON or CSV."""
|
||||||
|
fmt = request.args.get('format', 'json').lower()
|
||||||
|
|
||||||
|
if mode == 'sensor':
|
||||||
|
# Sensor doesn't use DataStore; return recent queue-based data
|
||||||
|
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
|
||||||
|
|
||||||
|
store_names = MODE_STORES.get(mode)
|
||||||
|
if not store_names:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
|
||||||
|
|
||||||
|
all_items: list[dict] = []
|
||||||
|
|
||||||
|
# Try v2 scanners first for wifi/bluetooth
|
||||||
|
if mode == 'wifi':
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None:
|
||||||
|
for ap in wifi_scanner.access_points:
|
||||||
|
all_items.append(ap.to_dict())
|
||||||
|
for client in wifi_scanner.clients:
|
||||||
|
item = client.to_dict()
|
||||||
|
item['_store'] = 'wifi_clients'
|
||||||
|
all_items.append(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif mode == 'bluetooth':
|
||||||
|
try:
|
||||||
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
|
if bt_scanner is not None:
|
||||||
|
for dev in bt_scanner.get_devices():
|
||||||
|
all_items.append(dev.to_dict())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to legacy DataStores if v2 scanners yielded nothing
|
||||||
|
if not all_items:
|
||||||
|
for store_name in store_names:
|
||||||
|
store = getattr(app_module, store_name, None)
|
||||||
|
if store is None:
|
||||||
|
continue
|
||||||
|
for key, value in store.items():
|
||||||
|
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
|
||||||
|
item.setdefault('_store', store_name)
|
||||||
|
all_items.append(item)
|
||||||
|
|
||||||
|
if fmt == 'csv':
|
||||||
|
if not all_items:
|
||||||
|
output = ''
|
||||||
|
else:
|
||||||
|
# Collect all keys across items
|
||||||
|
fieldnames: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in all_items:
|
||||||
|
for k in item:
|
||||||
|
if k not in seen:
|
||||||
|
fieldnames.append(k)
|
||||||
|
seen.add(k)
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
|
||||||
|
writer.writeheader()
|
||||||
|
for item in all_items:
|
||||||
|
# Serialize non-scalar values
|
||||||
|
row = {}
|
||||||
|
for k in fieldnames:
|
||||||
|
v = item.get(k)
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
row[k] = json.dumps(v)
|
||||||
|
else:
|
||||||
|
row[k] = v
|
||||||
|
writer.writerow(row)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
response = Response(output, mimetype='text/csv')
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Default: JSON
|
||||||
|
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Geofence CRUD
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@analytics_bp.route('/geofences')
|
||||||
|
def list_geofences():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'zones': get_geofence_manager().list_zones(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/geofences', methods=['POST'])
|
||||||
|
def create_geofence():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name')
|
||||||
|
lat = data.get('lat')
|
||||||
|
lon = data.get('lon')
|
||||||
|
radius_m = data.get('radius_m')
|
||||||
|
|
||||||
|
if not all([name, lat is not None, lon is not None, radius_m is not None]):
|
||||||
|
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(lat)
|
||||||
|
lon = float(lon)
|
||||||
|
radius_m = float(radius_m)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||||
|
if radius_m <= 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
|
||||||
|
|
||||||
|
alert_on = data.get('alert_on', 'enter_exit')
|
||||||
|
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
|
||||||
|
return jsonify({'status': 'success', 'zone_id': zone_id})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
|
||||||
|
def delete_geofence(zone_id: int):
|
||||||
|
ok = get_geofence_manager().delete_zone(zone_id)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
@@ -21,7 +21,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -47,6 +47,8 @@ APRS_FREQUENCIES = {
|
|||||||
'brazil': '145.570',
|
'brazil': '145.570',
|
||||||
'japan': '144.640',
|
'japan': '144.640',
|
||||||
'china': '144.640',
|
'china': '144.640',
|
||||||
|
'iss': '145.825',
|
||||||
|
'sonate2': '145.825',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
@@ -1378,6 +1380,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
'last_seen': packet.get('timestamp'),
|
'last_seen': packet.get('timestamp'),
|
||||||
'packet_type': packet.get('packet_type'),
|
'packet_type': packet.get('packet_type'),
|
||||||
}
|
}
|
||||||
|
# Geofence check
|
||||||
|
_aprs_lat = packet.get('lat')
|
||||||
|
_aprs_lon = packet.get('lon')
|
||||||
|
if _aprs_lat and _aprs_lon:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||||
|
{'callsign': callsign}
|
||||||
|
):
|
||||||
|
process_event('aprs', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Evict oldest stations when limit is exceeded
|
# Evict oldest stations when limit is exceeded
|
||||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||||
oldest = min(
|
oldest = min(
|
||||||
@@ -1751,25 +1766,19 @@ def stop_aprs() -> Response:
|
|||||||
@aprs_bp.route('/stream')
|
@aprs_bp.route('/stream')
|
||||||
def stream_aprs() -> Response:
|
def stream_aprs() -> Response:
|
||||||
"""SSE stream for APRS packets."""
|
"""SSE stream for APRS packets."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('aprs', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.aprs_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='aprs',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('aprs', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
@@ -556,26 +556,19 @@ def get_bt_devices():
|
|||||||
@bluetooth_bp.route('/stream')
|
@bluetooth_bp.route('/stream')
|
||||||
def stream_bt():
|
def stream_bt():
|
||||||
"""SSE stream for Bluetooth events."""
|
"""SSE stream for Bluetooth events."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
source_queue=app_module.bt_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='bluetooth',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('bluetooth', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ def start_scan():
|
|||||||
rssi_threshold = data.get('rssi_threshold', -100)
|
rssi_threshold = data.get('rssi_threshold', -100)
|
||||||
|
|
||||||
# Validate mode
|
# Validate mode
|
||||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||||
if mode not in valid_modes:
|
if mode not in valid_modes:
|
||||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||||
|
|
||||||
@@ -250,8 +250,8 @@ def start_scan():
|
|||||||
logger.debug(f"BT seen-before update failed: {e}")
|
logger.debug(f"BT seen-before update failed: {e}")
|
||||||
|
|
||||||
# Setup seen-before callback
|
# Setup seen-before callback
|
||||||
if scanner._on_device_updated is None:
|
if _handle_seen_before not in scanner._on_device_updated_callbacks:
|
||||||
scanner._on_device_updated = _handle_seen_before
|
scanner.add_device_callback(_handle_seen_before)
|
||||||
|
|
||||||
# Ensure cache is initialized
|
# Ensure cache is initialized
|
||||||
with _bt_seen_lock:
|
with _bt_seen_lock:
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
BT Locate — Bluetooth SAR Device Location Flask Blueprint.
|
||||||
|
|
||||||
|
Provides endpoints for managing locate sessions, streaming detection events,
|
||||||
|
and retrieving GPS-tagged signal trails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.bluetooth.irk_extractor import get_paired_irks
|
||||||
|
from utils.bt_locate import (
|
||||||
|
Environment,
|
||||||
|
LocateTarget,
|
||||||
|
get_locate_session,
|
||||||
|
resolve_rpa,
|
||||||
|
start_locate_session,
|
||||||
|
stop_locate_session,
|
||||||
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.bt_locate')
|
||||||
|
|
||||||
|
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/start', methods=['POST'])
|
||||||
|
def start_session():
|
||||||
|
"""
|
||||||
|
Start a locate session.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- mac_address: Target MAC address (optional)
|
||||||
|
- name_pattern: Target name substring (optional)
|
||||||
|
- irk_hex: Identity Resolving Key hex string (optional)
|
||||||
|
- device_id: Device ID from Bluetooth scanner (optional)
|
||||||
|
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||||
|
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||||
|
- known_name: Hand-off device name (optional)
|
||||||
|
- known_manufacturer: Hand-off manufacturer (optional)
|
||||||
|
- last_known_rssi: Hand-off last RSSI (optional)
|
||||||
|
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||||
|
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with session status.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
# Build target
|
||||||
|
target = LocateTarget(
|
||||||
|
mac_address=data.get('mac_address'),
|
||||||
|
name_pattern=data.get('name_pattern'),
|
||||||
|
irk_hex=data.get('irk_hex'),
|
||||||
|
device_id=data.get('device_id'),
|
||||||
|
device_key=data.get('device_key'),
|
||||||
|
fingerprint_id=data.get('fingerprint_id'),
|
||||||
|
known_name=data.get('known_name'),
|
||||||
|
known_manufacturer=data.get('known_manufacturer'),
|
||||||
|
last_known_rssi=data.get('last_known_rssi'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# At least one identifier required
|
||||||
|
if not any([
|
||||||
|
target.mac_address,
|
||||||
|
target.name_pattern,
|
||||||
|
target.irk_hex,
|
||||||
|
target.device_id,
|
||||||
|
target.device_key,
|
||||||
|
target.fingerprint_id,
|
||||||
|
]):
|
||||||
|
return jsonify({
|
||||||
|
'error': (
|
||||||
|
'At least one target identifier required '
|
||||||
|
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
|
||||||
|
)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Parse environment
|
||||||
|
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||||
|
try:
|
||||||
|
environment = Environment[env_str]
|
||||||
|
except KeyError:
|
||||||
|
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||||
|
|
||||||
|
custom_exponent = data.get('custom_exponent')
|
||||||
|
if custom_exponent is not None:
|
||||||
|
try:
|
||||||
|
custom_exponent = float(custom_exponent)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'error': 'custom_exponent must be a number'}), 400
|
||||||
|
|
||||||
|
# Fallback coordinates when GPS is unavailable (from user settings)
|
||||||
|
fallback_lat = None
|
||||||
|
fallback_lon = None
|
||||||
|
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
|
||||||
|
try:
|
||||||
|
fallback_lat = float(data['fallback_lat'])
|
||||||
|
fallback_lon = float(data['fallback_lon'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting locate session: target={target.to_dict()}, "
|
||||||
|
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||||
|
)
|
||||||
|
|
||||||
|
session = start_locate_session(
|
||||||
|
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'session': session.get_status(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_session():
|
||||||
|
"""Stop the active locate session."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'no_session'})
|
||||||
|
|
||||||
|
stop_locate_session()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/status', methods=['GET'])
|
||||||
|
def get_status():
|
||||||
|
"""Get locate session status."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({
|
||||||
|
'active': False,
|
||||||
|
'target': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(session.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||||
|
def get_trail():
|
||||||
|
"""Get detection trail data."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'trail': [], 'gps_trail': []})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'trail': session.get_trail(),
|
||||||
|
'gps_trail': session.get_gps_trail(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/stream', methods=['GET'])
|
||||||
|
def stream_detections():
|
||||||
|
"""SSE stream of detection events."""
|
||||||
|
|
||||||
|
def event_generator() -> Generator[str, None, None]:
|
||||||
|
while True:
|
||||||
|
# Re-fetch session each iteration in case it changes
|
||||||
|
s = get_locate_session()
|
||||||
|
if not s:
|
||||||
|
yield format_sse({'type': 'session_ended'}, event='session_ended')
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = s.event_queue.get(timeout=2.0)
|
||||||
|
yield format_sse(event, event='detection')
|
||||||
|
except Exception:
|
||||||
|
yield format_sse({}, event='ping')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
event_generator(),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
|
||||||
|
def test_resolve_rpa():
|
||||||
|
"""
|
||||||
|
Test if an IRK resolves to a given address.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- irk_hex: 16-byte IRK as hex string
|
||||||
|
- address: BLE address string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with resolution result.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
irk_hex = data.get('irk_hex', '')
|
||||||
|
address = data.get('address', '')
|
||||||
|
|
||||||
|
if not irk_hex or not address:
|
||||||
|
return jsonify({'error': 'irk_hex and address are required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
irk = bytes.fromhex(irk_hex)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
||||||
|
|
||||||
|
if len(irk) != 16:
|
||||||
|
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
|
||||||
|
|
||||||
|
result = resolve_rpa(irk, address)
|
||||||
|
return jsonify({
|
||||||
|
'resolved': result,
|
||||||
|
'irk_hex': irk_hex,
|
||||||
|
'address': address,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/environment', methods=['POST'])
|
||||||
|
def set_environment():
|
||||||
|
"""Update the environment on the active session."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'error': 'no active session'}), 400
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
env_str = data.get('environment', '').upper()
|
||||||
|
try:
|
||||||
|
environment = Environment[env_str]
|
||||||
|
except KeyError:
|
||||||
|
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||||
|
|
||||||
|
custom_exponent = data.get('custom_exponent')
|
||||||
|
if custom_exponent is not None:
|
||||||
|
try:
|
||||||
|
custom_exponent = float(custom_exponent)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
custom_exponent = None
|
||||||
|
|
||||||
|
session.set_environment(environment, custom_exponent)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'updated',
|
||||||
|
'environment': environment.name,
|
||||||
|
'path_loss_exponent': session.estimator.n,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/debug', methods=['GET'])
|
||||||
|
def debug_matching():
|
||||||
|
"""Debug endpoint showing scanner devices and match results."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'error': 'no session'})
|
||||||
|
|
||||||
|
scanner = session._scanner
|
||||||
|
if not scanner:
|
||||||
|
return jsonify({'error': 'no scanner'})
|
||||||
|
|
||||||
|
devices = scanner.get_devices(max_age_seconds=30)
|
||||||
|
return jsonify({
|
||||||
|
'target': session.target.to_dict(),
|
||||||
|
'device_count': len(devices),
|
||||||
|
'devices': [
|
||||||
|
{
|
||||||
|
'device_id': d.device_id,
|
||||||
|
'address': d.address,
|
||||||
|
'name': d.name,
|
||||||
|
'rssi': d.rssi_current,
|
||||||
|
'matches': session.target.matches(d),
|
||||||
|
}
|
||||||
|
for d in devices
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/paired_irks', methods=['GET'])
|
||||||
|
def paired_irks():
|
||||||
|
"""Return paired Bluetooth devices that have IRKs."""
|
||||||
|
try:
|
||||||
|
devices = get_paired_irks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to read paired IRKs")
|
||||||
|
return jsonify({'devices': [], 'error': str(e)})
|
||||||
|
|
||||||
|
return jsonify({'devices': devices})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/clear_trail', methods=['POST'])
|
||||||
|
def clear_trail():
|
||||||
|
"""Clear the detection trail."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'no_session'})
|
||||||
|
|
||||||
|
session.clear_trail()
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
@@ -38,8 +39,26 @@ logger = logging.getLogger('intercept.controller')
|
|||||||
|
|
||||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
|
||||||
# Multi-agent data queue for combined SSE stream
|
# Multi-agent SSE fanout state (per-client queues).
|
||||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||||
|
_agent_stream_subscribers_lock = threading.Lock()
|
||||||
|
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_agent_data(payload: dict) -> None:
|
||||||
|
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
subscribers = tuple(_agent_stream_subscribers)
|
||||||
|
|
||||||
|
for subscriber in subscribers:
|
||||||
|
try:
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
subscriber.get_nowait()
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -625,19 +644,16 @@ def ingest_push_data():
|
|||||||
received_at=data.get('received_at')
|
received_at=data.get('received_at')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Emit to SSE stream
|
# Emit to SSE stream (fanout to all connected clients)
|
||||||
try:
|
_broadcast_agent_data({
|
||||||
agent_data_queue.put_nowait({
|
'type': 'agent_data',
|
||||||
'type': 'agent_data',
|
'agent_id': agent['id'],
|
||||||
'agent_id': agent['id'],
|
'agent_name': agent_name,
|
||||||
'agent_name': agent_name,
|
'scan_type': data.get('scan_type'),
|
||||||
'scan_type': data.get('scan_type'),
|
'interface': data.get('interface'),
|
||||||
'interface': data.get('interface'),
|
'payload': data.get('payload'),
|
||||||
'payload': data.get('payload'),
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
'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({
|
return jsonify({
|
||||||
'status': 'accepted',
|
'status': 'accepted',
|
||||||
@@ -681,20 +697,28 @@ def stream_all_agents():
|
|||||||
This endpoint streams push data as it arrives from agents.
|
This endpoint streams push data as it arrives from agents.
|
||||||
Each message is tagged with agent_id and agent_name.
|
Each message is tagged with agent_id and agent_name.
|
||||||
"""
|
"""
|
||||||
|
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
_agent_stream_subscribers.add(client_queue)
|
||||||
|
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
while True:
|
try:
|
||||||
try:
|
while True:
|
||||||
msg = agent_data_queue.get(timeout=1.0)
|
try:
|
||||||
last_keepalive = time.time()
|
msg = client_queue.get(timeout=1.0)
|
||||||
yield format_sse(msg)
|
last_keepalive = time.time()
|
||||||
except queue.Empty:
|
yield format_sse(msg)
|
||||||
now = time.time()
|
except queue.Empty:
|
||||||
if now - last_keepalive >= keepalive_interval:
|
now = time.time()
|
||||||
yield format_sse({'type': 'keepalive'})
|
if now - last_keepalive >= keepalive_interval:
|
||||||
last_keepalive = now
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
finally:
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
_agent_stream_subscribers.discard(client_queue)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
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 sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||||
@@ -735,24 +735,19 @@ def stream_dmr_audio() -> Response:
|
|||||||
@dmr_bp.route('/stream')
|
@dmr_bp.route('/stream')
|
||||||
def stream_dmr() -> Response:
|
def stream_dmr() -> Response:
|
||||||
"""SSE stream for DMR decoder events."""
|
"""SSE stream for DMR decoder events."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('dmr', msg, msg.get('type'))
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
|
||||||
last_keepalive = time.time()
|
|
||||||
try:
|
|
||||||
process_event('dmr', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
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 = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=dmr_queue,
|
||||||
|
channel_key='dmr',
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from utils.database import (
|
|||||||
get_dsc_alert_summary,
|
get_dsc_alert_summary,
|
||||||
)
|
)
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
@@ -518,26 +518,19 @@ def stop_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stream')
|
@dsc_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
"""SSE stream for real-time DSC messages."""
|
"""SSE stream for real-time DSC messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('dsc', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.dsc_queue.get(timeout=1)
|
source_queue=app_module.dsc_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='dsc',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('dsc', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -4,19 +4,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import queue
|
import queue
|
||||||
import time
|
import time
|
||||||
from typing import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.sse import format_sse
|
|
||||||
from utils.gps import (
|
from utils.gps import (
|
||||||
get_gps_reader,
|
|
||||||
start_gpsd,
|
|
||||||
stop_gps,
|
|
||||||
get_current_position,
|
|
||||||
GPSPosition,
|
GPSPosition,
|
||||||
|
GPSSkyData,
|
||||||
|
detect_gps_devices,
|
||||||
|
get_current_position,
|
||||||
|
get_gps_reader,
|
||||||
|
is_gpsd_running,
|
||||||
|
start_gpsd,
|
||||||
|
start_gpsd_daemon,
|
||||||
|
stop_gps,
|
||||||
|
stop_gpsd_daemon,
|
||||||
)
|
)
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -29,12 +34,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
def _position_callback(position: GPSPosition) -> None:
|
def _position_callback(position: GPSPosition) -> None:
|
||||||
"""Callback to queue position updates for SSE stream."""
|
"""Callback to queue position updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_gps_queue.put_nowait(position.to_dict())
|
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Discard oldest if queue is full
|
# Discard oldest if queue is full
|
||||||
try:
|
try:
|
||||||
_gps_queue.get_nowait()
|
_gps_queue.get_nowait()
|
||||||
_gps_queue.put_nowait(position.to_dict())
|
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sky_callback(sky: GPSSkyData) -> None:
|
||||||
|
"""Callback to queue sky data updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_gps_queue.get_nowait()
|
||||||
|
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -45,36 +62,44 @@ def auto_connect_gps():
|
|||||||
Automatically connect to gpsd if available.
|
Automatically connect to gpsd if available.
|
||||||
|
|
||||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||||
|
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||||
Returns current status if already connected.
|
Returns current status if already connected.
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': position is not None,
|
'has_fix': position is not None,
|
||||||
'position': position.to_dict() if position else None
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Try to connect to gpsd on localhost:2947
|
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
port = 2947
|
port = 2947
|
||||||
|
|
||||||
# First check if gpsd is reachable
|
# If gpsd isn't running, try to detect a device and start it
|
||||||
try:
|
if not is_gpsd_running(host, port):
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
devices = detect_gps_devices()
|
||||||
sock.settimeout(1.0)
|
if not devices:
|
||||||
sock.connect((host, port))
|
return jsonify({
|
||||||
sock.close()
|
'status': 'unavailable',
|
||||||
except Exception:
|
'message': 'No GPS device detected'
|
||||||
return jsonify({
|
})
|
||||||
'status': 'unavailable',
|
|
||||||
'message': 'gpsd not running'
|
# Try to start gpsd with the first detected device
|
||||||
})
|
device_path = devices[0]['path']
|
||||||
|
success, msg = start_gpsd_daemon(device_path, host, port)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': msg,
|
||||||
|
'devices': devices,
|
||||||
|
})
|
||||||
|
logger.info(f"Auto-started gpsd on {device_path}")
|
||||||
|
|
||||||
# Clear the queue
|
# Clear the queue
|
||||||
while not _gps_queue.empty():
|
while not _gps_queue.empty():
|
||||||
@@ -84,14 +109,17 @@ def auto_connect_gps():
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Start the gpsd client
|
# Start the gpsd client
|
||||||
success = start_gpsd(host, port, callback=_position_callback)
|
success = start_gpsd(host, port,
|
||||||
|
callback=_position_callback,
|
||||||
|
sky_callback=_sky_callback)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': False,
|
'has_fix': False,
|
||||||
'position': None
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -100,14 +128,26 @@ def auto_connect_gps():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/devices')
|
||||||
|
def list_gps_devices():
|
||||||
|
"""List detected GPS serial devices."""
|
||||||
|
devices = detect_gps_devices()
|
||||||
|
return jsonify({
|
||||||
|
'devices': devices,
|
||||||
|
'gpsd_running': is_gpsd_running(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stop', methods=['POST'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS client."""
|
"""Stop GPS client and gpsd daemon if we started it."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader:
|
if reader:
|
||||||
reader.remove_callback(_position_callback)
|
reader.remove_callback(_position_callback)
|
||||||
|
reader.remove_sky_callback(_sky_callback)
|
||||||
|
|
||||||
stop_gps()
|
stop_gps()
|
||||||
|
stop_gpsd_daemon()
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -122,15 +162,18 @@ def get_gps_status():
|
|||||||
'running': False,
|
'running': False,
|
||||||
'device': None,
|
'device': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
'error': None,
|
'error': None,
|
||||||
'message': 'GPS client not started'
|
'message': 'GPS client not started'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': reader.is_running,
|
'running': reader.is_running,
|
||||||
'device': reader.device_path,
|
'device': reader.device_path,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||||
'error': reader.error,
|
'error': reader.error,
|
||||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||||
@@ -161,25 +204,42 @@ def get_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/satellites')
|
||||||
|
def get_satellites():
|
||||||
|
"""Get current satellite sky view data."""
|
||||||
|
reader = get_gps_reader()
|
||||||
|
|
||||||
|
if not reader or not reader.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'GPS client not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
sky = reader.sky
|
||||||
|
if sky:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'sky': sky.to_dict()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'waiting',
|
||||||
|
'message': 'Waiting for satellite data'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@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 and sky updates."""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0
|
source_queue=_gps_queue,
|
||||||
|
channel_key='gps',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
position = _gps_queue.get(timeout=1)
|
),
|
||||||
last_keepalive = time.time()
|
mimetype='text/event-stream',
|
||||||
yield format_sse({'type': 'position', **position})
|
)
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
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 sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
@@ -1182,25 +1182,19 @@ def scanner_status() -> Response:
|
|||||||
@listening_post_bp.route('/scanner/stream')
|
@listening_post_bp.route('/scanner/stream')
|
||||||
def stream_scanner_events() -> Response:
|
def stream_scanner_events() -> Response:
|
||||||
"""SSE stream for scanner events."""
|
"""SSE stream for scanner events."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('listening_scanner', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=scanner_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='listening_scanner',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('listening_scanner', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
@@ -1834,24 +1828,19 @@ def stop_waterfall() -> Response:
|
|||||||
@listening_post_bp.route('/waterfall/stream')
|
@listening_post_bp.route('/waterfall/stream')
|
||||||
def stream_waterfall() -> Response:
|
def stream_waterfall() -> Response:
|
||||||
"""SSE stream for waterfall data."""
|
"""SSE stream for waterfall data."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('waterfall', msg, msg.get('type'))
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
|
||||||
last_keepalive = time.time()
|
|
||||||
try:
|
|
||||||
process_event('waterfall', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
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 = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=waterfall_queue,
|
||||||
|
channel_key='listening_waterfall',
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from typing import Generator
|
|||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.meshtastic import (
|
from utils.meshtastic import (
|
||||||
get_meshtastic_client,
|
get_meshtastic_client,
|
||||||
start_meshtastic,
|
start_meshtastic,
|
||||||
@@ -469,22 +469,15 @@ def stream_messages():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0
|
source_queue=_mesh_queue,
|
||||||
|
channel_key='meshtastic',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
msg = _mesh_queue.get(timeout=1)
|
),
|
||||||
last_keepalive = time.time()
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
@@ -1051,3 +1044,19 @@ def request_store_forward():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': error or 'Failed to request S&F history'
|
'message': error or 'Failed to request S&F history'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/topology')
|
||||||
|
def mesh_topology():
|
||||||
|
"""Return mesh network topology graph."""
|
||||||
|
if not is_meshtastic_available():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||||
|
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'topology': client.get_topology(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
@@ -540,28 +540,19 @@ def toggle_logging() -> Response:
|
|||||||
|
|
||||||
@pager_bp.route('/stream')
|
@pager_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
import json
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('pager', msg, msg.get('type'))
|
||||||
|
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
source_queue=app_module.output_queue,
|
||||||
|
channel_key='pager',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
on_message=_on_msg,
|
||||||
last_keepalive = time.time()
|
),
|
||||||
try:
|
mimetype='text/event-stream',
|
||||||
process_event('pager', msg, msg.get('type'))
|
)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, send_file
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
@@ -107,3 +108,59 @@ def download_recording(session_id: str):
|
|||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=file_path.name,
|
download_name=file_path.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/<session_id>/events', methods=['GET'])
|
||||||
|
def get_recording_events(session_id: str):
|
||||||
|
"""Return parsed events from a recording for in-app replay."""
|
||||||
|
manager = get_recording_manager()
|
||||||
|
rec = manager.get_recording(session_id)
|
||||||
|
if not rec:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||||
|
|
||||||
|
file_path = Path(rec['file_path'])
|
||||||
|
try:
|
||||||
|
resolved_root = RECORDING_ROOT.resolve()
|
||||||
|
resolved_file = file_path.resolve()
|
||||||
|
if resolved_root not in resolved_file.parents:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||||
|
|
||||||
|
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
||||||
|
offset = max(0, request.args.get('offset', default=0, type=int))
|
||||||
|
|
||||||
|
events: list[dict] = []
|
||||||
|
seen = 0
|
||||||
|
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
|
||||||
|
for idx, line in enumerate(fh):
|
||||||
|
if idx < offset:
|
||||||
|
continue
|
||||||
|
if seen >= limit:
|
||||||
|
break
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
seen += 1
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'recording': {
|
||||||
|
'id': rec['id'],
|
||||||
|
'mode': rec['mode'],
|
||||||
|
'started_at': rec['started_at'],
|
||||||
|
'stopped_at': rec['stopped_at'],
|
||||||
|
'event_count': rec['event_count'],
|
||||||
|
},
|
||||||
|
'offset': offset,
|
||||||
|
'limit': limit,
|
||||||
|
'returned': len(events),
|
||||||
|
'events': events,
|
||||||
|
})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from utils.logging import sensor_logger as logger
|
|||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
|
||||||
@@ -288,26 +288,19 @@ def stop_rtlamr() -> Response:
|
|||||||
|
|
||||||
@rtlamr_bp.route('/stream_rtlamr')
|
@rtlamr_bp.route('/stream_rtlamr')
|
||||||
def stream_rtlamr() -> Response:
|
def stream_rtlamr() -> Response:
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('rtlamr', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
source_queue=app_module.rtlamr_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='rtlamr',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('rtlamr', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response
|
|||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
|
from utils.database import (
|
||||||
|
get_tracked_satellites,
|
||||||
|
add_tracked_satellite,
|
||||||
|
bulk_add_tracked_satellites,
|
||||||
|
update_tracked_satellite,
|
||||||
|
remove_tracked_satellite,
|
||||||
|
)
|
||||||
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
|
||||||
|
|
||||||
@@ -31,12 +38,32 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_db_satellites_into_cache():
|
||||||
|
"""Load user-tracked satellites from DB into the TLE cache."""
|
||||||
|
global _tle_cache
|
||||||
|
try:
|
||||||
|
db_sats = get_tracked_satellites()
|
||||||
|
loaded = 0
|
||||||
|
for sat in db_sats:
|
||||||
|
if sat['tle_line1'] and sat['tle_line2']:
|
||||||
|
# Use a cache key derived from name (sanitised)
|
||||||
|
cache_key = sat['name'].replace(' ', '-').upper()
|
||||||
|
if cache_key not in _tle_cache:
|
||||||
|
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
|
||||||
|
loaded += 1
|
||||||
|
if loaded:
|
||||||
|
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
||||||
|
|
||||||
|
|
||||||
def init_tle_auto_refresh():
|
def init_tle_auto_refresh():
|
||||||
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
def _auto_refresh_tle():
|
def _auto_refresh_tle():
|
||||||
try:
|
try:
|
||||||
|
_load_db_satellites_into_cache()
|
||||||
updated = refresh_tle_data()
|
updated = refresh_tle_data()
|
||||||
if updated:
|
if updated:
|
||||||
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
||||||
@@ -543,3 +570,75 @@ def fetch_celestrak(category):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
|
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tracked Satellites CRUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked', methods=['GET'])
|
||||||
|
def list_tracked_satellites():
|
||||||
|
"""Return all tracked satellites from the database."""
|
||||||
|
enabled_only = request.args.get('enabled', '').lower() == 'true'
|
||||||
|
sats = get_tracked_satellites(enabled_only=enabled_only)
|
||||||
|
return jsonify({'status': 'success', 'satellites': sats})
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked', methods=['POST'])
|
||||||
|
def add_tracked_satellites_endpoint():
|
||||||
|
"""Add one or more tracked satellites."""
|
||||||
|
global _tle_cache
|
||||||
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
|
# Accept a single satellite dict or a list
|
||||||
|
sat_list = data if isinstance(data, list) else [data]
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
for sat in sat_list:
|
||||||
|
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||||
|
name = sat.get('name', '')
|
||||||
|
if not norad_id or not name:
|
||||||
|
continue
|
||||||
|
tle1 = sat.get('tle_line1', sat.get('tle1'))
|
||||||
|
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||||
|
enabled = sat.get('enabled', True)
|
||||||
|
|
||||||
|
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
# Also inject into TLE cache if we have TLE data
|
||||||
|
if tle1 and tle2:
|
||||||
|
cache_key = name.replace(' ', '-').upper()
|
||||||
|
_tle_cache[cache_key] = (name, tle1, tle2)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'added': added,
|
||||||
|
'satellites': get_tracked_satellites(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
|
||||||
|
def update_tracked_satellite_endpoint(norad_id):
|
||||||
|
"""Update the enabled state of a tracked satellite."""
|
||||||
|
data = request.json or {}
|
||||||
|
enabled = data.get('enabled')
|
||||||
|
if enabled is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
|
||||||
|
|
||||||
|
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
||||||
|
if ok:
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
|
||||||
|
def delete_tracked_satellite_endpoint(norad_id):
|
||||||
|
"""Remove a tracked satellite by NORAD ID."""
|
||||||
|
ok, msg = remove_tracked_satellite(str(norad_id))
|
||||||
|
if ok:
|
||||||
|
return jsonify({'status': 'success', 'message': msg})
|
||||||
|
status_code = 403 if 'builtin' in msg.lower() else 404
|
||||||
|
return jsonify({'status': 'error', 'message': msg}), status_code
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
@@ -28,6 +28,10 @@ sensor_bp = Blueprint('sensor', __name__)
|
|||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sensor_active_device: int | None = None
|
sensor_active_device: int | None = None
|
||||||
|
|
||||||
|
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||||
|
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||||
|
_MAX_RSSI_HISTORY = 60
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
@@ -45,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
|
# Track RSSI history per device
|
||||||
|
_model = data.get('model', '')
|
||||||
|
_dev_id = data.get('id', '')
|
||||||
|
_rssi_val = data.get('rssi')
|
||||||
|
if _rssi_val is not None and _model:
|
||||||
|
_hist_key = f"{_model}_{_dev_id}"
|
||||||
|
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||||
|
hist.append((time.time(), float(_rssi_val)))
|
||||||
|
if len(hist) > _MAX_RSSI_HISTORY:
|
||||||
|
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||||
|
|
||||||
# Push scope event when signal level data is present
|
# Push scope event when signal level data is present
|
||||||
rssi = data.get('rssi')
|
rssi = data.get('rssi')
|
||||||
snr = data.get('snr')
|
snr = data.get('snr')
|
||||||
@@ -199,10 +214,16 @@ def start_sensor() -> Response:
|
|||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
# Monitor stderr
|
# Monitor stderr
|
||||||
|
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||||
|
_stderr_noise = (
|
||||||
|
'bitbuffer_add_bit',
|
||||||
|
'row count limit',
|
||||||
|
)
|
||||||
|
|
||||||
def monitor_stderr():
|
def monitor_stderr():
|
||||||
for line in app_module.sensor_process.stderr:
|
for line in app_module.sensor_process.stderr:
|
||||||
err = line.decode('utf-8', errors='replace').strip()
|
err = line.decode('utf-8', errors='replace').strip()
|
||||||
if err:
|
if err and not any(noise in err for noise in _stderr_noise):
|
||||||
logger.debug(f"[rtl_433] {err}")
|
logger.debug(f"[rtl_433] {err}")
|
||||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||||
|
|
||||||
@@ -253,27 +274,29 @@ def stop_sensor() -> Response:
|
|||||||
|
|
||||||
@sensor_bp.route('/stream_sensor')
|
@sensor_bp.route('/stream_sensor')
|
||||||
def stream_sensor() -> Response:
|
def stream_sensor() -> Response:
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sensor', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
source_queue=app_module.sensor_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='sensor',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('sensor', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@sensor_bp.route('/sensor/rssi_history')
|
||||||
|
def get_rssi_history() -> Response:
|
||||||
|
"""Return RSSI history for all tracked sensor devices."""
|
||||||
|
result = {}
|
||||||
|
for key, entries in sensor_rssi_history.items():
|
||||||
|
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||||
|
return jsonify({'status': 'success', 'devices': result})
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.space_weather')
|
||||||
|
|
||||||
|
space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TTL Cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# Cache TTLs in seconds
|
||||||
|
TTL_REALTIME = 300 # 5 min for real-time data
|
||||||
|
TTL_FORECAST = 1800 # 30 min for forecasts
|
||||||
|
TTL_DAILY = 3600 # 1 hr for daily summaries
|
||||||
|
TTL_IMAGE = 600 # 10 min for images
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str) -> Any | None:
|
||||||
|
entry = _cache.get(key)
|
||||||
|
if entry and time.time() < entry['expires']:
|
||||||
|
return entry['data']
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(key: str, data: Any, ttl: int) -> None:
|
||||||
|
_cache[key] = {'data': data, 'expires': time.time() + ttl}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TIMEOUT = 15 # seconds
|
||||||
|
|
||||||
|
SWPC_BASE = 'https://services.swpc.noaa.gov'
|
||||||
|
SWPC_JSON = f'{SWPC_BASE}/products'
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.read().decode()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.read()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data source fetchers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _fetch_json(url)
|
||||||
|
if data is not None:
|
||||||
|
_cache_set(cache_key, data, ttl)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_kp_index() -> Any | None:
|
||||||
|
return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_kp_forecast() -> Any | None:
|
||||||
|
return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_scales() -> Any | None:
|
||||||
|
return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_flux() -> Any | None:
|
||||||
|
return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_alerts() -> Any | None:
|
||||||
|
return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_solar_wind_plasma() -> Any | None:
|
||||||
|
return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_solar_wind_mag() -> Any | None:
|
||||||
|
return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_xrays() -> Any | None:
|
||||||
|
return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_xray_flares() -> Any | None:
|
||||||
|
return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_flare_probability() -> Any | None:
|
||||||
|
return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_solar_regions() -> Any | None:
|
||||||
|
return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_sunspot_report() -> Any | None:
|
||||||
|
return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
|
||||||
|
"""Parse HamQSL solar XML into a dict of band conditions."""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
solar = root.find('.//solardata')
|
||||||
|
if solar is None:
|
||||||
|
return None
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
# Scalar fields
|
||||||
|
for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots',
|
||||||
|
'heliumline', 'protonflux', 'electonflux', 'aurora',
|
||||||
|
'normalization', 'latdegree', 'solarwind', 'magneticfield',
|
||||||
|
'calculatedconditions', 'calculatedvhfconditions',
|
||||||
|
'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'):
|
||||||
|
el = solar.find(tag)
|
||||||
|
if el is not None and el.text:
|
||||||
|
result[tag] = el.text.strip()
|
||||||
|
# Band conditions
|
||||||
|
bands: list[dict[str, str]] = []
|
||||||
|
for band_el in solar.findall('.//calculatedconditions/band'):
|
||||||
|
bands.append({
|
||||||
|
'name': band_el.get('name', ''),
|
||||||
|
'time': band_el.get('time', ''),
|
||||||
|
'condition': band_el.text.strip() if band_el.text else ''
|
||||||
|
})
|
||||||
|
result['bands'] = bands
|
||||||
|
# VHF conditions
|
||||||
|
vhf: list[dict[str, str]] = []
|
||||||
|
for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'):
|
||||||
|
vhf.append({
|
||||||
|
'name': phen_el.get('name', ''),
|
||||||
|
'location': phen_el.get('location', ''),
|
||||||
|
'condition': phen_el.text.strip() if phen_el.text else ''
|
||||||
|
})
|
||||||
|
result['vhf'] = vhf
|
||||||
|
return result
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
logger.warning('Failed to parse HamQSL XML: %s', exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_band_conditions() -> dict[str, Any] | None:
|
||||||
|
cached = _cache_get('band_conditions')
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php')
|
||||||
|
if xml_text is None:
|
||||||
|
return None
|
||||||
|
data = _parse_hamqsl_xml(xml_text)
|
||||||
|
if data is not None:
|
||||||
|
_cache_set('band_conditions', data, TTL_FORECAST)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image proxy whitelist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IMAGE_WHITELIST: dict[str, dict[str, str]] = {
|
||||||
|
# D-RAP absorption maps
|
||||||
|
'drap_global': {
|
||||||
|
'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_5': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f05.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_10': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f10.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_15': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f15.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_20': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f20.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_25': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f25.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_30': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f30.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
# Aurora forecast
|
||||||
|
'aurora_north': {
|
||||||
|
'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
# SDO solar imagery
|
||||||
|
'sdo_193': {
|
||||||
|
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
'sdo_304': {
|
||||||
|
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
'sdo_magnetogram': {
|
||||||
|
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@space_weather_bp.route('/data')
|
||||||
|
def get_data():
|
||||||
|
"""Return aggregated space weather data from all sources."""
|
||||||
|
data = {
|
||||||
|
'kp_index': _fetch_kp_index(),
|
||||||
|
'kp_forecast': _fetch_kp_forecast(),
|
||||||
|
'scales': _fetch_scales(),
|
||||||
|
'flux': _fetch_flux(),
|
||||||
|
'alerts': _fetch_alerts(),
|
||||||
|
'solar_wind_plasma': _fetch_solar_wind_plasma(),
|
||||||
|
'solar_wind_mag': _fetch_solar_wind_mag(),
|
||||||
|
'xrays': _fetch_xrays(),
|
||||||
|
'xray_flares': _fetch_xray_flares(),
|
||||||
|
'flare_probability': _fetch_flare_probability(),
|
||||||
|
'solar_regions': _fetch_solar_regions(),
|
||||||
|
'sunspot_report': _fetch_sunspot_report(),
|
||||||
|
'band_conditions': _fetch_band_conditions(),
|
||||||
|
'timestamp': time.time(),
|
||||||
|
}
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@space_weather_bp.route('/image/<key>')
|
||||||
|
def get_image(key: str):
|
||||||
|
"""Proxy and cache whitelisted space weather images."""
|
||||||
|
entry = IMAGE_WHITELIST.get(key)
|
||||||
|
if not entry:
|
||||||
|
return jsonify({'error': 'Unknown image key'}), 404
|
||||||
|
|
||||||
|
cache_key = f'img_{key}'
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return Response(cached, content_type=entry['content_type'],
|
||||||
|
headers={'Cache-Control': 'public, max-age=300'})
|
||||||
|
|
||||||
|
img_data = _fetch_bytes(entry['url'])
|
||||||
|
if img_data is None:
|
||||||
|
return jsonify({'error': 'Failed to fetch image'}), 502
|
||||||
|
|
||||||
|
_cache_set(cache_key, img_data, TTL_IMAGE)
|
||||||
|
return Response(img_data, content_type=entry['content_type'],
|
||||||
|
headers={'Cache-Control': 'public, max-age=300'})
|
||||||
@@ -15,7 +15,7 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
|
|
||||||
import app as app_module
|
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 sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
@@ -27,6 +27,12 @@ logger = get_logger('intercept.sstv')
|
|||||||
|
|
||||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||||
|
|
||||||
|
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
||||||
|
# can type nearby values and still land on the canonical center frequency.
|
||||||
|
ISS_SSTV_MODULATION = 'fm'
|
||||||
|
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
||||||
|
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||||
|
|
||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
@@ -46,6 +52,14 @@ def _progress_callback(data: dict) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
||||||
|
"""Snap near-match user input to a supported ISS SSTV center frequency."""
|
||||||
|
for supported in ISS_SSTV_FREQUENCIES:
|
||||||
|
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
|
||||||
|
return supported
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/status')
|
@sstv_bp.route('/status')
|
||||||
def get_status():
|
def get_status():
|
||||||
"""
|
"""
|
||||||
@@ -62,6 +76,7 @@ def get_status():
|
|||||||
'decoder': decoder.decoder_available,
|
'decoder': decoder.decoder_available,
|
||||||
'running': decoder.is_running,
|
'running': decoder.is_running,
|
||||||
'iss_frequency': ISS_SSTV_FREQ,
|
'iss_frequency': ISS_SSTV_FREQ,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'image_count': len(decoder.get_images()),
|
'image_count': len(decoder.get_images()),
|
||||||
'doppler_enabled': decoder.doppler_enabled,
|
'doppler_enabled': decoder.doppler_enabled,
|
||||||
}
|
}
|
||||||
@@ -82,6 +97,7 @@ def start_decoder():
|
|||||||
JSON body (optional):
|
JSON body (optional):
|
||||||
{
|
{
|
||||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||||
|
"modulation": "fm", // ISS mode is FM-only
|
||||||
"device": 0, // RTL-SDR device index
|
"device": 0, // RTL-SDR device index
|
||||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||||
@@ -106,6 +122,7 @@ def start_decoder():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'frequency': ISS_SSTV_FREQ,
|
'frequency': ISS_SSTV_FREQ,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,18 +136,29 @@ def start_decoder():
|
|||||||
# Get parameters
|
# Get parameters
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||||
|
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
latitude = data.get('latitude')
|
latitude = data.get('latitude')
|
||||||
longitude = data.get('longitude')
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
|
# Validate modulation (ISS mode is FM-only)
|
||||||
|
if modulation != ISS_SSTV_MODULATION:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Validate frequency
|
# Validate frequency
|
||||||
try:
|
try:
|
||||||
frequency = float(frequency)
|
frequency = float(frequency)
|
||||||
if not (100 <= frequency <= 500): # VHF range
|
normalized_frequency = _normalize_iss_frequency(frequency)
|
||||||
|
if normalized_frequency is None:
|
||||||
|
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Frequency must be between 100-500 MHz'
|
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||||
}), 400
|
}), 400
|
||||||
|
frequency = normalized_frequency
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -178,7 +206,8 @@ def start_decoder():
|
|||||||
frequency=frequency,
|
frequency=frequency,
|
||||||
device_index=device_index,
|
device_index=device_index,
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude
|
longitude=longitude,
|
||||||
|
modulation=ISS_SSTV_MODULATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -187,6 +216,7 @@ def start_decoder():
|
|||||||
result = {
|
result = {
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'device': device_index,
|
'device': device_index,
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
}
|
}
|
||||||
@@ -392,26 +422,19 @@ def stream_progress():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sstv', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
progress = _sstv_queue.get(timeout=1)
|
source_queue=_sstv_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='sstv',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('sstv', progress, progress.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(progress)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
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 sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
@@ -27,6 +28,9 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
|
|||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
_sstv_general_active_device: int | None = None
|
||||||
|
|
||||||
# Predefined SSTV frequencies
|
# Predefined SSTV frequencies
|
||||||
SSTV_FREQUENCIES = [
|
SSTV_FREQUENCIES = [
|
||||||
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
||||||
@@ -40,7 +44,7 @@ SSTV_FREQUENCIES = [
|
|||||||
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||||
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
{'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||||
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -150,6 +154,17 @@ def start_decoder():
|
|||||||
'message': 'Modulation must be fm, usb, or lsb',
|
'message': 'Modulation must be fm, usb, or lsb',
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
global _sstv_general_active_device
|
||||||
|
device_int = int(device_index)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'sstv_general')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error,
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Set callback and start
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
success = decoder.start(
|
success = decoder.start(
|
||||||
@@ -159,6 +174,7 @@ def start_decoder():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
_sstv_general_active_device = device_int
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
@@ -166,6 +182,7 @@ def start_decoder():
|
|||||||
'device': device_index,
|
'device': device_index,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder',
|
'message': 'Failed to start decoder',
|
||||||
@@ -175,8 +192,14 @@ def start_decoder():
|
|||||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoder():
|
def stop_decoder():
|
||||||
"""Stop general SSTV decoder."""
|
"""Stop general SSTV decoder."""
|
||||||
|
global _sstv_general_active_device
|
||||||
decoder = get_general_sstv_decoder()
|
decoder = get_general_sstv_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
|
if _sstv_general_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_sstv_general_active_device)
|
||||||
|
_sstv_general_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -266,26 +289,19 @@ def delete_all_images():
|
|||||||
@sstv_general_bp.route('/stream')
|
@sstv_general_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""SSE stream of SSTV decode progress."""
|
"""SSE stream of SSTV decode progress."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sstv_general', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
progress = _sstv_general_queue.get(timeout=1)
|
source_queue=_sstv_general_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='sstv_general',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('sstv_general', progress, progress.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(progress)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ from utils.tscm.device_identity import (
|
|||||||
ingest_wifi_dict,
|
ingest_wifi_dict,
|
||||||
)
|
)
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
# Import unified Bluetooth scanner helper for TSCM integration
|
# Import unified Bluetooth scanner helper for TSCM integration
|
||||||
try:
|
try:
|
||||||
@@ -629,24 +630,17 @@ def sweep_status():
|
|||||||
@tscm_bp.route('/sweep/stream')
|
@tscm_bp.route('/sweep/stream')
|
||||||
def sweep_stream():
|
def sweep_stream():
|
||||||
"""SSE stream for real-time sweep updates."""
|
"""SSE stream for real-time sweep updates."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
while True:
|
process_event('tscm', msg, msg.get('type'))
|
||||||
try:
|
|
||||||
if tscm_queue:
|
|
||||||
msg = tscm_queue.get(timeout=1)
|
|
||||||
try:
|
|
||||||
process_event('tscm', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield f"data: {json.dumps(msg)}\n\n"
|
|
||||||
else:
|
|
||||||
time.sleep(1)
|
|
||||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
|
||||||
except queue.Empty:
|
|
||||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
generate(),
|
sse_stream_fanout(
|
||||||
|
source_queue=tscm_queue,
|
||||||
|
channel_key='tscm',
|
||||||
|
timeout=1.0,
|
||||||
|
keepalive_interval=30.0,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
headers={
|
headers={
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""VDL2 aircraft datalink routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import pty
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
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_device_index, validate_gain, validate_ppm
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
)
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
|
||||||
|
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
||||||
|
|
||||||
|
# Default VDL2 frequencies (MHz) - common worldwide
|
||||||
|
DEFAULT_VDL2_FREQUENCIES = [
|
||||||
|
'136975000', # Primary worldwide
|
||||||
|
'136725000', # Europe
|
||||||
|
'136775000', # Europe
|
||||||
|
'136800000', # Multi-region
|
||||||
|
'136875000', # Multi-region
|
||||||
|
]
|
||||||
|
|
||||||
|
# Message counter for statistics
|
||||||
|
vdl2_message_count = 0
|
||||||
|
vdl2_last_message_time = None
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
vdl2_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def find_dumpvdl2():
|
||||||
|
"""Find dumpvdl2 binary."""
|
||||||
|
return shutil.which('dumpvdl2')
|
||||||
|
|
||||||
|
|
||||||
|
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||||
|
"""Stream dumpvdl2 JSON output to queue."""
|
||||||
|
global vdl2_message_count, vdl2_last_message_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||||
|
sentinel = '' if is_text_mode else b''
|
||||||
|
for line in iter(process.stdout.readline, sentinel):
|
||||||
|
if is_text_mode:
|
||||||
|
line = line.strip()
|
||||||
|
else:
|
||||||
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
|
||||||
|
# Add our metadata
|
||||||
|
data['type'] = 'vdl2'
|
||||||
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
vdl2_message_count += 1
|
||||||
|
vdl2_last_message_time = time.time()
|
||||||
|
|
||||||
|
app_module.vdl2_queue.put(data)
|
||||||
|
|
||||||
|
# Feed flight correlator
|
||||||
|
try:
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
get_flight_correlator().add_vdl2_message(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Log if enabled
|
||||||
|
if app_module.logging_enabled:
|
||||||
|
try:
|
||||||
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON - could be status message
|
||||||
|
if line:
|
||||||
|
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"VDL2 stream error: {e}")
|
||||||
|
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||||
|
finally:
|
||||||
|
global vdl2_active_device
|
||||||
|
# Ensure process is terminated
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(process)
|
||||||
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
app_module.vdl2_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/tools')
|
||||||
|
def check_vdl2_tools() -> Response:
|
||||||
|
"""Check for VDL2 decoding tools."""
|
||||||
|
has_dumpvdl2 = find_dumpvdl2() is not None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'dumpvdl2': has_dumpvdl2,
|
||||||
|
'ready': has_dumpvdl2
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/status')
|
||||||
|
def vdl2_status() -> Response:
|
||||||
|
"""Get VDL2 decoder status."""
|
||||||
|
running = False
|
||||||
|
if app_module.vdl2_process:
|
||||||
|
running = app_module.vdl2_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'message_count': vdl2_message_count,
|
||||||
|
'last_message_time': vdl2_last_message_time,
|
||||||
|
'queue_size': app_module.vdl2_queue.qsize()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/start', methods=['POST'])
|
||||||
|
def start_vdl2() -> Response:
|
||||||
|
"""Start VDL2 decoder."""
|
||||||
|
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||||
|
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'VDL2 decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check for dumpvdl2
|
||||||
|
dumpvdl2_path = find_dumpvdl2()
|
||||||
|
if not dumpvdl2_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
ppm = validate_ppm(data.get('ppm', '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, 'vdl2')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
vdl2_active_device = device_int
|
||||||
|
|
||||||
|
# Get frequencies - use provided or defaults
|
||||||
|
# dumpvdl2 expects frequencies in Hz (integers)
|
||||||
|
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
|
||||||
|
if isinstance(frequencies, str):
|
||||||
|
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.vdl2_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.vdl2_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reset stats
|
||||||
|
vdl2_message_count = 0
|
||||||
|
vdl2_last_message_time = None
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
|
# Build dumpvdl2 command
|
||||||
|
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
||||||
|
cmd = [dumpvdl2_path]
|
||||||
|
cmd.extend(['--output', 'decoded:json:file:path=-'])
|
||||||
|
|
||||||
|
if is_soapy:
|
||||||
|
# SoapySDR device
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device_str = builder._build_device_string(sdr_device)
|
||||||
|
cmd.extend(['--soapysdr', device_str])
|
||||||
|
else:
|
||||||
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
|
|
||||||
|
# Add gain
|
||||||
|
if gain and str(gain) != '0':
|
||||||
|
cmd.extend(['--gain', str(gain)])
|
||||||
|
|
||||||
|
# Add PPM correction if specified
|
||||||
|
if ppm and str(ppm) != '0':
|
||||||
|
cmd.extend(['--correction', str(ppm)])
|
||||||
|
|
||||||
|
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
|
||||||
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
|
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_text_mode = False
|
||||||
|
|
||||||
|
# On macOS, use pty to avoid stdout buffering issues
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
os.close(slave_fd)
|
||||||
|
# Wrap master_fd as a text file for line-buffered reading
|
||||||
|
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||||
|
is_text_mode = True
|
||||||
|
else:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait briefly to check if process started
|
||||||
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
|
if process.poll() is not None:
|
||||||
|
# Process died - release device
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
stderr = ''
|
||||||
|
if process.stderr:
|
||||||
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
|
error_msg = 'dumpvdl2 failed to start'
|
||||||
|
if stderr:
|
||||||
|
error_msg += f': {stderr[:200]}'
|
||||||
|
logger.error(error_msg)
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
|
app_module.vdl2_process = process
|
||||||
|
register_process(process)
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=stream_vdl2_output,
|
||||||
|
args=(process, is_text_mode),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequencies': frequencies,
|
||||||
|
'device': device,
|
||||||
|
'gain': gain
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_vdl2() -> Response:
|
||||||
|
"""Stop VDL2 decoder."""
|
||||||
|
global vdl2_active_device
|
||||||
|
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
if not app_module.vdl2_process:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'VDL2 decoder not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.vdl2_process.terminate()
|
||||||
|
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app_module.vdl2_process.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping VDL2: {e}")
|
||||||
|
|
||||||
|
app_module.vdl2_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/stream')
|
||||||
|
def stream_vdl2() -> Response:
|
||||||
|
"""SSE stream for VDL2 messages."""
|
||||||
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('vdl2', msg, msg.get('type'))
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=app_module.vdl2_queue,
|
||||||
|
channel_key='vdl2',
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/frequencies')
|
||||||
|
def get_frequencies() -> Response:
|
||||||
|
"""Get default VDL2 frequencies."""
|
||||||
|
return jsonify({
|
||||||
|
'default': DEFAULT_VDL2_FREQUENCIES,
|
||||||
|
'regions': {
|
||||||
|
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
||||||
|
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
||||||
|
'asia_pacific': ['136975000', '136900000'],
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -566,14 +566,21 @@ def enable_schedule():
|
|||||||
scheduler = get_weather_sat_scheduler()
|
scheduler = get_weather_sat_scheduler()
|
||||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||||
|
|
||||||
result = scheduler.enable(
|
try:
|
||||||
lat=lat,
|
result = scheduler.enable(
|
||||||
lon=lon,
|
lat=lat,
|
||||||
min_elevation=min_elev,
|
lon=lon,
|
||||||
device=device,
|
min_elevation=min_elev,
|
||||||
gain=gain_val,
|
device=device,
|
||||||
bias_t=bool(data.get('bias_t', False)),
|
gain=gain_val,
|
||||||
)
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to enable weather sat scheduler")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to enable scheduler'
|
||||||
|
}), 500
|
||||||
|
|
||||||
return jsonify({'status': 'ok', **result})
|
return jsonify({'status': 'ok', **result})
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from utils.dependencies import check_tool, get_tool_path
|
|||||||
from utils.logging import wifi_logger as logger
|
from utils.logging import wifi_logger as logger
|
||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse, sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -1135,26 +1135,19 @@ def get_wifi_networks():
|
|||||||
@wifi_bp.route('/stream')
|
@wifi_bp.route('/stream')
|
||||||
def stream_wifi():
|
def stream_wifi():
|
||||||
"""SSE stream for WiFi events."""
|
"""SSE stream for WiFi events."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('wifi', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.wifi_queue.get(timeout=1)
|
source_queue=app_module.wifi_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='wifi',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('wifi', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
@@ -1557,23 +1550,15 @@ def v2_deauth_stream():
|
|||||||
- deauth_error: An error occurred
|
- deauth_error: An error occurred
|
||||||
- keepalive: Periodic keepalive
|
- keepalive: Periodic keepalive
|
||||||
"""
|
"""
|
||||||
def generate():
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
source_queue=app_module.deauth_detector_queue,
|
||||||
|
channel_key='wifi_deauth',
|
||||||
while True:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
try:
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
# Try to get from the dedicated deauth queue
|
),
|
||||||
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ need_sudo() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refresh sudo credential cache so long-running builds don't trigger
|
||||||
|
# mid-compilation password prompts (which can fail due to TTY issues
|
||||||
|
# inside subshells). Safe to call multiple times.
|
||||||
|
refresh_sudo() {
|
||||||
|
[[ -z "${SUDO:-}" ]] && return 0
|
||||||
|
sudo -v 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
detect_os() {
|
detect_os() {
|
||||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||||
OS="macos"
|
OS="macos"
|
||||||
@@ -218,6 +226,7 @@ check_tools() {
|
|||||||
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
|
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
|
||||||
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_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||||
echo
|
echo
|
||||||
@@ -304,28 +313,40 @@ install_python_deps() {
|
|||||||
|
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
local PIP="venv/bin/python -m pip"
|
||||||
|
local PY="venv/bin/python"
|
||||||
|
|
||||||
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
$PIP install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||||
ok "Upgraded pip tooling"
|
ok "Upgraded pip tooling"
|
||||||
|
|
||||||
progress "Installing Python dependencies"
|
progress "Installing Python dependencies"
|
||||||
# Try pip install, but don't fail if apt packages already satisfied deps
|
|
||||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
|
||||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
|
||||||
# Verify critical packages are available
|
|
||||||
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
|
||||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
|
||||||
echo "Try: pip install flask requests flask-limiter"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
ok "Core Python dependencies available"
|
|
||||||
else
|
|
||||||
ok "Python dependencies installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility)
|
# Install critical packages first to avoid all-or-nothing failures
|
||||||
# System apt packages may have older Flask 2.x which is incompatible
|
# (C extension packages like scipy/numpy can fail on newer Python versions
|
||||||
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
|
# and cause pip to roll back pure-Python packages like flask)
|
||||||
|
info "Installing core packages..."
|
||||||
|
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||||
|
"Werkzeug>=3.1.5" "pyserial>=3.5" "flask-sock" "websocket-client>=1.6.0" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Verify critical packages
|
||||||
|
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||||
|
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||||
|
echo "Try: venv/bin/pip install flask requests flask-limiter"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
ok "Core Python packages installed"
|
||||||
|
|
||||||
|
# Install optional packages individually (some may fail on newer Python)
|
||||||
|
info "Installing optional packages..."
|
||||||
|
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
|
||||||
|
"bleak>=0.21.0" "psycopg2-binary>=2.9.9" "meshtastic>=2.0.0" \
|
||||||
|
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0"; do
|
||||||
|
pkg_name="${pkg%%>=*}"
|
||||||
|
if ! $PIP install "$pkg" 2>/dev/null; then
|
||||||
|
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ok "Optional packages processed"
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +409,7 @@ install_rtlamr_from_source() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
else
|
else
|
||||||
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
@@ -430,7 +451,8 @@ install_multimon_ng_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
else
|
else
|
||||||
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
fi
|
fi
|
||||||
ok "multimon-ng installed successfully from source"
|
ok "multimon-ng installed successfully from source"
|
||||||
)
|
)
|
||||||
@@ -471,7 +493,8 @@ install_dsd_from_source() {
|
|||||||
if [[ -w /usr/local/lib ]]; then
|
if [[ -w /usr/local/lib ]]; then
|
||||||
make install >/dev/null 2>&1
|
make install >/dev/null 2>&1
|
||||||
else
|
else
|
||||||
sudo make install >/dev/null 2>&1
|
refresh_sudo
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO make install >/dev/null 2>&1
|
$SUDO make install >/dev/null 2>&1
|
||||||
@@ -507,7 +530,8 @@ install_dsd_from_source() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
else
|
else
|
||||||
sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO make install >/dev/null 2>&1 \
|
$SUDO make install >/dev/null 2>&1 \
|
||||||
@@ -545,7 +569,8 @@ install_dump1090_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 dump1090 /usr/local/bin/dump1090
|
install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
else
|
else
|
||||||
sudo install -m 0755 dump1090 /usr/local/bin/dump1090
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
fi
|
fi
|
||||||
ok "dump1090 installed successfully from source"
|
ok "dump1090 installed successfully from source"
|
||||||
else
|
else
|
||||||
@@ -571,18 +596,129 @@ install_acarsdec_from_source_macos() {
|
|||||||
|| { warn "Failed to clone acarsdec"; exit 1; }
|
|| { warn "Failed to clone acarsdec"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/acarsdec"
|
cd "$tmp_dir/acarsdec"
|
||||||
|
|
||||||
|
# Fix compiler flags for macOS Apple Silicon (ARM64)
|
||||||
|
# -march=native can fail with Apple Clang on M-series chips
|
||||||
|
# -Ofast is deprecated in modern Clang
|
||||||
|
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||||
|
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
|
||||||
|
info "Patched compiler flags for Apple Silicon (arm64)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fix pthread_tryjoin_np (Linux-only GNU extension) for macOS
|
||||||
|
# Replace with pthread_join which provides equivalent behavior
|
||||||
|
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
||||||
|
sed -i '' 's/pthread_tryjoin_np(\([^,]*\), NULL)/pthread_join(\1, NULL)/g' rtl.c
|
||||||
|
info "Patched pthread_tryjoin_np for macOS compatibility"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fix libacars linking on macOS (upstream issue #112)
|
||||||
|
# Use LIBACARS_LINK_LIBRARIES (full path) instead of LIBACARS_LIBRARIES (name only)
|
||||||
|
if grep -q 'LIBACARS_LIBRARIES' CMakeLists.txt 2>/dev/null; then
|
||||||
|
sed -i '' 's/${LIBACARS_LIBRARIES}/${LIBACARS_LINK_LIBRARIES}/g' CMakeLists.txt
|
||||||
|
info "Patched libacars linking for macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p build && cd build
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
# Set Homebrew paths for Apple Silicon (/opt/homebrew) or Intel (/usr/local)
|
||||||
|
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||||
|
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||||
|
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
|
||||||
|
|
||||||
info "Compiling acarsdec..."
|
info "Compiling acarsdec..."
|
||||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
build_log="$tmp_dir/acarsdec-build.log"
|
||||||
|
if cmake .. -Drtl=ON \
|
||||||
|
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
|
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||||
|
>"$build_log" 2>&1 \
|
||||||
|
&& make >>"$build_log" 2>&1; then
|
||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
else
|
else
|
||||||
sudo install -m 0755 acarsdec /usr/local/bin/acarsdec
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
fi
|
fi
|
||||||
ok "acarsdec installed successfully from source"
|
ok "acarsdec installed successfully from source"
|
||||||
else
|
else
|
||||||
warn "Failed to build acarsdec. ACARS decoding will not be available."
|
warn "Failed to build acarsdec. ACARS decoding will not be available."
|
||||||
|
warn "Build log (last 30 lines):"
|
||||||
|
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dumpvdl2_from_source_macos() {
|
||||||
|
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||||
|
|
||||||
|
brew_install cmake
|
||||||
|
brew_install librtlsdr
|
||||||
|
brew_install pkg-config
|
||||||
|
brew_install glib
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||||
|
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||||
|
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
|
||||||
|
|
||||||
|
# Build libacars first
|
||||||
|
info "Cloning libacars..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone libacars"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/libacars"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling libacars..."
|
||||||
|
build_log="$tmp_dir/libacars-build.log"
|
||||||
|
if cmake .. \
|
||||||
|
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||||
|
>"$build_log" 2>&1 \
|
||||||
|
&& make >>"$build_log" 2>&1; then
|
||||||
|
if [[ -w /usr/local/lib ]]; then
|
||||||
|
make install >>"$build_log" 2>&1
|
||||||
|
else
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO make install >>"$build_log" 2>&1
|
||||||
|
fi
|
||||||
|
ok "libacars installed"
|
||||||
|
else
|
||||||
|
warn "Failed to build libacars."
|
||||||
|
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build dumpvdl2
|
||||||
|
info "Cloning dumpvdl2..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/dumpvdl2"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling dumpvdl2..."
|
||||||
|
build_log="$tmp_dir/dumpvdl2-build.log"
|
||||||
|
if cmake .. \
|
||||||
|
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||||
|
>"$build_log" 2>&1 \
|
||||||
|
&& make >>"$build_log" 2>&1; then
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||||
|
else
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||||
|
fi
|
||||||
|
ok "dumpvdl2 installed successfully from source"
|
||||||
|
else
|
||||||
|
warn "Failed to build dumpvdl2. VDL2 decoding will not be available."
|
||||||
|
warn "Build log (last 30 lines):"
|
||||||
|
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -611,7 +747,8 @@ install_aiscatcher_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||||
else
|
else
|
||||||
sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||||
fi
|
fi
|
||||||
ok "AIS-catcher installed successfully from source"
|
ok "AIS-catcher installed successfully from source"
|
||||||
else
|
else
|
||||||
@@ -623,11 +760,23 @@ install_aiscatcher_from_source_macos() {
|
|||||||
install_satdump_from_source_debian() {
|
install_satdump_from_source_debian() {
|
||||||
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||||
|
|
||||||
|
# Core deps — hard-fail if missing
|
||||||
apt_install build-essential git cmake pkg-config \
|
apt_install build-essential git cmake pkg-config \
|
||||||
libpng-dev libtiff-dev libjemalloc-dev libvolk-dev libnng-dev \
|
libpng-dev libtiff-dev libzstd-dev \
|
||||||
libzstd-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev \
|
|
||||||
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
||||||
|
|
||||||
|
# libvolk: package name differs between distros
|
||||||
|
# Ubuntu / Debian Trixie+: libvolk-dev
|
||||||
|
# Raspberry Pi OS Bookworm / Debian Bookworm: libvolk2-dev
|
||||||
|
apt_try_install_any libvolk-dev libvolk2-dev \
|
||||||
|
|| warn "libvolk not found — SatDump will build without VOLK acceleration"
|
||||||
|
|
||||||
|
# Optional SDR hardware libs — soft-fail so missing hardware doesn't abort
|
||||||
|
for pkg in libjemalloc-dev libnng-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev; do
|
||||||
|
$SUDO apt-get install -y --no-install-recommends "$pkg" >/dev/null 2>&1 \
|
||||||
|
|| warn "${pkg} not available — skipping (SatDump can build without it)"
|
||||||
|
done
|
||||||
|
|
||||||
# Run in subshell to isolate EXIT trap
|
# Run in subshell to isolate EXIT trap
|
||||||
(
|
(
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
@@ -638,11 +787,42 @@ install_satdump_from_source_debian() {
|
|||||||
|| { warn "Failed to clone SatDump"; exit 1; }
|
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/SatDump"
|
cd "$tmp_dir/SatDump"
|
||||||
|
|
||||||
|
# Patch: fix deprecated std::allocator usage for newer compilers
|
||||||
|
# GCC 13+ errors on deprecated allocator members in sol2.
|
||||||
|
# Pragmas must go in lua_utils.cpp (the instantiation site), not sol.hpp (definition site).
|
||||||
|
lua_utils="src-core/common/lua/lua_utils.cpp"
|
||||||
|
if [ -f "$lua_utils" ]; then
|
||||||
|
{
|
||||||
|
echo '#pragma GCC diagnostic push'
|
||||||
|
echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
|
||||||
|
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
|
||||||
|
cat "$lua_utils"
|
||||||
|
echo # ensure the file ends with a newline before the closing pragma
|
||||||
|
echo '#pragma GCC diagnostic pop'
|
||||||
|
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p build && cd build
|
mkdir -p build && cd build
|
||||||
|
|
||||||
info "Compiling SatDump (this may take a while)..."
|
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
||||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >/dev/null 2>&1 \
|
build_log="$tmp_dir/satdump-build.log"
|
||||||
&& make -j "$(nproc)" >/dev/null 2>&1; then
|
|
||||||
|
# Show periodic progress while building so the user knows it's not hung
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
sleep 30
|
||||||
|
if [ -f "$build_log" ]; then
|
||||||
|
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
|
||||||
|
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
progress_pid=$!
|
||||||
|
|
||||||
|
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >"$build_log" 2>&1 \
|
||||||
|
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
|
||||||
|
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||||
$SUDO make install >/dev/null 2>&1
|
$SUDO make install >/dev/null 2>&1
|
||||||
$SUDO ldconfig
|
$SUDO ldconfig
|
||||||
|
|
||||||
@@ -659,54 +839,86 @@ install_satdump_from_source_debian() {
|
|||||||
|
|
||||||
ok "SatDump installed successfully."
|
ok "SatDump installed successfully."
|
||||||
else
|
else
|
||||||
|
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||||
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||||
|
warn "Build log (last 30 lines):"
|
||||||
|
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
install_satdump_from_source_macos() {
|
install_satdump_macos() {
|
||||||
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
info "Installing SatDump v1.2.2 from pre-built release (weather satellite decoder)..."
|
||||||
|
|
||||||
brew_install cmake
|
# Determine architecture
|
||||||
brew_install libpng
|
local arch
|
||||||
brew_install libtiff
|
arch="$(uname -m)"
|
||||||
brew_install jemalloc
|
local dmg_name
|
||||||
brew_install libvolk
|
if [ "$arch" = "arm64" ]; then
|
||||||
brew_install nng
|
dmg_name="SatDump-macOS-Silicon.dmg"
|
||||||
brew_install zstd
|
else
|
||||||
brew_install soapysdr
|
dmg_name="SatDump-macOS-Intel.dmg"
|
||||||
brew_install hackrf
|
fi
|
||||||
brew_install fftw
|
|
||||||
|
local dmg_url="https://github.com/SatDump/SatDump/releases/download/1.2.2/${dmg_name}"
|
||||||
|
local install_dir="/usr/local/lib/satdump"
|
||||||
|
|
||||||
# Run in subshell to isolate EXIT trap
|
# Run in subshell to isolate EXIT trap
|
||||||
(
|
(
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap 'hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null || true; rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
info "Cloning SatDump v1.2.2..."
|
info "Downloading ${dmg_name}..."
|
||||||
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
if ! curl -sL -o "$tmp_dir/satdump.dmg" "$dmg_url"; then
|
||||||
|| { warn "Failed to clone SatDump"; exit 1; }
|
warn "Failed to download SatDump. Weather satellite decoding will not be available."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$tmp_dir/SatDump"
|
info "Installing SatDump..."
|
||||||
mkdir -p build && cd build
|
# Mount the DMG
|
||||||
|
hdiutil attach "$tmp_dir/satdump.dmg" -nobrowse -quiet -mountpoint "$tmp_dir/mnt" \
|
||||||
|
|| { warn "Failed to mount SatDump DMG"; exit 1; }
|
||||||
|
|
||||||
info "Compiling SatDump (this may take a while)..."
|
local app_dir="$tmp_dir/mnt/SatDump.app"
|
||||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >/dev/null 2>&1 \
|
if [ ! -d "$app_dir" ]; then
|
||||||
&& make -j "$(sysctl -n hw.ncpu)" >/dev/null 2>&1; then
|
warn "SatDump.app not found in DMG"
|
||||||
if [[ -w /usr/local/bin ]]; then
|
exit 1
|
||||||
make install >/dev/null 2>&1
|
fi
|
||||||
else
|
|
||||||
sudo make install >/dev/null 2>&1
|
# Install: copy app contents to /usr/local/lib/satdump
|
||||||
fi
|
refresh_sudo
|
||||||
ok "SatDump installed successfully."
|
$SUDO mkdir -p "$install_dir"
|
||||||
|
$SUDO cp -R "$app_dir/Contents/MacOS/"* "$install_dir/"
|
||||||
|
$SUDO cp -R "$app_dir/Contents/Resources/"* "$install_dir/"
|
||||||
|
|
||||||
|
# Create wrapper script so satdump can find its resources via @executable_path
|
||||||
|
$SUDO tee /usr/local/bin/satdump >/dev/null <<'WRAPPER'
|
||||||
|
#!/bin/sh
|
||||||
|
exec /usr/local/lib/satdump/satdump "$@"
|
||||||
|
WRAPPER
|
||||||
|
$SUDO chmod +x /usr/local/bin/satdump
|
||||||
|
|
||||||
|
hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if /usr/local/lib/satdump/satdump 2>&1 | grep -q "Usage"; then
|
||||||
|
ok "SatDump v1.2.2 installed successfully."
|
||||||
else
|
else
|
||||||
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
warn "SatDump installed but may not work correctly."
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=19
|
need_sudo
|
||||||
|
|
||||||
|
# Prime sudo credentials upfront so builds don't prompt mid-compilation
|
||||||
|
if [[ -n "${SUDO:-}" ]]; then
|
||||||
|
info "Some tools require sudo to install. You may be prompted for your password."
|
||||||
|
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOTAL_STEPS=22
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -779,6 +991,13 @@ install_macos_packages() {
|
|||||||
ok "acarsdec already installed"
|
ok "acarsdec already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
progress "Installing dumpvdl2"
|
||||||
|
if ! cmd_exists dumpvdl2; then
|
||||||
|
install_dumpvdl2_from_source_macos || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||||
|
else
|
||||||
|
ok "dumpvdl2 already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing AIS-catcher"
|
progress "Installing AIS-catcher"
|
||||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||||
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
||||||
@@ -791,7 +1010,7 @@ install_macos_packages() {
|
|||||||
echo
|
echo
|
||||||
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||||
if ask_yes_no "Do you want to install SatDump?"; then
|
if ask_yes_no "Do you want to install SatDump?"; then
|
||||||
install_satdump_from_source_macos || warn "SatDump build failed. Weather satellite decoding will not be available."
|
install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available."
|
||||||
else
|
else
|
||||||
warn "Skipping SatDump installation. You can install it later if needed."
|
warn "Skipping SatDump installation. You can install it later if needed."
|
||||||
fi
|
fi
|
||||||
@@ -872,10 +1091,13 @@ install_dump1090_from_source_debian() {
|
|||||||
librtlsdr-dev libusb-1.0-0-dev \
|
librtlsdr-dev libusb-1.0-0-dev \
|
||||||
libncurses-dev tcl-dev python3-dev
|
libncurses-dev tcl-dev python3-dev
|
||||||
|
|
||||||
|
local JOBS
|
||||||
|
JOBS="$(nproc 2>/dev/null || echo 1)"
|
||||||
|
|
||||||
# Run in subshell to isolate EXIT trap
|
# Run in subshell to isolate EXIT trap
|
||||||
(
|
(
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap '{ [[ -n "${progress_pid:-}" ]] && kill "$progress_pid" 2>/dev/null && wait "$progress_pid" 2>/dev/null || true; }; rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
info "Cloning FlightAware dump1090..."
|
info "Cloning FlightAware dump1090..."
|
||||||
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||||
@@ -883,23 +1105,45 @@ install_dump1090_from_source_debian() {
|
|||||||
|
|
||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
# Remove -Werror to prevent build failures on newer GCC versions
|
# 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
|
sed -i 's/-Werror//g' Makefile 2>/dev/null || true
|
||||||
info "Compiling FlightAware dump1090..."
|
info "Compiling FlightAware dump1090 (using ${JOBS} CPU cores)..."
|
||||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
build_log="$tmp_dir/dump1090-build.log"
|
||||||
|
|
||||||
|
(while true; do sleep 20; printf " [*] Still compiling dump1090...\n"; done) &
|
||||||
|
progress_pid=$!
|
||||||
|
|
||||||
|
if make -j "$JOBS" BLADERF=no RTLSDR=yes >"$build_log" 2>&1; then
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
ok "dump1090 installed successfully (FlightAware)."
|
ok "dump1090 installed successfully (FlightAware)."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
||||||
|
warn "Build log (last 20 lines):"
|
||||||
|
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
|
||||||
rm -rf "$tmp_dir/dump1090"
|
rm -rf "$tmp_dir/dump1090"
|
||||||
git clone --depth 1 https://github.com/wiedehopf/readsb.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 wiedehopf/readsb"; exit 1; }
|
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
info "Compiling readsb..."
|
info "Compiling readsb (using ${JOBS} CPU cores)..."
|
||||||
make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; }
|
build_log="$tmp_dir/readsb-build.log"
|
||||||
|
|
||||||
|
(while true; do sleep 20; printf " [*] Still compiling readsb...\n"; done) &
|
||||||
|
progress_pid=$!
|
||||||
|
|
||||||
|
if ! make -j "$JOBS" RTLSDR=yes >"$build_log" 2>&1; then
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
|
warn "Build log (last 20 lines):"
|
||||||
|
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
fail "Failed to build readsb from source (required)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
||||||
ok "dump1090 installed successfully (via readsb)."
|
ok "dump1090 installed successfully (via readsb)."
|
||||||
)
|
)
|
||||||
@@ -924,7 +1168,7 @@ install_acarsdec_from_source_debian() {
|
|||||||
mkdir -p build && cd build
|
mkdir -p build && cd build
|
||||||
|
|
||||||
info "Compiling acarsdec..."
|
info "Compiling acarsdec..."
|
||||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
if cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
ok "acarsdec installed successfully."
|
ok "acarsdec installed successfully."
|
||||||
else
|
else
|
||||||
@@ -933,6 +1177,52 @@ install_acarsdec_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_dumpvdl2_from_source_debian() {
|
||||||
|
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake \
|
||||||
|
librtlsdr-dev libusb-1.0-0-dev libglib2.0-dev libxml2-dev
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
# Build libacars first
|
||||||
|
info "Cloning libacars..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone libacars"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/libacars"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling libacars..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
$SUDO ldconfig
|
||||||
|
ok "libacars installed"
|
||||||
|
else
|
||||||
|
warn "Failed to build libacars."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build dumpvdl2
|
||||||
|
info "Cloning dumpvdl2..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/dumpvdl2"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling dumpvdl2..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||||
|
ok "dumpvdl2 installed successfully."
|
||||||
|
else
|
||||||
|
warn "Failed to build dumpvdl2 from source. VDL2 decoding will not be available."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_aiscatcher_from_source_debian() {
|
install_aiscatcher_from_source_debian() {
|
||||||
info "AIS-catcher not available via APT. Building from source..."
|
info "AIS-catcher not available via APT. Building from source..."
|
||||||
|
|
||||||
@@ -1025,6 +1315,17 @@ install_rtlsdr_blog_drivers_debian() {
|
|||||||
$SUDO udevadm trigger || true
|
$SUDO udevadm trigger || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Make the Blog drivers' library take priority over the apt-installed
|
||||||
|
# librtlsdr. Removing apt packages is too destructive (dump1090-mutability
|
||||||
|
# and other tools depend on librtlsdr0 and get swept out). Instead,
|
||||||
|
# prepend /usr/local/lib to ldconfig's search path — files named 00-*
|
||||||
|
# sort before the distro's aarch64-linux-gnu.conf — so ldconfig lists
|
||||||
|
# /usr/local/lib/librtlsdr.so.0 first and the dynamic linker uses it.
|
||||||
|
if [[ -d /etc/ld.so.conf.d ]]; then
|
||||||
|
echo '/usr/local/lib' | $SUDO tee /etc/ld.so.conf.d/00-local-first.conf >/dev/null
|
||||||
|
fi
|
||||||
|
$SUDO ldconfig
|
||||||
|
|
||||||
ok "RTL-SDR Blog drivers installed successfully."
|
ok "RTL-SDR Blog drivers installed successfully."
|
||||||
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
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."
|
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
||||||
@@ -1034,6 +1335,7 @@ install_rtlsdr_blog_drivers_debian() {
|
|||||||
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_udev_rules_debian() {
|
setup_udev_rules_debian() {
|
||||||
@@ -1058,24 +1360,35 @@ blacklist_kernel_drivers_debian() {
|
|||||||
|
|
||||||
if [[ -f "$blacklist_file" ]]; then
|
if [[ -f "$blacklist_file" ]]; then
|
||||||
ok "RTL-SDR kernel driver blacklist already present"
|
ok "RTL-SDR kernel driver blacklist already present"
|
||||||
return 0
|
else
|
||||||
fi
|
info "Blacklisting conflicting DVB kernel drivers..."
|
||||||
|
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||||
info "Blacklisting conflicting DVB kernel drivers..."
|
|
||||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
|
||||||
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
||||||
blacklist dvb_usb_rtl28xxu
|
blacklist dvb_usb_rtl28xxu
|
||||||
blacklist rtl2832
|
blacklist rtl2832
|
||||||
blacklist rtl2830
|
blacklist rtl2830
|
||||||
blacklist r820t
|
blacklist r820t
|
||||||
EOF
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
# Unload modules if currently loaded
|
# Always unload modules if currently loaded — this must happen even on
|
||||||
|
# re-runs where the blacklist file already exists, since the modules may
|
||||||
|
# still be live from the current boot (e.g. blacklist wasn't in initramfs).
|
||||||
|
local unloaded=false
|
||||||
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
||||||
if lsmod | grep -q "^$mod"; then
|
if lsmod | grep -q "^$mod"; then
|
||||||
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
||||||
|
unloaded=true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
$unloaded && info "Unloaded conflicting DVB kernel modules from current session."
|
||||||
|
|
||||||
|
# Bake the blacklist into the initramfs so it survives reboots on
|
||||||
|
# Raspberry Pi OS / Debian (without this the modules can reload on boot).
|
||||||
|
if cmd_exists update-initramfs; then
|
||||||
|
info "Updating initramfs to persist driver blacklist across reboots..."
|
||||||
|
$SUDO update-initramfs -u >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
||||||
echo
|
echo
|
||||||
@@ -1096,7 +1409,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=26
|
TOTAL_STEPS=28
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1138,12 +1451,18 @@ install_debian_packages() {
|
|||||||
|
|
||||||
apt_install_if_missing rtl-sdr
|
apt_install_if_missing rtl-sdr
|
||||||
|
|
||||||
progress "RTL-SDR Blog drivers"
|
progress "RTL-SDR Blog drivers (V4 support)"
|
||||||
if cmd_exists rtl_test; then
|
if $IS_DRAGONOS; then
|
||||||
ok "RTL-SDR drivers already installed"
|
info "DragonOS: skipping RTL-SDR Blog driver install (pre-configured)."
|
||||||
else
|
else
|
||||||
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
echo
|
||||||
install_rtlsdr_blog_drivers_debian
|
info "RTL-SDR Blog drivers add V4 (R828D tuner) support and bias-tee improvements."
|
||||||
|
info "They are backward-compatible with all RTL-SDR devices."
|
||||||
|
if ask_yes_no "Install RTL-SDR Blog drivers? (recommended for V4 users, safe for all)" "y"; then
|
||||||
|
install_rtlsdr_blog_drivers_debian
|
||||||
|
else
|
||||||
|
warn "Skipping RTL-SDR Blog drivers. V4 devices may not work correctly."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
progress "Installing multimon-ng"
|
progress "Installing multimon-ng"
|
||||||
@@ -1233,12 +1552,21 @@ install_debian_packages() {
|
|||||||
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
||||||
|
|
||||||
progress "Installing dump1090"
|
progress "Installing dump1090"
|
||||||
|
# Remove any stale symlink left from a previous run where dump1090-mutability
|
||||||
|
# was later uninstalled — cmd_exists finds the broken symlink and skips the
|
||||||
|
# real install, leaving dump1090 seemingly present but non-functional.
|
||||||
|
local dump1090_path
|
||||||
|
dump1090_path="$(command -v dump1090 2>/dev/null || true)"
|
||||||
|
if [[ -n "$dump1090_path" ]] && [[ ! -x "$dump1090_path" ]]; then
|
||||||
|
info "Removing broken dump1090 symlink: $dump1090_path"
|
||||||
|
$SUDO rm -f "$dump1090_path"
|
||||||
|
fi
|
||||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||||
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
|
||||||
@@ -1249,6 +1577,13 @@ install_debian_packages() {
|
|||||||
fi
|
fi
|
||||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||||
|
|
||||||
|
progress "Installing dumpvdl2"
|
||||||
|
if ! cmd_exists dumpvdl2; then
|
||||||
|
install_dumpvdl2_from_source_debian || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||||
|
else
|
||||||
|
ok "dumpvdl2 already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing AIS-catcher"
|
progress "Installing AIS-catcher"
|
||||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||||
install_aiscatcher_from_source_debian
|
install_aiscatcher_from_source_debian
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
@@ -27,12 +27,36 @@
|
|||||||
--radar-bg: #101823;
|
--radar-bg: #101823;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-dark: #f4f7fb;
|
||||||
|
--bg-panel: #e9eef5;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--border-color: #d1d9e6;
|
||||||
|
--border-glow: #1f5fa8;
|
||||||
|
--text-primary: #122034;
|
||||||
|
--text-secondary: #3a4a5f;
|
||||||
|
--text-dim: #6b7c93;
|
||||||
|
--accent-green: #1f8a57;
|
||||||
|
--accent-cyan: #1f5fa8;
|
||||||
|
--accent-orange: #b5863a;
|
||||||
|
--accent-red: #c74444;
|
||||||
|
--accent-yellow: #b5863a;
|
||||||
|
--accent-amber: #b5863a;
|
||||||
|
--grid-line: rgba(31, 95, 168, 0.12);
|
||||||
|
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
--radar-cyan: #1f5fa8;
|
||||||
|
--radar-bg: #e9eef5;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
height: 100dvh;
|
||||||
overflow-x: hidden;
|
height: 100vh; /* Fallback */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated radar sweep background */
|
/* Animated radar sweep background */
|
||||||
@@ -227,16 +251,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid - Mobile first */
|
/* Main dashboard grid - Mobile first */
|
||||||
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
|
|
||||||
.dashboard {
|
.dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100dvh - 160px);
|
flex: 1;
|
||||||
height: calc(100vh - 160px); /* Fallback */
|
min-height: 0;
|
||||||
min-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet: Two-column layout */
|
/* Tablet: Two-column layout */
|
||||||
@@ -249,13 +271,29 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: Full layout with ACARS */
|
/* Desktop: Full layout with ACARS/VDL2 + map + sidebar */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
grid-template-columns: auto 1fr 300px;
|
grid-template-columns: auto 1fr 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left sidebars wrapper (ACARS + VDL2) */
|
||||||
|
.left-sidebars {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.left-sidebars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ACARS sidebar (left of map) - Collapsible */
|
/* ACARS sidebar (left of map) - Collapsible */
|
||||||
.acars-sidebar {
|
.acars-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -267,12 +305,10 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show ACARS sidebar on desktop */
|
/* Show ACARS sidebar inside wrapper */
|
||||||
@media (min-width: 1024px) {
|
.left-sidebars .acars-sidebar {
|
||||||
.acars-sidebar {
|
display: flex;
|
||||||
display: flex;
|
height: 100%;
|
||||||
max-height: calc(100dvh - 160px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.acars-collapse-btn {
|
.acars-collapse-btn {
|
||||||
@@ -419,6 +455,335 @@ body {
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* VDL2 sidebar (left of map, after ACARS) - Collapsible */
|
||||||
|
.vdl2-sidebar {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show VDL2 sidebar inside wrapper */
|
||||||
|
.left-sidebars .vdl2-sidebar {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-collapse-btn {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-collapse-btn:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-collapse-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar.collapsed .vdl2-collapse-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar:not(.collapsed) .vdl2-collapse-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#vdl2CollapseIcon {
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar.collapsed #vdl2CollapseIcon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar-content {
|
||||||
|
width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.3s ease, opacity 0.2s ease;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar.collapsed .vdl2-sidebar-content {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .panel::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .panel-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar #vdl2PanelContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-info,
|
||||||
|
.vdl2-sidebar .vdl2-controls {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn {
|
||||||
|
background: var(--accent-green);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn:hover {
|
||||||
|
background: #1db954;
|
||||||
|
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn.active {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn.active:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-message-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 10px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-message-item:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VDL2 Message Modal */
|
||||||
|
.vdl2-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: vdl2ModalFadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vdl2ModalFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal {
|
||||||
|
background: var(--bg-panel, #1a1a2e);
|
||||||
|
border: 1px solid var(--accent-cyan, #4a9eff);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 520px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 1px var(--accent-cyan, #4a9eff);
|
||||||
|
animation: vdl2ModalSlideIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vdl2ModalSlideIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-close:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: var(--accent-red, #ef4444);
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-section-title {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-field-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-field-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-msg-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-raw-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-raw-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-raw-json {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Panels */
|
/* Panels */
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
@@ -495,6 +860,8 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -526,42 +893,6 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#radarOverlayCanvas {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 500;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarOverlayCanvas.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarScope {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: none;
|
|
||||||
background: var(--radar-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarScope.active {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarCanvas {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right sidebar - Mobile first */
|
/* Right sidebar - Mobile first */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -588,51 +919,21 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View toggle */
|
|
||||||
.view-toggle {
|
|
||||||
display: flex;
|
|
||||||
padding: 10px;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: 'Orbitron', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn:hover {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn.active {
|
|
||||||
background: var(--accent-cyan);
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selected aircraft panel */
|
/* Selected aircraft panel */
|
||||||
.selected-aircraft {
|
.selected-aircraft {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 480px;
|
max-height: 280px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-height: 900px) {
|
||||||
|
.selected-aircraft {
|
||||||
|
max-height: 340px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.selected-info {
|
.selected-info {
|
||||||
padding: 12px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#aircraftPhotoContainer {
|
#aircraftPhotoContainer {
|
||||||
@@ -640,7 +941,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#aircraftPhotoContainer img {
|
#aircraftPhotoContainer img {
|
||||||
max-height: 140px;
|
max-height: 100px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -649,24 +950,24 @@ body {
|
|||||||
|
|
||||||
.selected-callsign {
|
.selected-callsign {
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 15px var(--accent-cyan);
|
text-shadow: 0 0 15px var(--accent-cyan);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-grid {
|
.telemetry-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-item {
|
.telemetry-item {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 5px 8px;
|
||||||
border-left: 2px solid var(--accent-cyan);
|
border-left: 2px solid var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,9 +1077,10 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
border-top: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-bar > .control-group {
|
.controls-bar > .control-group {
|
||||||
@@ -907,6 +1209,15 @@ body {
|
|||||||
.control-group.airband-group {
|
.control-group.airband-group {
|
||||||
background: rgba(245, 158, 11, 0.05);
|
background: rgba(245, 158, 11, 0.05);
|
||||||
border-color: rgba(245, 158, 11, 0.2);
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group.airband-group > .control-group-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group.airband-group .control-group-label {
|
.control-group.airband-group .control-group-label {
|
||||||
@@ -1010,6 +1321,7 @@ body {
|
|||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -1021,6 +1333,15 @@ body {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar on controls bar */
|
||||||
|
.controls-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-bar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* No aircraft message */
|
/* No aircraft message */
|
||||||
.no-aircraft {
|
.no-aircraft {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1289,7 +1610,7 @@ body {
|
|||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-height: calc(100dvh - 160px);
|
min-height: 400px;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1489,6 +1810,10 @@ body {
|
|||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.strip-stat.source-stat .strip-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.strip-stat.session-stat {
|
.strip-stat.session-stat {
|
||||||
background: rgba(34, 197, 94, 0.05);
|
background: rgba(34, 197, 94, 0.05);
|
||||||
border-color: rgba(34, 197, 94, 0.2);
|
border-color: rgba(34, 197, 94, 0.2);
|
||||||
@@ -1779,6 +2104,9 @@ body {
|
|||||||
.strip-btn {
|
.strip-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1789,6 +2117,12 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-btn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strip-btn:hover:not(:disabled) {
|
.strip-btn:hover:not(:disabled) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
@@ -30,6 +30,27 @@
|
|||||||
--radar-bg: #101823;
|
--radar-bg: #101823;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-dark: #f4f7fb;
|
||||||
|
--bg-panel: #e9eef5;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--border-color: #d1d9e6;
|
||||||
|
--border-glow: #1f5fa8;
|
||||||
|
--text-primary: #122034;
|
||||||
|
--text-secondary: #3a4a5f;
|
||||||
|
--text-dim: #6b7c93;
|
||||||
|
--accent-green: #1f8a57;
|
||||||
|
--accent-cyan: #1f5fa8;
|
||||||
|
--accent-orange: #b5863a;
|
||||||
|
--accent-red: #c74444;
|
||||||
|
--accent-yellow: #b5863a;
|
||||||
|
--accent-amber: #b5863a;
|
||||||
|
--grid-line: rgba(31, 95, 168, 0.12);
|
||||||
|
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
--radar-cyan: #1f5fa8;
|
||||||
|
--radar-bg: #e9eef5;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
@@ -496,7 +517,7 @@ body {
|
|||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
background: rgba(74, 158, 255, 0.05);
|
background: rgba(74, 158, 255, 0.05);
|
||||||
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -568,7 +589,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-name {
|
.vessel-name {
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -662,7 +683,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-name {
|
.vessel-item-name {
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1223,7 +1244,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-header {
|
.dsc-distress-alert .dsc-alert-header {
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/* Shared UX platform components: run-state strip, command palette, setup assistant, and toasts */
|
||||||
|
|
||||||
|
.run-state-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 6px 12px 0;
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(19, 30, 44, 0.96), rgba(11, 18, 28, 0.97));
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#runStateChips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.25);
|
||||||
|
background: linear-gradient(180deg, rgba(17, 26, 38, 0.82), rgba(12, 18, 28, 0.84));
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667788;
|
||||||
|
box-shadow: 0 0 0 0 rgba(102, 119, 136, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip.running .dot {
|
||||||
|
background: var(--accent-green, #28c27a);
|
||||||
|
box-shadow: 0 0 0 4px rgba(40, 194, 122, 0.16), 0 0 12px rgba(40, 194, 122, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip.active {
|
||||||
|
border-color: rgba(74, 163, 255, 0.65);
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(74, 163, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-right {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-value {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-btn {
|
||||||
|
background: linear-gradient(180deg, rgba(17, 27, 41, 0.9), rgba(10, 16, 25, 0.92));
|
||||||
|
color: var(--accent-cyan, #4aa3ff);
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-btn:hover {
|
||||||
|
background: rgba(74, 163, 255, 0.14);
|
||||||
|
border-color: rgba(74, 163, 255, 0.7);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10vh 18px 0;
|
||||||
|
z-index: 25000;
|
||||||
|
background: rgba(4, 8, 14, 0.65);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(16, 26, 39, 0.98), rgba(10, 17, 27, 0.98));
|
||||||
|
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-list {
|
||||||
|
max-height: min(62vh, 520px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .title {
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .desc {
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .kbd {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item.active,
|
||||||
|
.command-palette-item:hover,
|
||||||
|
.command-palette-item:focus-visible {
|
||||||
|
background: rgba(74, 163, 255, 0.12);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty {
|
||||||
|
padding: 22px 16px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 26000;
|
||||||
|
background: rgba(4, 8, 14, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-modal {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
max-height: 84vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101926;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-title {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-content {
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step {
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-status {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-status.done {
|
||||||
|
color: var(--accent-green, #28c27a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
background: var(--bg-tertiary, #121f2d);
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn.primary {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent-cyan, #4aa3ff);
|
||||||
|
border-color: var(--accent-cyan, #4aa3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-footer {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-footer-note {
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 25500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: min(380px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast {
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-left: 3px solid var(--accent-cyan, #4aa3ff);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 24, 35, 0.97);
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast.error {
|
||||||
|
border-left-color: var(--accent-red, #e25d5d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast.warning {
|
||||||
|
border-left-color: var(--accent-orange, #d6a85e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-title {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-msg {
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-actions {
|
||||||
|
margin-top: 7px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-actions button {
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary, #132133);
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-actions button:hover {
|
||||||
|
border-color: rgba(74, 163, 255, 0.5);
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.run-state-strip {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.command-palette-overlay {
|
||||||
|
padding: 8vh 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-header,
|
||||||
|
.setup-content,
|
||||||
|
.setup-footer {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-stack {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,14 +28,16 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
background-image:
|
background-image:
|
||||||
|
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
||||||
|
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||||
|
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||||
var(--noise-image),
|
var(--noise-image),
|
||||||
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
|
|
||||||
linear-gradient(180deg, var(--grid-dot), transparent 35%),
|
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
|
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,11 +123,12 @@
|
|||||||
CARDS / PANELS
|
CARDS / PANELS
|
||||||
============================================ */
|
============================================ */
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
background: var(--surface-panel-gradient);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -135,8 +136,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,11 +161,12 @@
|
|||||||
|
|
||||||
/* Panel variant (used in dashboards) */
|
/* Panel variant (used in dashboards) */
|
||||||
.panel {
|
.panel {
|
||||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
background: var(--surface-panel-gradient);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (clip-path: polygon(0 0)) {
|
@supports (clip-path: polygon(0 0)) {
|
||||||
@@ -190,8 +192,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -722,7 +724,20 @@
|
|||||||
|
|
||||||
.card:hover,
|
.card:hover,
|
||||||
.panel:hover {
|
.panel:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-glow);
|
||||||
|
box-shadow: var(--shadow-md), var(--shadow-glow), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .card,
|
||||||
|
[data-theme="light"] .panel {
|
||||||
|
border-color: rgba(31, 95, 168, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .card-header,
|
||||||
|
[data-theme="light"] .panel-header {
|
||||||
|
border-bottom-color: rgba(31, 95, 168, 0.2);
|
||||||
|
background: linear-gradient(180deg, rgba(243, 247, 252, 0.96) 0%, rgba(233, 239, 247, 0.95) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats strip value highlight on hover */
|
/* Stats strip value highlight on hover */
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
--bg-card: #121a25;
|
--bg-card: #121a25;
|
||||||
--bg-elevated: #1b2734;
|
--bg-elevated: #1b2734;
|
||||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||||
|
--surface-glass: rgba(16, 25, 37, 0.82);
|
||||||
|
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||||
|
--ambient-top-left: rgba(74, 163, 255, 0.14);
|
||||||
|
--ambient-top-right: rgba(56, 193, 128, 0.09);
|
||||||
|
--ambient-bottom: rgba(214, 168, 94, 0.06);
|
||||||
|
|
||||||
/* Background aliases for components */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
@@ -78,8 +83,8 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
TYPOGRAPHY
|
TYPOGRAPHY
|
||||||
============================================ */
|
============================================ */
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
|
||||||
/* Font sizes */
|
/* Font sizes */
|
||||||
--text-xs: 10px;
|
--text-xs: 10px;
|
||||||
@@ -158,6 +163,11 @@
|
|||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-elevated: #f1f4f9;
|
--bg-elevated: #f1f4f9;
|
||||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||||
|
--surface-glass: rgba(255, 255, 255, 0.84);
|
||||||
|
--surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%);
|
||||||
|
--ambient-top-left: rgba(31, 95, 168, 0.1);
|
||||||
|
--ambient-top-right: rgba(31, 138, 87, 0.06);
|
||||||
|
--ambient-bottom: rgba(181, 134, 58, 0.05);
|
||||||
|
|
||||||
/* Background aliases for components */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
/* Local font declarations for offline mode */
|
/* Local font declarations for offline mode */
|
||||||
|
/* Roboto Condensed - variable font, one file covers all weights */
|
||||||
|
|
||||||
/* Space Mono - Console font */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Space Mono';
|
font-family: 'Roboto Condensed';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 300 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
|
src: url('/static/vendor/fonts/RobotoCondensed-Latin.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Space Mono';
|
font-family: 'Roboto Condensed';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 300 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
|
src: url('/static/vendor/fonts/RobotoCondensed-LatinExt.woff2') format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
border-bottom: 1px solid var(--border-color, #202833);
|
border-bottom: 1px solid var(--border-color, #202833);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 1100;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +434,6 @@ a.nav-dashboard-btn:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-dashboard-btn .nav-label {
|
.nav-dashboard-btn .nav-label {
|
||||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal.active {
|
.help-modal.active {
|
||||||
@@ -26,37 +27,41 @@
|
|||||||
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
||||||
border: 1px solid var(--border-color, #1f2937);
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 30px;
|
padding: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-content h2 {
|
.help-content h2 {
|
||||||
color: var(--accent-cyan, #4a9eff);
|
color: var(--accent-cyan, #4a9eff);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
font-size: 24px;
|
font-size: 15px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-content h3 {
|
.help-content h3 {
|
||||||
color: var(--text-primary, #e8eaed);
|
color: var(--text-primary, #e8eaed);
|
||||||
margin: 25px 0 15px 0;
|
margin: 20px 0 10px 0;
|
||||||
font-size: 14px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
padding-bottom: 8px;
|
padding-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-close {
|
.help-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 12px;
|
||||||
right: 15px;
|
right: 12px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-dim, #4b5563);
|
color: var(--text-dim, #4b5563);
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-close:hover {
|
.help-close:hover {
|
||||||
@@ -66,43 +71,54 @@
|
|||||||
.help-modal .icon-grid {
|
.help-modal .icon-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
margin: 15px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .icon-item {
|
.help-modal .icon-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 6px 8px;
|
||||||
background: var(--bg-primary, #0a0c10);
|
background: var(--bg-primary, #0a0c10);
|
||||||
border: 1px solid var(--border-color, #1f2937);
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .icon-item .icon {
|
.help-modal .icon-item .icon {
|
||||||
font-size: 18px;
|
width: 20px;
|
||||||
width: 30px;
|
height: 20px;
|
||||||
text-align: center;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .icon-item .desc {
|
.help-modal .icon-item .desc {
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .tip-list {
|
.help-modal .tip-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 15px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .tip-list li {
|
.help-modal .tip-list li {
|
||||||
padding: 8px 0;
|
padding: 5px 0;
|
||||||
padding-left: 20px;
|
padding-left: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +134,15 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li strong {
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.help-tabs {
|
.help-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
border: 1px solid var(--border-color, #1f2937);
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -129,12 +150,13 @@
|
|||||||
|
|
||||||
.help-tab {
|
.help-tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
background: var(--bg-primary, #0a0c10);
|
background: var(--bg-primary, #0a0c10);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -176,9 +198,9 @@
|
|||||||
/* Ensure code tags are styled */
|
/* Ensure code tags are styled */
|
||||||
.help-modal code {
|
.help-modal code {
|
||||||
background: var(--bg-tertiary, #151a23);
|
background: var(--bg-tertiary, #151a23);
|
||||||
padding: 2px 6px;
|
padding: 1px 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
font-size: 11px;
|
font-size: 10.5px;
|
||||||
color: var(--accent-cyan, #4a9eff);
|
color: var(--accent-cyan, #4a9eff);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
/* Analytics Dashboard Styles */
|
||||||
|
|
||||||
|
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.main-content.analytics-active {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
.main-content.analytics-active > .output-panel {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.main-content.analytics-active > .sidebar {
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.main-content.analytics-active .sidebar-collapse-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.main-content.analytics-active > .output-panel {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
margin-bottom: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card {
|
||||||
|
background: var(--bg-card, #151f2b);
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card.low {
|
||||||
|
border-color: rgba(90, 106, 122, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card.medium {
|
||||||
|
border-color: rgba(74, 163, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card.high {
|
||||||
|
border-color: rgba(214, 168, 94, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card.critical {
|
||||||
|
border-color: rgba(226, 93, 93, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card .insight-title {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card .insight-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card .insight-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary, #9aabba);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-insight-card .insight-detail {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-top-changes {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row .mode {
|
||||||
|
min-width: 84px;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row .delta {
|
||||||
|
min-width: 48px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row .delta.up {
|
||||||
|
color: var(--accent-green, #38c180);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row .delta.down {
|
||||||
|
color: var(--accent-red, #e25d5d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row .delta.flat {
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-change-row .avg {
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card {
|
||||||
|
background: var(--bg-card, #151f2b);
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: var(--space-3, 12px);
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition-fast, 150ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card:hover {
|
||||||
|
border-color: var(--accent-cyan, #4aa3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-count {
|
||||||
|
font-size: var(--text-2xl, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-label {
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: var(--space-1, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-sparkline {
|
||||||
|
height: 24px;
|
||||||
|
margin-top: var(--space-2, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-sparkline svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-sparkline polyline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-cyan, #4aa3ff);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Health indicators */
|
||||||
|
.analytics-health {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
margin-bottom: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1, 4px);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-red, #e25d5d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot.running {
|
||||||
|
background: var(--accent-green, #38c180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emergency squawk panel */
|
||||||
|
.squawk-emergency {
|
||||||
|
background: rgba(226, 93, 93, 0.1);
|
||||||
|
border: 1px solid var(--accent-red, #e25d5d);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: var(--space-3, 12px);
|
||||||
|
margin-bottom: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-emergency .squawk-title {
|
||||||
|
color: var(--accent-red, #e25d5d);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-sm, 12px);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: var(--space-2, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-emergency .squawk-item {
|
||||||
|
font-size: var(--text-sm, 12px);
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
padding: var(--space-1, 4px) 0;
|
||||||
|
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-emergency .squawk-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert feed */
|
||||||
|
.analytics-alert-feed {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-alert-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
padding: var(--space-2, 8px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-alert-item .alert-severity {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
||||||
|
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
||||||
|
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
||||||
|
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
||||||
|
|
||||||
|
/* Correlation panel */
|
||||||
|
.analytics-correlation-pair {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
padding: var(--space-2, 8px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-correlation-pair .confidence-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-secondary, #101823);
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-correlation-pair .confidence-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-green, #38c180);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-pattern-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-pattern-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-pattern-item .pattern-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-pattern-item .pattern-mode {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-pattern-item .pattern-device {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-pattern-item .pattern-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-pattern-item .pattern-confidence {
|
||||||
|
color: var(--accent-green, #38c180);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geofence zone list */
|
||||||
|
.geofence-zone-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2, 8px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geofence-zone-item .zone-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geofence-zone-item .zone-radius {
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geofence-zone-item .zone-delete {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent-red, #e25d5d);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--accent-red, #e25d5d);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export controls */
|
||||||
|
.export-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls select,
|
||||||
|
.export-controls button {
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||||
|
background: var(--bg-card, #151f2b);
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent-cyan, #4aa3ff);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent-cyan, #4aa3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers */
|
||||||
|
.analytics-section-header {
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: var(--space-2, 8px);
|
||||||
|
padding-bottom: var(--space-1, 4px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.analytics-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
padding: var(--space-4, 16px);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-toolbar,
|
||||||
|
.analytics-replay-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-toolbar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--bg-card, #151f2b);
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-toolbar button,
|
||||||
|
.analytics-replay-toolbar button,
|
||||||
|
.analytics-replay-toolbar select {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
background: var(--bg-card, #151f2b);
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-toolbar button,
|
||||||
|
.analytics-replay-toolbar button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(74, 163, 255, 0.2);
|
||||||
|
border-color: rgba(74, 163, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-summary {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-item,
|
||||||
|
.analytics-replay-item {
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
padding: 7px 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-item:last-child,
|
||||||
|
.analytics-replay-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-item .title,
|
||||||
|
.analytics-replay-item .title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-item .mode,
|
||||||
|
.analytics-replay-item .mode {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.35);
|
||||||
|
color: var(--accent-cyan, #4aa3ff);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-target-item .meta,
|
||||||
|
.analytics-replay-item .meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/* BT Locate Mode Styles */
|
||||||
|
|
||||||
|
/* Environment preset grid */
|
||||||
|
.btl-env-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-btn.active {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
border-color: var(--accent-green, #00ff88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-n {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PROXIMITY HUD — main visuals area
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.btl-hud {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
min-width: 130px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band.immediate {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
|
||||||
|
animation: btl-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band.near {
|
||||||
|
color: #f97316;
|
||||||
|
border-color: #f97316;
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
box-shadow: 0 0 15px rgba(249, 115, 22, 0.15);
|
||||||
|
animation: btl-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band.far {
|
||||||
|
color: #eab308;
|
||||||
|
border-color: #eab308;
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
box-shadow: 0 0 10px rgba(234, 179, 8, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btl-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metric-lg .btl-hud-value {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-unit {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-export-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-export-format {
|
||||||
|
min-width: 62px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-audio-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-audio-toggle input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-clear-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-clear-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom info bar */
|
||||||
|
.btl-hud-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-info-item {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-info-sep {
|
||||||
|
color: rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-diag {
|
||||||
|
display: none;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
opacity: 0.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-diag:not(:empty) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VISUALS AREA — map + chart
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.btl-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 250px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btLocateMap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 450;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-legend {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 430;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-bar {
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-track-stats {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 430;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-rssi-chart-container {
|
||||||
|
height: 100px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-rssi-chart-container .btl-chart-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btLocateRssiChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LOCATE BUTTON — Bluetooth device cards
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.bt-locate-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--accent-green, #00ff88);
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-locate-btn:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
border-color: var(--accent-green, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-locate-btn svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
IRK DETECT BUTTON + DEVICE PICKER
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.btl-detect-irk-btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-detect-irk-btn:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-detect-irk-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker {
|
||||||
|
margin-top: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-status {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-list {
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-item {
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-item:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-item:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-meta {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE — stack HUD vertically on narrow
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.btl-hud {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band {
|
||||||
|
min-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metrics {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-controls {
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-export-row {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-controls {
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 6px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-legend {
|
||||||
|
left: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-track-stats {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/* GPS Mode Styles */
|
||||||
|
|
||||||
|
/* Sidebar info grid */
|
||||||
|
.gps-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-info-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-info-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status */
|
||||||
|
.gps-connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot.connected {
|
||||||
|
background: #00ff88;
|
||||||
|
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot.waiting {
|
||||||
|
background: #ffaa00;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot.error {
|
||||||
|
background: #ff4444;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix badge */
|
||||||
|
.gps-fix-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-fix-badge.no-fix {
|
||||||
|
background: rgba(255, 68, 68, 0.2);
|
||||||
|
color: #ff4444;
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-fix-badge.fix-2d {
|
||||||
|
background: rgba(255, 170, 0, 0.2);
|
||||||
|
color: #ffaa00;
|
||||||
|
border: 1px solid rgba(255, 170, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-fix-badge.fix-3d {
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
color: #00ff88;
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOP quality indicators */
|
||||||
|
.gps-dop-good { color: #00ff88; }
|
||||||
|
.gps-dop-moderate { color: #ffaa00; }
|
||||||
|
.gps-dop-poor { color: #ff4444; }
|
||||||
|
|
||||||
|
/* ===== Visuals Panel ===== */
|
||||||
|
|
||||||
|
.gps-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top row: sky view + position info */
|
||||||
|
.gps-visuals-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sky View */
|
||||||
|
.gps-skyview-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-skyview-panel h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-skyview-canvas-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gpsSkyCanvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position info panel */
|
||||||
|
.gps-position-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-position-panel h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-big {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal Strength Bars */
|
||||||
|
.gps-signal-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-panel h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
height: 140px;
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar {
|
||||||
|
width: 14px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar.unused {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-prn {
|
||||||
|
font-size: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-snr {
|
||||||
|
font-size: 7px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Constellation colors */
|
||||||
|
.gps-const-gps { background-color: #00d4ff; }
|
||||||
|
.gps-const-glonass { background-color: #00ff88; }
|
||||||
|
.gps-const-galileo { background-color: #ff8800; }
|
||||||
|
.gps-const-beidou { background-color: #ff4466; }
|
||||||
|
.gps-const-sbas { background-color: #ffdd00; }
|
||||||
|
.gps-const-qzss { background-color: #cc66ff; }
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.gps-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.gps-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-empty-state svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-empty-state p {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 300px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gps-visuals-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-skyview-panel,
|
||||||
|
.gps-position-panel {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-big {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
/* Space Weather Mode Styles */
|
||||||
|
|
||||||
|
/* Main container */
|
||||||
|
.sw-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header metrics strip */
|
||||||
|
.sw-header-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-stat + .sw-header-stat {
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-value {
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-value.accent-cyan { color: var(--accent-cyan); }
|
||||||
|
.sw-header-value.accent-green { color: #00ff88; }
|
||||||
|
.sw-header-value.accent-yellow { color: #ffcc00; }
|
||||||
|
.sw-header-value.accent-orange { color: #ff8800; }
|
||||||
|
.sw-header-value.accent-red { color: #ff3366; }
|
||||||
|
|
||||||
|
/* Refresh controls in strip */
|
||||||
|
.sw-strip-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-refresh-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-refresh-btn:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-last-update {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NOAA G/S/R Scale cards */
|
||||||
|
.sw-scales-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-value {
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-desc {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale severity colors */
|
||||||
|
.sw-scale-0 { color: #00ff88; border-color: #00ff8833; }
|
||||||
|
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
|
||||||
|
.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; }
|
||||||
|
.sw-scale-3 { color: #ff8800; border-color: #ff880033; }
|
||||||
|
.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
|
||||||
|
.sw-scale-5 { color: #ff0044; border-color: #ff004433; }
|
||||||
|
|
||||||
|
/* HF Band conditions grid */
|
||||||
|
.sw-band-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto repeat(2, 1fr);
|
||||||
|
gap: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-header {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-cond {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-good { color: #00ff88; background: #00ff8815; }
|
||||||
|
.sw-band-fair { color: #ffcc00; background: #ffcc0015; }
|
||||||
|
.sw-band-poor { color: #ff3366; background: #ff336615; }
|
||||||
|
|
||||||
|
/* 2-column dashboard grid */
|
||||||
|
.sw-dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart containers */
|
||||||
|
.sw-chart-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-chart-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-chart-wrap canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flare probability table */
|
||||||
|
.sw-prob-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-prob-table th {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-prob-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solar image gallery */
|
||||||
|
.sw-image-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tab:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tab.active {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
background: var(--accent-cyan)10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-frame {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-frame img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* D-RAP frequency selector */
|
||||||
|
.sw-drap-freqs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-drap-freq-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-drap-freq-btn:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-drap-freq-btn.active {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts list */
|
||||||
|
.sw-alerts-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-msg {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active regions table */
|
||||||
|
.sw-regions-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-regions-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-regions-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-regions-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty / loading states */
|
||||||
|
.sw-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-loading::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: sw-spin 0.8s linear infinite;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sw-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width card */
|
||||||
|
.sw-full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sw-dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scales-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-strip {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-stat {
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-trigger-grid label {
|
.subghz-trigger-grid label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-primary, #0d1117);
|
background: var(--bg-primary, #0d1117);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
.subghz-trigger-help {
|
.subghz-trigger-help {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
@@ -416,7 +416,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.35px;
|
letter-spacing: 0.35px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-capture-actions button:hover {
|
.subghz-capture-actions button:hover {
|
||||||
@@ -554,7 +554,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -591,7 +591,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@@ -695,12 +695,12 @@
|
|||||||
.subghz-tx-modal .tx-freq {
|
.subghz-tx-modal .tx-freq {
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-modal .tx-duration {
|
.subghz-tx-modal .tx-duration {
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-segment-box {
|
.subghz-tx-segment-box {
|
||||||
@@ -742,7 +742,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-primary, #0d1117);
|
background: var(--bg-primary, #0d1117);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,7 +755,7 @@
|
|||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
font-size: 11px !important;
|
font-size: 11px !important;
|
||||||
color: var(--accent-cyan, #00d4ff) !important;
|
color: var(--accent-cyan, #00d4ff) !important;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-burst-assist {
|
.subghz-tx-burst-assist {
|
||||||
@@ -768,7 +768,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-burst-title {
|
.subghz-tx-burst-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -805,7 +805,7 @@
|
|||||||
|
|
||||||
.subghz-tx-burst-range {
|
.subghz-tx-burst-range {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
@@ -839,7 +839,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px dashed var(--border-color, #2a3040);
|
border: 1px dashed var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -854,7 +854,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
}
|
}
|
||||||
@@ -865,7 +865,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #00d4ff;
|
color: #00d4ff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -884,7 +884,7 @@
|
|||||||
.subghz-tx-modal-actions button {
|
.subghz-tx-modal-actions button {
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
@@ -926,7 +926,7 @@
|
|||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 24px 12px;
|
padding: 24px 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-captures-list-main .subghz-empty {
|
.subghz-captures-list-main .subghz-empty {
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
border: 1px solid #2a3040;
|
border: 1px solid #2a3040;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: none;
|
display: none;
|
||||||
@@ -970,7 +970,7 @@
|
|||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,7 +1029,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
@@ -1068,7 +1068,7 @@
|
|||||||
content: 'No peaks detected';
|
content: 'No peaks detected';
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1082,7 +1082,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
transition: border-color 0.12s;
|
transition: border-color 0.12s;
|
||||||
}
|
}
|
||||||
@@ -1108,7 +1108,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1192,7 +1192,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1211,7 +1211,7 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
@@ -1263,7 +1263,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,7 +1300,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
@@ -1365,7 +1365,7 @@
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 114px;
|
max-height: 114px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -1402,7 +1402,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-header-title {
|
.subghz-hub-header-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -1410,7 +1410,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-header-sub {
|
.subghz-hub-header-sub {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -1472,14 +1472,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-title {
|
.subghz-hub-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-desc {
|
.subghz-hub-desc {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
}
|
}
|
||||||
@@ -1526,7 +1526,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-saved-selection-count {
|
.subghz-saved-selection-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -1538,7 +1538,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
@@ -1550,7 +1550,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-op-panel-title {
|
.subghz-op-panel-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1620,7 +1620,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
@@ -1654,14 +1654,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-info-label {
|
.subghz-rx-info-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-info-value {
|
.subghz-rx-info-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -1688,7 +1688,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(0, 0, 0, 0.22);
|
background: rgba(0, 0, 0, 0.22);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-hint-label {
|
.subghz-rx-hint-label {
|
||||||
@@ -1722,7 +1722,7 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
@@ -1741,7 +1741,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-level-label {
|
.subghz-rx-level-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -1772,7 +1772,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-scope-label {
|
.subghz-rx-scope-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -1832,7 +1832,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
@@ -1854,7 +1854,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
@@ -1938,7 +1938,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-label {
|
.subghz-tx-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
@@ -1958,14 +1958,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-info-label {
|
.subghz-tx-info-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-info-value {
|
.subghz-tx-info-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-sweep-peaks-title {
|
.subghz-sweep-peaks-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* VDL2 Mode Styles */
|
||||||
|
|
||||||
|
/* VDL2 Status Indicator */
|
||||||
|
.vdl2-status-dot.listening {
|
||||||
|
background: var(--accent-cyan) !important;
|
||||||
|
animation: vdl2-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vdl2-status-dot.receiving {
|
||||||
|
background: var(--accent-green) !important;
|
||||||
|
}
|
||||||
|
.vdl2-status-dot.error {
|
||||||
|
background: var(--accent-red) !important;
|
||||||
|
}
|
||||||
|
@keyframes vdl2-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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VDL2 message animation */
|
||||||
|
.vdl2-msg {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
animation: vdl2FadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
.vdl2-msg:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
}
|
||||||
|
@keyframes vdl2FadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
.wxsat-strip-status-text {
|
.wxsat-strip-status-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-strip-btn {
|
.wxsat-strip-btn {
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
.wxsat-strip-value {
|
.wxsat-strip-value {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-loc-input:focus {
|
.wxsat-loc-input:focus {
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
.wxsat-cd-value {
|
.wxsat-cd-value {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -248,13 +248,13 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-countdown-detail {
|
.wxsat-countdown-detail {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Timeline ===== */
|
/* ===== Timeline ===== */
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Pass Predictions Panel ===== */
|
/* ===== Pass Predictions Panel ===== */
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
.wxsat-passes-count {
|
.wxsat-passes-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-passes-list {
|
.wxsat-passes-list {
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
background: rgba(255, 187, 0, 0.15);
|
background: rgba(255, 187, 0, 0.15);
|
||||||
color: #ffbb00;
|
color: #ffbb00;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -409,7 +409,7 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-mode.apt {
|
.wxsat-pass-mode.apt {
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-detail-label {
|
.wxsat-pass-detail-label {
|
||||||
@@ -499,7 +499,7 @@
|
|||||||
.wxsat-panel-subtitle {
|
.wxsat-panel-subtitle {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#wxsatPolarCanvas {
|
#wxsatPolarCanvas {
|
||||||
@@ -510,8 +510,93 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-ground-map {
|
.wxsat-ground-map {
|
||||||
|
position: relative;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
background: var(--bg-primary, #0d1117);
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #061329 0%, #050d1a 54%, #061325 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-ground-map .leaflet-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track {
|
||||||
|
filter: drop-shadow(0 0 5px rgba(91, 240, 255, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track.lrpt {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(0, 255, 190, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-icon {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-marker {
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-h,
|
||||||
|
.wxsat-crosshair-v,
|
||||||
|
.wxsat-crosshair-ring,
|
||||||
|
.wxsat-crosshair-dot {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-h {
|
||||||
|
top: 50%;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 93, 93, 0.95);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-v {
|
||||||
|
left: 50%;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(255, 93, 93, 0.95);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-ring {
|
||||||
|
inset: 6px;
|
||||||
|
border: 1.5px solid rgba(255, 93, 93, 0.95);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 93, 93, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffa0a0;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 100, 100, 0.65);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-map-tooltip {
|
||||||
|
background: rgba(5, 15, 32, 0.92);
|
||||||
|
border: 1px solid rgba(102, 229, 255, 0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8fe8ff;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 210, 255, 0.24);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-map-tooltip.leaflet-tooltip-top:before {
|
||||||
|
border-top-color: rgba(102, 229, 255, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Image Gallery Panel ===== */
|
/* ===== Image Gallery Panel ===== */
|
||||||
@@ -547,7 +632,7 @@
|
|||||||
.wxsat-gallery-count {
|
.wxsat-gallery-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-gallery-grid {
|
.wxsat-gallery-grid {
|
||||||
@@ -636,7 +721,7 @@
|
|||||||
.wxsat-image-product {
|
.wxsat-image-product {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-image-timestamp {
|
.wxsat-image-timestamp {
|
||||||
@@ -649,7 +734,7 @@
|
|||||||
.wxsat-date-header {
|
.wxsat-date-header {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -708,7 +793,7 @@
|
|||||||
.wxsat-capture-message {
|
.wxsat-capture-message {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -719,7 +804,7 @@
|
|||||||
.wxsat-capture-elapsed {
|
.wxsat-capture-elapsed {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,7 +870,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +1026,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-phase-step {
|
.wxsat-phase-step {
|
||||||
@@ -1012,7 +1097,7 @@
|
|||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: var(--bg-primary, #0d1117);
|
background: var(--bg-primary, #0d1117);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,7 +428,7 @@
|
|||||||
|
|
||||||
/* Visual panels should be scrollable, not clipped */
|
/* Visual panels should be scrollable, not clipped */
|
||||||
.wifi-visuals,
|
.wifi-visuals,
|
||||||
.bt-visuals {
|
.bt-visuals-column {
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
|
|
||||||
/* Visual panels should stack in single column on mobile when visible */
|
/* Visual panels should stack in single column on mobile when visible */
|
||||||
.wifi-visuals,
|
.wifi-visuals,
|
||||||
.bt-visuals {
|
.bt-visuals-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -465,6 +465,34 @@
|
|||||||
.wifi-visual-panel {
|
.wifi-visual-panel {
|
||||||
grid-column: auto !important;
|
grid-column: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-main-area {
|
||||||
|
flex-direction: column !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-side-panels {
|
||||||
|
width: 100% !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-detail-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-row-secondary {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
white-space: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-row-actions {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-list-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============== MOBILE MAP FIXES ============== */
|
/* ============== MOBILE MAP FIXES ============== */
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
|
|||||||
@@ -479,6 +479,54 @@
|
|||||||
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global Leaflet map theme: cyber overlay */
|
||||||
|
.leaflet-container.map-theme-cyber {
|
||||||
|
position: relative;
|
||||||
|
background: #020813;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.map-theme-cyber .leaflet-tile-pane {
|
||||||
|
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hard global fallback: enforce cyber tint on all Leaflet tile images */
|
||||||
|
html.map-cyber-enabled .leaflet-container .leaflet-tile {
|
||||||
|
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hard global fallback: cyber glow + grid overlay */
|
||||||
|
html.map-cyber-enabled .leaflet-container {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.map-cyber-enabled .leaflet-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 620;
|
||||||
|
background:
|
||||||
|
radial-gradient(95% 78% at 50% 44%, rgba(18, 170, 255, 0.17), rgba(18, 170, 255, 0) 64%),
|
||||||
|
linear-gradient(180deg, rgba(24, 118, 255, 0.045), rgba(24, 118, 255, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.map-cyber-enabled .leaflet-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 621;
|
||||||
|
opacity: 0.42;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(78, 188, 255, 0.14) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(78, 188, 255, 0.14) 1px, transparent 1px);
|
||||||
|
background-size: 52px 52px, 52px 52px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const ProximityRadar = (function() {
|
|||||||
let isHovered = false;
|
let isHovered = false;
|
||||||
let renderPending = false;
|
let renderPending = false;
|
||||||
let renderTimer = null;
|
let renderTimer = null;
|
||||||
|
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the radar component
|
* Initialize the radar component
|
||||||
@@ -119,6 +120,36 @@ const ProximityRadar = (function() {
|
|||||||
|
|
||||||
svg = container.querySelector('svg');
|
svg = container.querySelector('svg');
|
||||||
|
|
||||||
|
// Event delegation on the devices group (survives innerHTML rebuilds)
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('click', (e) => {
|
||||||
|
const deviceEl = e.target.closest('.radar-device');
|
||||||
|
if (!deviceEl) return;
|
||||||
|
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||||
|
if (onDeviceClick && deviceKey) {
|
||||||
|
// Lock out re-renders briefly so the DOM stays stable after click
|
||||||
|
interactionLockUntil = Date.now() + 500;
|
||||||
|
onDeviceClick(deviceKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseenter', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = true;
|
||||||
|
}
|
||||||
|
}, true); // capture phase so we catch enter on child elements
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseleave', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = false;
|
||||||
|
if (renderPending) {
|
||||||
|
renderPending = false;
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Add sweep animation
|
// Add sweep animation
|
||||||
animateSweep();
|
animateSweep();
|
||||||
}
|
}
|
||||||
@@ -165,8 +196,8 @@ const ProximityRadar = (function() {
|
|||||||
devices.set(device.device_key, device);
|
devices.set(device.device_key, device);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defer render while user is hovering to prevent DOM rebuild flicker
|
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||||
if (isHovered) {
|
if (isHovered || Date.now() < interactionLockUntil) {
|
||||||
renderPending = true;
|
renderPending = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,7 +260,7 @@ const ProximityRadar = (function() {
|
|||||||
style="cursor: pointer;">
|
style="cursor: pointer;">
|
||||||
<!-- Invisible hit area to prevent hover flicker -->
|
<!-- Invisible hit area to prevent hover flicker -->
|
||||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||||
</circle>` : ''}
|
</circle>` : ''}
|
||||||
@@ -244,24 +275,6 @@ const ProximityRadar = (function() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
devicesGroup.innerHTML = dots;
|
devicesGroup.innerHTML = dots;
|
||||||
|
|
||||||
// Attach event handlers
|
|
||||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
|
||||||
el.addEventListener('click', (e) => {
|
|
||||||
const deviceKey = el.getAttribute('data-device-key');
|
|
||||||
if (onDeviceClick && deviceKey) {
|
|
||||||
onDeviceClick(deviceKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.addEventListener('mouseenter', () => { isHovered = true; });
|
|
||||||
el.addEventListener('mouseleave', () => {
|
|
||||||
isHovered = false;
|
|
||||||
if (renderPending) {
|
|
||||||
renderPending = false;
|
|
||||||
renderDevices();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -345,19 +358,125 @@ const ProximityRadar = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight a specific device on the radar
|
* Highlight a specific device on the radar (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function highlightDevice(deviceKey) {
|
function highlightDevice(deviceKey) {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = deviceKey;
|
selectedDeviceKey = deviceKey;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
// Remove highlight from previously selected node
|
||||||
|
if (prev && prev !== deviceKey) {
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
// Remove animated selection ring
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
// Restore dot opacity
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add highlight to newly selected node
|
||||||
|
if (deviceKey) {
|
||||||
|
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
|
||||||
|
if (newEl) {
|
||||||
|
applySelectionToElement(newEl, deviceKey);
|
||||||
|
} else {
|
||||||
|
// Node not in DOM yet; full render needed on next cycle
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device highlighting
|
* Apply selection styling to a radar device element in-place
|
||||||
|
*/
|
||||||
|
function applySelectionToElement(el, deviceKey) {
|
||||||
|
el.classList.add('selected');
|
||||||
|
const device = devices.get(deviceKey);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||||
|
|
||||||
|
// Update dot styling
|
||||||
|
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
dot.setAttribute('fill-opacity', '1');
|
||||||
|
dot.setAttribute('stroke', '#00d4ff');
|
||||||
|
dot.setAttribute('stroke-width', '2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animated selection ring if not already present
|
||||||
|
if (!el.querySelector('.radar-select-ring')) {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
const ring = document.createElementNS(ns, 'circle');
|
||||||
|
ring.classList.add('radar-select-ring');
|
||||||
|
ring.setAttribute('r', dotSize + 8);
|
||||||
|
ring.setAttribute('fill', 'none');
|
||||||
|
ring.setAttribute('stroke', '#00d4ff');
|
||||||
|
ring.setAttribute('stroke-width', '2');
|
||||||
|
ring.setAttribute('stroke-opacity', '0.8');
|
||||||
|
|
||||||
|
const animR = document.createElementNS(ns, 'animate');
|
||||||
|
animR.setAttribute('attributeName', 'r');
|
||||||
|
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
|
||||||
|
animR.setAttribute('dur', '1.5s');
|
||||||
|
animR.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animR);
|
||||||
|
|
||||||
|
const animO = document.createElementNS(ns, 'animate');
|
||||||
|
animO.setAttribute('attributeName', 'stroke-opacity');
|
||||||
|
animO.setAttribute('values', '0.8;0.4;0.8');
|
||||||
|
animO.setAttribute('dur', '1.5s');
|
||||||
|
animO.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animO);
|
||||||
|
|
||||||
|
// Insert after the hit area
|
||||||
|
const hitArea = el.querySelector('.radar-device-hitarea');
|
||||||
|
if (hitArea && hitArea.nextSibling) {
|
||||||
|
el.insertBefore(ring, hitArea.nextSibling);
|
||||||
|
} else {
|
||||||
|
el.insertBefore(ring, el.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear device highlighting (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function clearHighlight() {
|
function clearHighlight() {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = null;
|
selectedDeviceKey = null;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg || !prev) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -302,7 +302,13 @@ const SignalCards = (function() {
|
|||||||
*/
|
*/
|
||||||
function formatRelativeTime(timestamp) {
|
function formatRelativeTime(timestamp) {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
const date = new Date(timestamp);
|
let date = new Date(timestamp);
|
||||||
|
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
|
||||||
|
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
|
||||||
|
const today = new Date();
|
||||||
|
date = new Date(today.toDateString() + ' ' + timestamp);
|
||||||
|
}
|
||||||
|
if (isNaN(date.getTime())) return timestamp;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = Math.floor((now - date) / 1000);
|
const diff = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ async function syncAgentModeStates(agentId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Also check modes that might need to be marked as stopped
|
// Also check modes that might need to be marked as stopped
|
||||||
const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post'];
|
const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'vdl2', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post'];
|
||||||
allModes.forEach(mode => {
|
allModes.forEach(mode => {
|
||||||
if (!agentRunningModes.includes(mode)) {
|
if (!agentRunningModes.includes(mode)) {
|
||||||
syncModeUI(mode, false, agentId);
|
syncModeUI(mode, false, agentId);
|
||||||
@@ -485,7 +485,7 @@ async function syncLocalModeStates() {
|
|||||||
*/
|
*/
|
||||||
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
||||||
// SDR modes that can't run simultaneously on same device
|
// SDR modes that can't run simultaneously on same device
|
||||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||||
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
||||||
|
|
||||||
let warning = document.getElementById('agentModeWarning');
|
let warning = document.getElementById('agentModeWarning');
|
||||||
@@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||||
|
|
||||||
// If we're trying to start an SDR mode
|
// If we're trying to start an SDR mode
|
||||||
if (sdrModes.includes(modeToStart)) {
|
if (sdrModes.includes(modeToStart)) {
|
||||||
@@ -704,6 +704,7 @@ function syncModeUI(mode, isRunning, agentId = null) {
|
|||||||
'wifi': 'setWiFiRunning',
|
'wifi': 'setWiFiRunning',
|
||||||
'bluetooth': 'setBluetoothRunning',
|
'bluetooth': 'setBluetoothRunning',
|
||||||
'acars': 'setAcarsRunning',
|
'acars': 'setAcarsRunning',
|
||||||
|
'vdl2': 'setVdl2Running',
|
||||||
'listening_post': 'setListeningPostRunning'
|
'listening_post': 'setListeningPostRunning'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
const AlertCenter = (function() {
|
const AlertCenter = (function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||||
|
|
||||||
let alerts = [];
|
let alerts = [];
|
||||||
let rules = [];
|
let rules = [];
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
loadRules();
|
loadRules();
|
||||||
@@ -17,6 +18,7 @@ const AlertCenter = (function() {
|
|||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource = new EventSource('/alerts/stream');
|
eventSource = new EventSource('/alerts/stream');
|
||||||
eventSource.onmessage = function(e) {
|
eventSource.onmessage = function(e) {
|
||||||
try {
|
try {
|
||||||
@@ -27,21 +29,26 @@ const AlertCenter = (function() {
|
|||||||
console.error('[Alerts] SSE parse error', err);
|
console.error('[Alerts] SSE parse error', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = function() {
|
eventSource.onerror = function() {
|
||||||
console.warn('[Alerts] SSE connection error');
|
console.warn('[Alerts] SSE connection error');
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(connect, 2500);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAlert(alert) {
|
function handleAlert(alert) {
|
||||||
alerts.unshift(alert);
|
alerts.unshift(alert);
|
||||||
alerts = alerts.slice(0, 50);
|
alerts = alerts.slice(0, 60);
|
||||||
updateFeedUI();
|
updateFeedUI();
|
||||||
|
|
||||||
if (typeof showNotification === 'function') {
|
const severity = String(alert.severity || '').toLowerCase();
|
||||||
const severity = (alert.severity || '').toLowerCase();
|
if (typeof showNotification === 'function' && ['high', 'critical'].includes(severity)) {
|
||||||
if (['high', 'critical'].includes(severity)) {
|
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
}
|
||||||
}
|
|
||||||
|
if (typeof showAppToast === 'function' && ['high', 'critical'].includes(severity)) {
|
||||||
|
showAppToast(alert.title || 'Alert', alert.message || 'Alert triggered', 'warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ const AlertCenter = (function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = alerts.map(alert => {
|
list.innerHTML = alerts.map((alert) => {
|
||||||
const title = escapeHtml(alert.title || 'Alert');
|
const title = escapeHtml(alert.title || 'Alert');
|
||||||
const message = escapeHtml(alert.message || '');
|
const message = escapeHtml(alert.message || '');
|
||||||
const severity = escapeHtml(alert.severity || 'medium');
|
const severity = escapeHtml(alert.severity || 'medium');
|
||||||
@@ -74,27 +81,218 @@ const AlertCenter = (function() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRulesUI() {
|
||||||
|
const list = document.getElementById('alertsRulesList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (!rules.length) {
|
||||||
|
list.innerHTML = '<div class="settings-feed-empty">No rules yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = rules.map((rule) => {
|
||||||
|
const enabled = Boolean(rule.enabled);
|
||||||
|
const mode = rule.mode || 'all';
|
||||||
|
const eventType = rule.event_type || 'any';
|
||||||
|
const severity = (rule.severity || 'medium').toUpperCase();
|
||||||
|
const match = formatMatch(rule.match);
|
||||||
|
const statusText = enabled ? 'ENABLED' : 'DISABLED';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item" style="border-left: 2px solid ${enabled ? 'var(--accent-green)' : 'var(--text-dim)'};">
|
||||||
|
<div class="settings-feed-title" style="display:flex; gap:8px; align-items:center; justify-content:space-between;">
|
||||||
|
<span>${escapeHtml(rule.name || 'Rule')}</span>
|
||||||
|
<span style="color: var(--text-dim); font-size: 10px;">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">Mode: ${escapeHtml(mode)} | Event: ${escapeHtml(eventType)} | Severity: ${escapeHtml(severity)}</div>
|
||||||
|
<div class="settings-feed-meta">Match: ${escapeHtml(match)}</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-top: 8px;">
|
||||||
|
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.editRule(${Number(rule.id)})">Edit</button>
|
||||||
|
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.toggleRule(${Number(rule.id)}, ${enabled ? 'false' : 'true'})">${enabled ? 'Disable' : 'Enable'}</button>
|
||||||
|
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px; border-color: var(--accent-red); color: var(--accent-red);" onclick="AlertCenter.deleteRule(${Number(rule.id)})">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMatch(match) {
|
||||||
|
if (!match || typeof match !== 'object' || !Object.keys(match).length) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
const [k, v] = Object.entries(match)[0];
|
||||||
|
return `${k}=${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
function loadFeed() {
|
function loadFeed() {
|
||||||
fetch('/alerts/events?limit=20')
|
fetch('/alerts/events?limit=30')
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
alerts = data.events || [];
|
alerts = data.events || [];
|
||||||
updateFeedUI();
|
updateFeedUI();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[Alerts] Load feed failed', err));
|
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRules() {
|
function loadRules() {
|
||||||
fetch('/alerts/rules?all=1')
|
return fetch('/alerts/rules?all=1')
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
rules = data.rules || [];
|
rules = data.rules || [];
|
||||||
|
renderRulesUI();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[Alerts] Load rules failed', err));
|
.catch((err) => {
|
||||||
|
console.error('[Alerts] Load rules failed', err);
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRule() {
|
||||||
|
const editingId = getEditingRuleId();
|
||||||
|
const payload = buildRulePayload();
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
payload.name = payload.mode ? `${payload.mode} alert` : 'Alert Rule';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = editingId ? `/alerts/rules/${editingId}` : '/alerts/rules';
|
||||||
|
const method = editingId ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
throw new Error(data.message || 'Failed to save rule');
|
||||||
|
}
|
||||||
|
clearRuleForm();
|
||||||
|
return loadRules();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (typeof showAppToast === 'function') {
|
||||||
|
showAppToast('Alerts', editingId ? 'Rule updated' : 'Rule created', 'info');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Save Alert Rule', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRulePayload() {
|
||||||
|
const nameEl = document.getElementById('alertsRuleName');
|
||||||
|
const modeEl = document.getElementById('alertsRuleMode');
|
||||||
|
const eventTypeEl = document.getElementById('alertsRuleEventType');
|
||||||
|
const keyEl = document.getElementById('alertsRuleMatchKey');
|
||||||
|
const valueEl = document.getElementById('alertsRuleMatchValue');
|
||||||
|
const severityEl = document.getElementById('alertsRuleSeverity');
|
||||||
|
|
||||||
|
const match = {};
|
||||||
|
const key = keyEl ? String(keyEl.value || '').trim() : '';
|
||||||
|
const value = valueEl ? String(valueEl.value || '').trim() : '';
|
||||||
|
if (key && value) {
|
||||||
|
match[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: nameEl ? String(nameEl.value || '').trim() : 'Alert Rule',
|
||||||
|
mode: modeEl ? String(modeEl.value || '').trim() || null : null,
|
||||||
|
event_type: eventTypeEl ? String(eventTypeEl.value || '').trim() || null : null,
|
||||||
|
match,
|
||||||
|
severity: severityEl ? String(severityEl.value || 'medium') : 'medium',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRuleForm() {
|
||||||
|
setField('alertsRuleName', '');
|
||||||
|
setField('alertsRuleMode', '');
|
||||||
|
setField('alertsRuleEventType', '');
|
||||||
|
setField('alertsRuleMatchKey', '');
|
||||||
|
setField('alertsRuleMatchValue', '');
|
||||||
|
setField('alertsRuleSeverity', 'medium');
|
||||||
|
setField('alertsRuleEditingId', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRule(ruleId) {
|
||||||
|
const rule = rules.find((r) => Number(r.id) === Number(ruleId));
|
||||||
|
if (!rule) return;
|
||||||
|
|
||||||
|
const matchEntries = Object.entries(rule.match || {});
|
||||||
|
const firstMatch = matchEntries.length ? matchEntries[0] : ['', ''];
|
||||||
|
|
||||||
|
setField('alertsRuleName', rule.name || '');
|
||||||
|
setField('alertsRuleMode', rule.mode || '');
|
||||||
|
setField('alertsRuleEventType', rule.event_type || '');
|
||||||
|
setField('alertsRuleMatchKey', firstMatch[0] || '');
|
||||||
|
setField('alertsRuleMatchValue', firstMatch[1] == null ? '' : String(firstMatch[1]));
|
||||||
|
setField('alertsRuleSeverity', rule.severity || 'medium');
|
||||||
|
setField('alertsRuleEditingId', String(rule.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRule(ruleId, enabled) {
|
||||||
|
fetch(`/alerts/rules/${ruleId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: Boolean(enabled) }),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
throw new Error(data.message || 'Failed to update rule');
|
||||||
|
}
|
||||||
|
return loadRules();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Toggle Alert Rule', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRule(ruleId) {
|
||||||
|
if (!confirm('Delete this alert rule?')) return;
|
||||||
|
|
||||||
|
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
throw new Error(data.message || 'Failed to delete rule');
|
||||||
|
}
|
||||||
|
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
||||||
|
clearRuleForm();
|
||||||
|
}
|
||||||
|
return loadRules();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Delete Alert Rule', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditingRuleId() {
|
||||||
|
const el = document.getElementById('alertsRuleEditingId');
|
||||||
|
if (!el || !el.value) return null;
|
||||||
|
const parsed = Number(el.value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setField(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableTrackerAlerts() {
|
function enableTrackerAlerts() {
|
||||||
@@ -106,17 +304,18 @@ const AlertCenter = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureTrackerRule(enabled) {
|
function ensureTrackerRule(enabled) {
|
||||||
loadRules();
|
loadRules().then(() => {
|
||||||
setTimeout(() => {
|
const existing = rules.find((r) => r.name === TRACKER_RULE_NAME);
|
||||||
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
fetch(`/alerts/rules/${existing.id}`, {
|
return fetch(`/alerts/rules/${existing.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled })
|
body: JSON.stringify({ enabled }),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules());
|
||||||
} else if (enabled) {
|
}
|
||||||
fetch('/alerts/rules', {
|
|
||||||
|
if (enabled) {
|
||||||
|
return fetch('/alerts/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -126,44 +325,49 @@ const AlertCenter = (function() {
|
|||||||
match: { is_tracker: true },
|
match: { is_tracker: true },
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notify: { webhook: true }
|
notify: { webhook: true },
|
||||||
})
|
}),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules());
|
||||||
}
|
}
|
||||||
}, 150);
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBluetoothWatchlist(address, name) {
|
function addBluetoothWatchlist(address, name) {
|
||||||
if (!address) return;
|
if (!address) return;
|
||||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
const upper = String(address).toUpperCase();
|
||||||
if (existing) {
|
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||||
return;
|
if (existing) return;
|
||||||
}
|
|
||||||
fetch('/alerts/rules', {
|
fetch('/alerts/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||||
mode: 'bluetooth',
|
mode: 'bluetooth',
|
||||||
event_type: 'device_update',
|
event_type: 'device_update',
|
||||||
match: { address: address },
|
match: { address: upper },
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notify: { webhook: true }
|
notify: { webhook: true },
|
||||||
})
|
}),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules());
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBluetoothWatchlist(address) {
|
function removeBluetoothWatchlist(address) {
|
||||||
if (!address) return;
|
if (!address) return;
|
||||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
const upper = String(address).toUpperCase();
|
||||||
|
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||||
.then(() => loadRules());
|
.then(() => loadRules());
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWatchlisted(address) {
|
function isWatchlisted(address) {
|
||||||
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
if (!address) return false;
|
||||||
|
const upper = String(address).toUpperCase();
|
||||||
|
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
@@ -179,6 +383,12 @@ const AlertCenter = (function() {
|
|||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
loadFeed,
|
loadFeed,
|
||||||
|
loadRules,
|
||||||
|
saveRule,
|
||||||
|
clearRuleForm,
|
||||||
|
editRule,
|
||||||
|
toggleRule,
|
||||||
|
deleteRule,
|
||||||
enableTrackerAlerts,
|
enableTrackerAlerts,
|
||||||
disableTrackerAlerts,
|
disableTrackerAlerts,
|
||||||
addBluetoothWatchlist,
|
addBluetoothWatchlist,
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ let observerLocation = (function() {
|
|||||||
return ObserverLocation.getForModule('observerLocation');
|
return ObserverLocation.getForModule('observerLocation');
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem('observerLocation');
|
const saved = localStorage.getItem('observerLocation');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
if (parsed.lat && parsed.lon) return parsed;
|
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
return { lat: 51.5074, lon: -0.1278 };
|
return { lat: 51.5074, lon: -0.1278 };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -373,7 +373,7 @@ function showInfo(text) {
|
|||||||
|
|
||||||
const infoEl = document.createElement('div');
|
const infoEl = document.createElement('div');
|
||||||
infoEl.className = 'info-msg';
|
infoEl.className = 'info-msg';
|
||||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Space Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #888; word-break: break-all;';
|
||||||
infoEl.textContent = text;
|
infoEl.textContent = text;
|
||||||
output.insertBefore(infoEl, output.firstChild);
|
output.insertBefore(infoEl, output.firstChild);
|
||||||
}
|
}
|
||||||
@@ -387,7 +387,7 @@ function showError(text) {
|
|||||||
|
|
||||||
const errorEl = document.createElement('div');
|
const errorEl = document.createElement('div');
|
||||||
errorEl.className = 'error-msg';
|
errorEl.className = 'error-msg';
|
||||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Space Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||||
errorEl.textContent = '⚠ ' + text;
|
errorEl.textContent = '⚠ ' + text;
|
||||||
output.insertBefore(errorEl, output.firstChild);
|
output.insertBefore(errorEl, output.firstChild);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
const CommandPalette = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let overlayEl = null;
|
||||||
|
let inputEl = null;
|
||||||
|
let listEl = null;
|
||||||
|
let isOpen = false;
|
||||||
|
let activeIndex = 0;
|
||||||
|
let filteredItems = [];
|
||||||
|
|
||||||
|
const fallbackModeCommands = [
|
||||||
|
{ mode: 'pager', label: 'Pager' },
|
||||||
|
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||||
|
{ mode: 'rtlamr', label: 'Meters' },
|
||||||
|
{ mode: 'listening', label: 'Listening Post' },
|
||||||
|
{ mode: 'subghz', label: 'SubGHz' },
|
||||||
|
{ mode: 'aprs', label: 'APRS' },
|
||||||
|
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||||
|
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||||
|
{ mode: 'bt_locate', label: 'BT Locate' },
|
||||||
|
{ mode: 'satellite', label: 'Satellite' },
|
||||||
|
{ mode: 'sstv', label: 'ISS SSTV' },
|
||||||
|
{ mode: 'weathersat', label: 'Weather Sat' },
|
||||||
|
{ mode: 'sstv_general', label: 'HF SSTV' },
|
||||||
|
{ mode: 'gps', label: 'GPS' },
|
||||||
|
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||||
|
{ mode: 'dmr', label: 'Digital Voice' },
|
||||||
|
{ mode: 'websdr', label: 'WebSDR' },
|
||||||
|
{ mode: 'analytics', label: 'Analytics' },
|
||||||
|
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getModeCommands() {
|
||||||
|
const commands = [];
|
||||||
|
const seenModes = new Set();
|
||||||
|
|
||||||
|
const catalog = window.interceptModeCatalog;
|
||||||
|
if (catalog && typeof catalog === 'object') {
|
||||||
|
for (const [mode, meta] of Object.entries(catalog)) {
|
||||||
|
if (!mode || seenModes.has(mode)) continue;
|
||||||
|
const label = String((meta && meta.label) || mode).trim();
|
||||||
|
commands.push({ mode, label });
|
||||||
|
seenModes.add(mode);
|
||||||
|
}
|
||||||
|
if (commands.length > 0) return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navNodes = document.querySelectorAll('.mode-nav-btn[data-mode], .mobile-nav-btn[data-mode]');
|
||||||
|
navNodes.forEach((node) => {
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
const href = String(node.getAttribute('href') || '');
|
||||||
|
if (href.includes('/dashboard')) return;
|
||||||
|
}
|
||||||
|
const mode = String(node.dataset.mode || '').trim();
|
||||||
|
if (!mode || seenModes.has(mode)) return;
|
||||||
|
const label = String(node.dataset.modeLabel || node.textContent || mode).trim();
|
||||||
|
commands.push({ mode, label });
|
||||||
|
seenModes.add(mode);
|
||||||
|
});
|
||||||
|
if (commands.length > 0) return commands;
|
||||||
|
|
||||||
|
return fallbackModeCommands.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
buildDOM();
|
||||||
|
registerHotkeys();
|
||||||
|
renderItems('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDOM() {
|
||||||
|
overlayEl = document.createElement('div');
|
||||||
|
overlayEl.className = 'command-palette-overlay';
|
||||||
|
overlayEl.id = 'commandPaletteOverlay';
|
||||||
|
overlayEl.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlayEl) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const palette = document.createElement('div');
|
||||||
|
palette.className = 'command-palette';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'command-palette-header';
|
||||||
|
|
||||||
|
inputEl = document.createElement('input');
|
||||||
|
inputEl.className = 'command-palette-input';
|
||||||
|
inputEl.type = 'text';
|
||||||
|
inputEl.autocomplete = 'off';
|
||||||
|
inputEl.placeholder = 'Search commands and modes...';
|
||||||
|
inputEl.setAttribute('aria-label', 'Command Palette Search');
|
||||||
|
inputEl.addEventListener('input', () => {
|
||||||
|
renderItems(inputEl.value || '');
|
||||||
|
});
|
||||||
|
inputEl.addEventListener('keydown', onInputKeyDown);
|
||||||
|
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'command-palette-hint';
|
||||||
|
hint.textContent = 'Esc close';
|
||||||
|
|
||||||
|
header.appendChild(inputEl);
|
||||||
|
header.appendChild(hint);
|
||||||
|
|
||||||
|
listEl = document.createElement('div');
|
||||||
|
listEl.className = 'command-palette-list';
|
||||||
|
listEl.id = 'commandPaletteList';
|
||||||
|
|
||||||
|
palette.appendChild(header);
|
||||||
|
palette.appendChild(listEl);
|
||||||
|
overlayEl.appendChild(palette);
|
||||||
|
document.body.appendChild(overlayEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerHotkeys() {
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
const cmdK = (event.key.toLowerCase() === 'k') && (event.ctrlKey || event.metaKey);
|
||||||
|
if (cmdK) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isOpen) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeyDown(event) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = Math.min(activeIndex + 1, Math.max(filteredItems.length - 1, 0));
|
||||||
|
renderSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = Math.max(activeIndex - 1, 0);
|
||||||
|
renderSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = filteredItems[activeIndex];
|
||||||
|
if (item && typeof item.run === 'function') {
|
||||||
|
item.run();
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommands() {
|
||||||
|
const commands = [
|
||||||
|
{
|
||||||
|
title: 'Open Settings',
|
||||||
|
description: 'Open global settings panel',
|
||||||
|
keyword: 'settings configure tools',
|
||||||
|
shortcut: 'S',
|
||||||
|
run: () => {
|
||||||
|
if (typeof showSettings === 'function') showSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings: Alerts',
|
||||||
|
description: 'Open alert rules and feed',
|
||||||
|
keyword: 'settings alerts rule',
|
||||||
|
run: () => openSettingsTab('alerts')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings: Recording',
|
||||||
|
description: 'Open recording manager',
|
||||||
|
keyword: 'settings recording replay',
|
||||||
|
run: () => openSettingsTab('recording')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings: Location',
|
||||||
|
description: 'Configure observer location',
|
||||||
|
keyword: 'settings location gps lat lon',
|
||||||
|
run: () => openSettingsTab('location')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'View Aircraft Dashboard',
|
||||||
|
description: 'Open dedicated ADS-B dashboard page',
|
||||||
|
keyword: 'aircraft adsb dashboard',
|
||||||
|
run: () => { window.location.href = '/adsb/dashboard'; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'View Vessel Dashboard',
|
||||||
|
description: 'Open dedicated AIS dashboard page',
|
||||||
|
keyword: 'vessel ais dashboard',
|
||||||
|
run: () => { window.location.href = '/ais/dashboard'; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kill All Running Processes',
|
||||||
|
description: 'Stop all decoders and scans',
|
||||||
|
keyword: 'kill stop processes emergency',
|
||||||
|
run: () => {
|
||||||
|
if (typeof killAll === 'function') {
|
||||||
|
killAll();
|
||||||
|
} else if (typeof fetch === 'function') {
|
||||||
|
fetch('/killall', { method: 'POST' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Toggle Sidebar Width',
|
||||||
|
description: 'Collapse or expand the left sidebar',
|
||||||
|
keyword: 'sidebar collapse layout',
|
||||||
|
run: () => {
|
||||||
|
if (typeof toggleMainSidebarCollapse === 'function') {
|
||||||
|
toggleMainSidebarCollapse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const modeEntry of getModeCommands()) {
|
||||||
|
commands.push({
|
||||||
|
title: `Switch Mode: ${modeEntry.label}`,
|
||||||
|
description: 'Navigate directly to mode',
|
||||||
|
keyword: `mode ${modeEntry.mode} ${modeEntry.label.toLowerCase()}`,
|
||||||
|
run: () => goToMode(modeEntry.mode),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(query) {
|
||||||
|
const q = String(query || '').trim().toLowerCase();
|
||||||
|
const allItems = getCommands();
|
||||||
|
|
||||||
|
filteredItems = allItems.filter((item) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const haystack = `${item.title} ${item.description || ''} ${item.keyword || ''}`.toLowerCase();
|
||||||
|
return haystack.includes(q);
|
||||||
|
}).slice(0, 80);
|
||||||
|
|
||||||
|
activeIndex = 0;
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (filteredItems.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'command-palette-empty';
|
||||||
|
empty.textContent = 'No matching commands';
|
||||||
|
listEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredItems.forEach((item, idx) => {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'command-palette-item';
|
||||||
|
row.dataset.index = String(idx);
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
item.run();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'meta';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'title';
|
||||||
|
title.textContent = item.title;
|
||||||
|
meta.appendChild(title);
|
||||||
|
|
||||||
|
const desc = document.createElement('span');
|
||||||
|
desc.className = 'desc';
|
||||||
|
desc.textContent = item.description || '';
|
||||||
|
meta.appendChild(desc);
|
||||||
|
|
||||||
|
row.appendChild(meta);
|
||||||
|
|
||||||
|
if (item.shortcut) {
|
||||||
|
const kbd = document.createElement('span');
|
||||||
|
kbd.className = 'kbd';
|
||||||
|
kbd.textContent = item.shortcut;
|
||||||
|
row.appendChild(kbd);
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelection() {
|
||||||
|
const rows = listEl.querySelectorAll('.command-palette-item');
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const idx = Number(row.dataset.index || 0);
|
||||||
|
row.classList.toggle('active', idx === activeIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeRow = listEl.querySelector(`.command-palette-item[data-index="${activeIndex}"]`);
|
||||||
|
if (activeRow) {
|
||||||
|
activeRow.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToMode(mode) {
|
||||||
|
const welcome = document.getElementById('welcomePage');
|
||||||
|
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
||||||
|
welcome.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof switchMode === 'function') {
|
||||||
|
switchMode(mode, { updateUrl: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingsTab(tab) {
|
||||||
|
if (typeof showSettings === 'function') {
|
||||||
|
showSettings();
|
||||||
|
}
|
||||||
|
if (typeof switchSettingsTab === 'function') {
|
||||||
|
switchSettingsTab(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
isOpen = true;
|
||||||
|
overlayEl.classList.add('open');
|
||||||
|
renderItems('');
|
||||||
|
inputEl.value = '';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputEl.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
isOpen = false;
|
||||||
|
overlayEl.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
CommandPalette.init();
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
const FirstRunSetup = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const COMPLETE_KEY = 'intercept.setup.complete.v1';
|
||||||
|
const DEFAULT_MODE_KEY = 'intercept.default_mode';
|
||||||
|
|
||||||
|
let overlayEl = null;
|
||||||
|
let depsStatusEl = null;
|
||||||
|
let locationStatusEl = null;
|
||||||
|
let notifyStatusEl = null;
|
||||||
|
let modeStatusEl = null;
|
||||||
|
let modeSelectEl = null;
|
||||||
|
|
||||||
|
let dependencyReady = null;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
buildDOM();
|
||||||
|
maybeShow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeShow() {
|
||||||
|
if (localStorage.getItem(COMPLETE_KEY) === 'true') return;
|
||||||
|
|
||||||
|
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||||
|
open();
|
||||||
|
refreshStatuses();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const waitTimer = setInterval(() => {
|
||||||
|
attempts += 1;
|
||||||
|
if (localStorage.getItem(COMPLETE_KEY) === 'true') {
|
||||||
|
clearInterval(waitTimer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||||
|
clearInterval(waitTimer);
|
||||||
|
open();
|
||||||
|
refreshStatuses();
|
||||||
|
}
|
||||||
|
if (attempts > 30) {
|
||||||
|
clearInterval(waitTimer);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDOM() {
|
||||||
|
overlayEl = document.createElement('div');
|
||||||
|
overlayEl.id = 'firstRunSetupOverlay';
|
||||||
|
overlayEl.className = 'setup-overlay';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'setup-modal';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'setup-header';
|
||||||
|
|
||||||
|
const headingWrap = document.createElement('div');
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
title.className = 'setup-title';
|
||||||
|
title.textContent = 'Quick Setup';
|
||||||
|
headingWrap.appendChild(title);
|
||||||
|
|
||||||
|
const subtitle = document.createElement('p');
|
||||||
|
subtitle.className = 'setup-subtitle';
|
||||||
|
subtitle.textContent = 'Complete these checks once so all modes work reliably.';
|
||||||
|
headingWrap.appendChild(subtitle);
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'setup-close';
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close setup assistant');
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
|
||||||
|
header.appendChild(headingWrap);
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'setup-content';
|
||||||
|
|
||||||
|
const depsStep = createStep(
|
||||||
|
'Dependencies',
|
||||||
|
'Verify required tools are installed for enabled modes.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
depsStatusEl = statusEl;
|
||||||
|
|
||||||
|
const checkBtn = buildButton('Recheck', () => checkDependencies());
|
||||||
|
const openToolsBtn = buildButton('Open Tools', () => {
|
||||||
|
if (typeof showSettings === 'function') showSettings();
|
||||||
|
if (typeof switchSettingsTab === 'function') switchSettingsTab('tools');
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(checkBtn);
|
||||||
|
actionsEl.appendChild(openToolsBtn);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationStep = createStep(
|
||||||
|
'Observer Location',
|
||||||
|
'Set latitude/longitude for pass prediction and mapping features.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
locationStatusEl = statusEl;
|
||||||
|
actionsEl.appendChild(buildButton('Open Location', () => {
|
||||||
|
if (typeof showSettings === 'function') showSettings();
|
||||||
|
if (typeof switchSettingsTab === 'function') switchSettingsTab('location');
|
||||||
|
}));
|
||||||
|
actionsEl.appendChild(buildButton('Recheck', refreshStatuses));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifyStep = createStep(
|
||||||
|
'Desktop Alerts',
|
||||||
|
'Allow notifications so high-priority alerts are visible when the tab is hidden.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
notifyStatusEl = statusEl;
|
||||||
|
actionsEl.appendChild(buildButton('Enable Notifications', requestNotifications));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const modeStep = createStep(
|
||||||
|
'Default Start Mode',
|
||||||
|
'Choose which mode should be selected by default.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
modeStatusEl = statusEl;
|
||||||
|
|
||||||
|
modeSelectEl = document.createElement('select');
|
||||||
|
modeSelectEl.className = 'setup-btn';
|
||||||
|
const modes = [
|
||||||
|
['pager', 'Pager'],
|
||||||
|
['sensor', '433MHz'],
|
||||||
|
['rtlamr', 'Meters'],
|
||||||
|
['listening', 'Listening Post'],
|
||||||
|
['wifi', 'WiFi'],
|
||||||
|
['bluetooth', 'Bluetooth'],
|
||||||
|
['bt_locate', 'BT Locate'],
|
||||||
|
['aprs', 'APRS'],
|
||||||
|
['satellite', 'Satellite'],
|
||||||
|
['sstv', 'ISS SSTV'],
|
||||||
|
['weathersat', 'Weather Sat'],
|
||||||
|
['sstv_general', 'HF SSTV'],
|
||||||
|
['analytics', 'Analytics'],
|
||||||
|
];
|
||||||
|
for (const [value, label] of modes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = value;
|
||||||
|
opt.textContent = label;
|
||||||
|
modeSelectEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||||
|
if (savedDefaultMode) {
|
||||||
|
modeSelectEl.value = savedDefaultMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsEl.appendChild(modeSelectEl);
|
||||||
|
actionsEl.appendChild(buildButton('Save', () => {
|
||||||
|
const selected = modeSelectEl.value || 'pager';
|
||||||
|
localStorage.setItem(DEFAULT_MODE_KEY, selected);
|
||||||
|
refreshStatuses();
|
||||||
|
if (typeof showAppToast === 'function') {
|
||||||
|
showAppToast('Default Mode Saved', `New sessions will default to ${selected}.`, 'info');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
content.appendChild(depsStep);
|
||||||
|
content.appendChild(locationStep);
|
||||||
|
content.appendChild(notifyStep);
|
||||||
|
content.appendChild(modeStep);
|
||||||
|
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'setup-footer';
|
||||||
|
|
||||||
|
const note = document.createElement('span');
|
||||||
|
note.className = 'setup-footer-note';
|
||||||
|
note.textContent = 'You can reopen these options anytime in Settings.';
|
||||||
|
|
||||||
|
const footerActions = document.createElement('div');
|
||||||
|
footerActions.style.display = 'inline-flex';
|
||||||
|
footerActions.style.gap = '8px';
|
||||||
|
|
||||||
|
const laterBtn = buildButton('Remind Me Later', close);
|
||||||
|
const completeBtn = buildButton('Mark Setup Complete', completeSetup, true);
|
||||||
|
completeBtn.id = 'setupCompleteBtn';
|
||||||
|
|
||||||
|
footerActions.appendChild(laterBtn);
|
||||||
|
footerActions.appendChild(completeBtn);
|
||||||
|
|
||||||
|
footer.appendChild(note);
|
||||||
|
footer.appendChild(footerActions);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(content);
|
||||||
|
modal.appendChild(footer);
|
||||||
|
|
||||||
|
overlayEl.appendChild(modal);
|
||||||
|
document.body.appendChild(overlayEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep(title, description, initActions) {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = 'setup-step';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'setup-step-header';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'setup-step-title';
|
||||||
|
titleEl.textContent = title;
|
||||||
|
|
||||||
|
const statusEl = document.createElement('span');
|
||||||
|
statusEl.className = 'setup-step-status';
|
||||||
|
statusEl.textContent = 'Pending';
|
||||||
|
|
||||||
|
header.appendChild(titleEl);
|
||||||
|
header.appendChild(statusEl);
|
||||||
|
|
||||||
|
const descEl = document.createElement('p');
|
||||||
|
descEl.className = 'setup-step-desc';
|
||||||
|
descEl.textContent = description;
|
||||||
|
|
||||||
|
const actionsEl = document.createElement('div');
|
||||||
|
actionsEl.className = 'setup-step-actions';
|
||||||
|
|
||||||
|
if (typeof initActions === 'function') {
|
||||||
|
initActions(statusEl, actionsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.appendChild(header);
|
||||||
|
root.appendChild(descEl);
|
||||||
|
root.appendChild(actionsEl);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildButton(label, onClick, primary) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = `setup-btn${primary ? ' primary' : ''}`;
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener('click', onClick);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDependencies() {
|
||||||
|
if (depsStatusEl) depsStatusEl.textContent = 'Checking...';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/dependencies');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
dependencyReady = false;
|
||||||
|
} else {
|
||||||
|
const modes = Object.values(data.modes || {});
|
||||||
|
dependencyReady = modes.every((modeInfo) => Boolean(modeInfo.ready));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dependencyReady = false;
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Dependency Check', err, {
|
||||||
|
onRetry: checkDependencies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStatuses() {
|
||||||
|
const hasLocation = hasValidLocation();
|
||||||
|
const notifications = notificationStatus();
|
||||||
|
const hasDefaultMode = Boolean(localStorage.getItem(DEFAULT_MODE_KEY));
|
||||||
|
|
||||||
|
setStatus(locationStatusEl, hasLocation, hasLocation ? 'Configured' : 'Not set');
|
||||||
|
setStatus(notifyStatusEl, notifications.ready, notifications.label);
|
||||||
|
setStatus(modeStatusEl, hasDefaultMode, hasDefaultMode ? localStorage.getItem(DEFAULT_MODE_KEY) : 'Not set');
|
||||||
|
|
||||||
|
if (dependencyReady === null) {
|
||||||
|
checkDependencies();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(depsStatusEl, dependencyReady, dependencyReady ? 'Ready' : 'Missing tools');
|
||||||
|
|
||||||
|
const doneCount = Number(dependencyReady) + Number(hasLocation) + Number(notifications.ready) + Number(hasDefaultMode);
|
||||||
|
const completeBtn = document.getElementById('setupCompleteBtn');
|
||||||
|
if (completeBtn) {
|
||||||
|
completeBtn.textContent = doneCount >= 3 ? 'Mark Setup Complete' : 'Complete Anyway';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(el, done, label) {
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.toggle('done', Boolean(done));
|
||||||
|
el.textContent = String(label || (done ? 'Done' : 'Pending'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidLocation() {
|
||||||
|
const rawLat = localStorage.getItem('observerLat');
|
||||||
|
const rawLon = localStorage.getItem('observerLon');
|
||||||
|
|
||||||
|
if (rawLat === null || rawLon === null || rawLat === '' || rawLon === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = Number(rawLat);
|
||||||
|
const lon = Number(rawLon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||||
|
|
||||||
|
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationStatus() {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
return { ready: true, label: 'Unsupported (optional)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return { ready: true, label: 'Enabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
return { ready: false, label: 'Blocked in browser' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ready: false, label: 'Permission needed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestNotifications() {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
refreshStatuses();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Notifications', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeSetup() {
|
||||||
|
localStorage.setItem(COMPLETE_KEY, 'true');
|
||||||
|
close();
|
||||||
|
|
||||||
|
if (typeof showAppToast === 'function') {
|
||||||
|
showAppToast('Setup Complete', 'You can revisit these options in Settings.', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
overlayEl.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
overlayEl.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
refreshStatuses,
|
||||||
|
completeSetup,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
FirstRunSetup.init();
|
||||||
|
});
|
||||||
@@ -96,7 +96,10 @@ const RecordingUI = (function() {
|
|||||||
<div class="settings-feed-item">
|
<div class="settings-feed-item">
|
||||||
<div class="settings-feed-title">
|
<div class="settings-feed-title">
|
||||||
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
<div style="display:flex; gap:6px;">
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.openReplay('${rec.id}')">Replay</button>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||||
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||||
@@ -109,6 +112,17 @@ const RecordingUI = (function() {
|
|||||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openReplay(sessionId) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
localStorage.setItem('analyticsReplaySession', sessionId);
|
||||||
|
if (typeof hideSettings === 'function') hideSettings();
|
||||||
|
if (typeof switchMode === 'function') {
|
||||||
|
switchMode('analytics', { updateUrl: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/?mode=analytics';
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return String(str)
|
return String(str)
|
||||||
@@ -126,6 +140,7 @@ const RecordingUI = (function() {
|
|||||||
stop,
|
stop,
|
||||||
stopById,
|
stopById,
|
||||||
download,
|
download,
|
||||||
|
openReplay,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
const RunState = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const REFRESH_MS = 5000;
|
||||||
|
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz'];
|
||||||
|
const MODE_ALIASES = {
|
||||||
|
bt: 'bluetooth',
|
||||||
|
bt_locate: 'bluetooth',
|
||||||
|
btlocate: 'bluetooth',
|
||||||
|
aircraft: 'adsb',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeLabels = {
|
||||||
|
pager: 'Pager',
|
||||||
|
sensor: '433',
|
||||||
|
wifi: 'WiFi',
|
||||||
|
bluetooth: 'BT',
|
||||||
|
adsb: 'ADS-B',
|
||||||
|
ais: 'AIS',
|
||||||
|
acars: 'ACARS',
|
||||||
|
vdl2: 'VDL2',
|
||||||
|
aprs: 'APRS',
|
||||||
|
dsc: 'DSC',
|
||||||
|
dmr: 'DMR',
|
||||||
|
subghz: 'SubGHz',
|
||||||
|
};
|
||||||
|
|
||||||
|
let refreshTimer = null;
|
||||||
|
let activeMode = null;
|
||||||
|
let lastHealth = null;
|
||||||
|
let lastErrorToastAt = 0;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const root = document.getElementById('runStateStrip');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
wireActions();
|
||||||
|
wrapModeSwitch();
|
||||||
|
activeMode = inferCurrentMode();
|
||||||
|
renderHealth(null);
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
if (!refreshTimer) {
|
||||||
|
refreshTimer = window.setInterval(refresh, REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireActions() {
|
||||||
|
const refreshBtn = document.getElementById('runStateRefreshBtn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsBtn = document.getElementById('runStateSettingsBtn');
|
||||||
|
if (settingsBtn) {
|
||||||
|
settingsBtn.addEventListener('click', () => {
|
||||||
|
if (typeof showSettings === 'function') {
|
||||||
|
showSettings();
|
||||||
|
if (typeof switchSettingsTab === 'function') {
|
||||||
|
switchSettingsTab('tools');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapModeSwitch() {
|
||||||
|
if (typeof window.switchMode !== 'function') return;
|
||||||
|
if (window.switchMode.__runStateWrapped) return;
|
||||||
|
|
||||||
|
const original = window.switchMode;
|
||||||
|
const wrapped = function(mode) {
|
||||||
|
if (mode) {
|
||||||
|
activeMode = normalizeMode(String(mode));
|
||||||
|
}
|
||||||
|
const result = original.apply(this, arguments);
|
||||||
|
markActiveChip();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
wrapped.__runStateWrapped = true;
|
||||||
|
window.switchMode = wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/health');
|
||||||
|
const data = await response.json();
|
||||||
|
lastHealth = data;
|
||||||
|
renderHealth(data);
|
||||||
|
} catch (err) {
|
||||||
|
renderHealth(null, err);
|
||||||
|
const now = Date.now();
|
||||||
|
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||||
|
lastErrorToastAt = now;
|
||||||
|
reportActionableError('Run State', err, { persistent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHealth(data, err) {
|
||||||
|
const chipsContainer = document.getElementById('runStateChips');
|
||||||
|
const summaryEl = document.getElementById('runStateSummary');
|
||||||
|
if (!chipsContainer || !summaryEl) return;
|
||||||
|
|
||||||
|
chipsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!data || data.status !== 'healthy') {
|
||||||
|
const offline = buildChip('API', false);
|
||||||
|
offline.classList.add('active');
|
||||||
|
chipsContainer.appendChild(offline);
|
||||||
|
summaryEl.textContent = err ? `Health unavailable: ${extractMessage(err)}` : 'Health unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = normalizeProcesses(data.processes || {});
|
||||||
|
for (const mode of CHIP_MODES) {
|
||||||
|
const isRunning = Boolean(processes[mode]);
|
||||||
|
chipsContainer.appendChild(buildChip(modeLabels[mode] || mode.toUpperCase(), isRunning, mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = data.data || {};
|
||||||
|
summaryEl.textContent = `Aircraft ${counts.aircraft_count || 0} | Vessels ${counts.vessel_count || 0} | WiFi ${counts.wifi_networks_count || 0} | BT ${counts.bt_devices_count || 0}`;
|
||||||
|
markActiveChip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChip(label, running, mode) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = `run-state-chip${running ? ' running' : ''}`;
|
||||||
|
if (mode) {
|
||||||
|
chip.dataset.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'dot';
|
||||||
|
chip.appendChild(dot);
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = label;
|
||||||
|
chip.appendChild(text);
|
||||||
|
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markActiveChip() {
|
||||||
|
if (!activeMode) {
|
||||||
|
activeMode = inferCurrentMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#runStateChips .run-state-chip').forEach((chip) => {
|
||||||
|
chip.classList.remove('active');
|
||||||
|
if (chip.dataset.mode && chip.dataset.mode === normalizeMode(activeMode)) {
|
||||||
|
chip.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCurrentMode() {
|
||||||
|
const modeParam = new URLSearchParams(window.location.search).get('mode');
|
||||||
|
if (modeParam) return normalizeMode(modeParam);
|
||||||
|
|
||||||
|
if (typeof window.currentMode === 'string' && window.currentMode) {
|
||||||
|
return normalizeMode(window.currentMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicator = document.getElementById('activeModeIndicator');
|
||||||
|
if (!indicator) return 'pager';
|
||||||
|
|
||||||
|
const text = indicator.textContent || '';
|
||||||
|
const normalized = text.toLowerCase();
|
||||||
|
if (normalized.includes('wifi')) return 'wifi';
|
||||||
|
if (normalized.includes('bluetooth')) return 'bluetooth';
|
||||||
|
if (normalized.includes('bt locate')) return 'bluetooth';
|
||||||
|
if (normalized.includes('ads-b')) return 'adsb';
|
||||||
|
if (normalized.includes('ais')) return 'ais';
|
||||||
|
if (normalized.includes('acars')) return 'acars';
|
||||||
|
if (normalized.includes('vdl2')) return 'vdl2';
|
||||||
|
if (normalized.includes('aprs')) return 'aprs';
|
||||||
|
if (normalized.includes('dsc')) return 'dsc';
|
||||||
|
if (normalized.includes('subghz')) return 'subghz';
|
||||||
|
if (normalized.includes('dmr')) return 'dmr';
|
||||||
|
if (normalized.includes('433')) return 'sensor';
|
||||||
|
return 'pager';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMode(mode) {
|
||||||
|
const value = String(mode || '').trim().toLowerCase();
|
||||||
|
if (!value) return 'pager';
|
||||||
|
return MODE_ALIASES[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcesses(raw) {
|
||||||
|
const processes = Object.assign({}, raw || {});
|
||||||
|
processes.bluetooth = Boolean(
|
||||||
|
processes.bluetooth ||
|
||||||
|
processes.bt ||
|
||||||
|
processes.bt_scan ||
|
||||||
|
processes.btlocate ||
|
||||||
|
processes.bt_locate
|
||||||
|
);
|
||||||
|
processes.wifi = Boolean(
|
||||||
|
processes.wifi ||
|
||||||
|
processes.wifi_scan ||
|
||||||
|
processes.wlan
|
||||||
|
);
|
||||||
|
return processes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessage(err) {
|
||||||
|
if (!err) return 'Unknown error';
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
if (err.message) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastHealth() {
|
||||||
|
return lastHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
refresh,
|
||||||
|
destroy,
|
||||||
|
getLastHealth,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
RunState.init();
|
||||||
|
});
|
||||||
@@ -22,15 +22,16 @@ const Settings = {
|
|||||||
cartodb_dark: {
|
cartodb_dark: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
subdomains: 'abcd'
|
subdomains: 'abcd',
|
||||||
|
mapTheme: 'cyber',
|
||||||
|
options: {}
|
||||||
},
|
},
|
||||||
cartodb_dark_cyan: {
|
cartodb_dark_cyan: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
subdomains: 'abcd',
|
subdomains: 'abcd',
|
||||||
options: {
|
mapTheme: 'cyber',
|
||||||
className: 'tile-layer-cyan'
|
options: {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
cartodb_light: {
|
cartodb_light: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
||||||
@@ -50,26 +51,153 @@ const Settings = {
|
|||||||
// Current settings cache
|
// Current settings cache
|
||||||
_cache: {},
|
_cache: {},
|
||||||
|
|
||||||
|
// Init guard to prevent concurrent fetch races across pages/modes
|
||||||
|
_initialized: false,
|
||||||
|
_initPromise: null,
|
||||||
|
_themeObserver: null,
|
||||||
|
_themeObserverStarted: false,
|
||||||
|
_themeObserverRaf: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tile provider key is valid.
|
||||||
|
* @param {string} provider
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isKnownTileProvider(provider) {
|
||||||
|
if (typeof provider !== 'string') return false;
|
||||||
|
const key = provider.trim();
|
||||||
|
return key === 'custom' || Object.prototype.hasOwnProperty.call(this.tileProviders, key);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize tile provider values from storage/UI.
|
||||||
|
* @param {string} provider
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
_normalizeTileProvider(provider) {
|
||||||
|
if (typeof provider !== 'string') return this.defaults['offline.tile_provider'];
|
||||||
|
const key = provider.trim();
|
||||||
|
if (this._isKnownTileProvider(key)) return key;
|
||||||
|
return this.defaults['offline.tile_provider'];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist and retrieve preferred map theme behavior for dark Carto tiles.
|
||||||
|
* Helps keep Cyber style enabled even if server-side tile provider drifts.
|
||||||
|
*/
|
||||||
|
_getMapThemePreference() {
|
||||||
|
if (typeof localStorage === 'undefined') return 'cyber';
|
||||||
|
const pref = localStorage.getItem('intercept_map_theme_pref');
|
||||||
|
if (pref === 'none' || pref === 'cyber') return pref;
|
||||||
|
return 'cyber';
|
||||||
|
},
|
||||||
|
|
||||||
|
_setMapThemePreference(pref) {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
if (pref !== 'none' && pref !== 'cyber') return;
|
||||||
|
localStorage.setItem('intercept_map_theme_pref', pref);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Cyber map theme should be considered active globally.
|
||||||
|
* @param {Object} [config]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isCyberThemeEnabled(config) {
|
||||||
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
|
return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle root class used for hard global Leaflet theming.
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_syncRootMapThemeClass(config) {
|
||||||
|
if (typeof document === 'undefined' || !document.documentElement) return;
|
||||||
|
const enabled = this._isCyberThemeEnabled(config);
|
||||||
|
document.documentElement.classList.toggle('map-cyber-enabled', enabled);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer localStorage tile settings when available to avoid stale server values.
|
||||||
|
*/
|
||||||
|
_applyLocalTileOverrides() {
|
||||||
|
const stored = localStorage.getItem('intercept_settings');
|
||||||
|
if (!stored) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const local = JSON.parse(stored) || {};
|
||||||
|
const localProvider = this._normalizeTileProvider(local['offline.tile_provider']);
|
||||||
|
if (localProvider) {
|
||||||
|
this._cache['offline.tile_provider'] = localProvider;
|
||||||
|
}
|
||||||
|
if (typeof local['offline.tile_server_url'] === 'string') {
|
||||||
|
this._cache['offline.tile_server_url'] = local['offline.tile_server_url'];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore malformed local settings and keep current cache.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize settings - load from server/localStorage
|
* Initialize settings - load from server/localStorage
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init(options = {}) {
|
||||||
try {
|
const force = Boolean(options && options.force);
|
||||||
const response = await fetch('/offline/settings');
|
|
||||||
if (response.ok) {
|
if (!force && this._initialized) {
|
||||||
const data = await response.json();
|
return this._cache;
|
||||||
this._cache = { ...this.defaults, ...data.settings };
|
|
||||||
} else {
|
|
||||||
// Fall back to localStorage
|
|
||||||
this._loadFromLocalStorage();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load settings from server, using localStorage:', e);
|
|
||||||
this._loadFromLocalStorage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._updateUI();
|
if (!force && this._initPromise) {
|
||||||
return this._cache;
|
return this._initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/offline/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this._cache = { ...this.defaults, ...data.settings };
|
||||||
|
} else {
|
||||||
|
// Fall back to localStorage
|
||||||
|
this._loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load settings from server, using localStorage:', e);
|
||||||
|
this._loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._applyLocalTileOverrides();
|
||||||
|
this._cache['offline.tile_provider'] = this._normalizeTileProvider(this._cache['offline.tile_provider']);
|
||||||
|
|
||||||
|
// If dark Carto was restored by stale server settings but user prefers Cyber,
|
||||||
|
// keep the visible provider aligned with Cyber selection.
|
||||||
|
if (this._cache['offline.tile_provider'] === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||||
|
this._cache['offline.tile_provider'] = 'cartodb_dark_cyan';
|
||||||
|
}
|
||||||
|
this._updateUI();
|
||||||
|
|
||||||
|
// Re-apply map theme to already-registered maps in case init happened after map creation.
|
||||||
|
const allMaps = this._collectMaps();
|
||||||
|
if (allMaps.length > 0) {
|
||||||
|
const config = this.getTileConfig();
|
||||||
|
allMaps.forEach((map) => this._applyMapTheme(map, config));
|
||||||
|
}
|
||||||
|
const activeConfig = this.getTileConfig();
|
||||||
|
this._syncRootMapThemeClass(activeConfig);
|
||||||
|
this._applyThemeToAllContainers(activeConfig);
|
||||||
|
this._ensureThemeObserver();
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
|
return this._cache;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._initPromise;
|
||||||
|
} finally {
|
||||||
|
this._initPromise = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,11 +227,14 @@ const Settings = {
|
|||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
try {
|
try {
|
||||||
await fetch('/offline/settings', {
|
const response = await fetch('/offline/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key, value })
|
body: JSON.stringify({ key, value })
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Save failed (${response.status})`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to save setting to server:', e);
|
console.warn('Failed to save setting to server:', e);
|
||||||
}
|
}
|
||||||
@@ -152,6 +283,16 @@ const Settings = {
|
|||||||
* Set tile provider
|
* Set tile provider
|
||||||
*/
|
*/
|
||||||
async setTileProvider(provider) {
|
async setTileProvider(provider) {
|
||||||
|
provider = this._normalizeTileProvider(provider);
|
||||||
|
|
||||||
|
if (provider === 'cartodb_dark_cyan') {
|
||||||
|
this._setMapThemePreference('cyber');
|
||||||
|
} else if (provider === 'cartodb_dark') {
|
||||||
|
this._setMapThemePreference('none');
|
||||||
|
} else {
|
||||||
|
this._setMapThemePreference('none');
|
||||||
|
}
|
||||||
|
|
||||||
await this._save('offline.tile_provider', provider);
|
await this._save('offline.tile_provider', provider);
|
||||||
|
|
||||||
// Show/hide custom URL input
|
// Show/hide custom URL input
|
||||||
@@ -160,10 +301,11 @@ const Settings = {
|
|||||||
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not custom and we have a map, update tiles immediately
|
// Update tiles immediately for all providers.
|
||||||
if (provider !== 'custom') {
|
this._updateMapTiles();
|
||||||
this._updateMapTiles();
|
const activeConfig = this.getTileConfig();
|
||||||
}
|
this._syncRootMapThemeClass(activeConfig);
|
||||||
|
this._applyThemeToAllContainers(activeConfig);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +320,7 @@ const Settings = {
|
|||||||
* Get current tile configuration
|
* Get current tile configuration
|
||||||
*/
|
*/
|
||||||
getTileConfig() {
|
getTileConfig() {
|
||||||
const provider = this.get('offline.tile_provider');
|
const provider = this._normalizeTileProvider(this.get('offline.tile_provider'));
|
||||||
|
|
||||||
if (provider === 'custom') {
|
if (provider === 'custom') {
|
||||||
const customUrl = this.get('offline.tile_server_url');
|
const customUrl = this.get('offline.tile_server_url');
|
||||||
@@ -189,7 +331,170 @@ const Settings = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
||||||
|
|
||||||
|
// Robust fallback: if dark Carto is active and Cyber is preferred,
|
||||||
|
// keep Cyber theme enabled even when provider temporarily reverts.
|
||||||
|
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||||
|
return { ...config, mapTheme: 'cyber' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve map theme class from tile config.
|
||||||
|
* @param {Object} config
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
_getMapThemeClass(config) {
|
||||||
|
if (!config || !config.mapTheme) return null;
|
||||||
|
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply or clear map theme styles for a Leaflet container.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_applyThemeToContainer(container, config) {
|
||||||
|
if (!container || !container.classList) return;
|
||||||
|
const tilePane = container.querySelector('.leaflet-tile-pane');
|
||||||
|
|
||||||
|
container.querySelectorAll('.intercept-map-theme-overlay').forEach((el) => el.remove());
|
||||||
|
|
||||||
|
if (tilePane && tilePane.style) {
|
||||||
|
tilePane.style.filter = '';
|
||||||
|
tilePane.style.opacity = '';
|
||||||
|
tilePane.style.willChange = '';
|
||||||
|
}
|
||||||
|
if (container.style) {
|
||||||
|
container.style.background = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('map-theme-cyber');
|
||||||
|
|
||||||
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||||
|
if (!themeClass) return;
|
||||||
|
|
||||||
|
container.classList.add(themeClass);
|
||||||
|
|
||||||
|
if (container.style) {
|
||||||
|
container.style.background = '#020813';
|
||||||
|
}
|
||||||
|
if (tilePane && tilePane.style) {
|
||||||
|
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
||||||
|
tilePane.style.opacity = '1';
|
||||||
|
tilePane.style.willChange = 'filter';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid/glow overlays are rendered via CSS pseudo elements on
|
||||||
|
// `html.map-cyber-enabled .leaflet-container` for consistent stacking.
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply/remove map theme class on a Leaflet map container.
|
||||||
|
* @param {L.Map} map
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_applyMapTheme(map, config) {
|
||||||
|
if (!map || typeof map.getContainer !== 'function') return;
|
||||||
|
const container = map.getContainer();
|
||||||
|
this._applyThemeToContainer(container, config);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply current map theme to all rendered Leaflet containers.
|
||||||
|
* Covers maps that were not explicitly registered with Settings.
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_applyThemeToAllContainers(config) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const containers = document.querySelectorAll('.leaflet-container');
|
||||||
|
if (!containers.length) return;
|
||||||
|
|
||||||
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
|
this._syncRootMapThemeClass(resolvedConfig);
|
||||||
|
containers.forEach((container) => this._applyThemeToContainer(container, resolvedConfig));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch the DOM for new Leaflet maps and apply current theme automatically.
|
||||||
|
*/
|
||||||
|
_ensureThemeObserver() {
|
||||||
|
if (this._themeObserverStarted || typeof MutationObserver === 'undefined') return;
|
||||||
|
if (typeof document === 'undefined' || !document.body) return;
|
||||||
|
|
||||||
|
const scheduleApply = () => {
|
||||||
|
if (this._themeObserverRaf && typeof cancelAnimationFrame === 'function') {
|
||||||
|
cancelAnimationFrame(this._themeObserverRaf);
|
||||||
|
}
|
||||||
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
|
this._themeObserverRaf = requestAnimationFrame(() => {
|
||||||
|
this._themeObserverRaf = null;
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._themeObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (!mutation.addedNodes || mutation.addedNodes.length === 0) continue;
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (!(node instanceof Element)) continue;
|
||||||
|
if (node.classList.contains('leaflet-container') || node.querySelector('.leaflet-container')) {
|
||||||
|
scheduleApply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._themeObserver.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this._themeObserverStarted = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all known map instances.
|
||||||
|
* @returns {L.Map[]}
|
||||||
|
*/
|
||||||
|
_collectMaps() {
|
||||||
|
const windowMaps = [
|
||||||
|
window.map,
|
||||||
|
window.leafletMap,
|
||||||
|
window.aprsMap,
|
||||||
|
window.radarMap,
|
||||||
|
window.vesselMap,
|
||||||
|
window.groundMap,
|
||||||
|
window.groundTrackMap,
|
||||||
|
window.meshMap,
|
||||||
|
window.issMap
|
||||||
|
].filter(m => m && typeof m.eachLayer === 'function');
|
||||||
|
|
||||||
|
return [...new Set([...this._registeredMaps, ...windowMaps])];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep map theme stable if map internals or layers are refreshed.
|
||||||
|
* @param {L.Map} map - Leaflet map instance
|
||||||
|
*/
|
||||||
|
_attachMapThemeHooks(map) {
|
||||||
|
if (!map || typeof map.on !== 'function' || map._interceptThemeHookBound) return;
|
||||||
|
|
||||||
|
const reapplyTheme = () => this._applyMapTheme(map);
|
||||||
|
const hookEvents = ['layeradd', 'layerremove', 'zoomend', 'resize', 'load'];
|
||||||
|
hookEvents.forEach((eventName) => map.on(eventName, reapplyTheme));
|
||||||
|
|
||||||
|
map._interceptThemeHookBound = true;
|
||||||
|
map._interceptThemeHookHandler = reapplyTheme;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,6 +505,18 @@ const Settings = {
|
|||||||
if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
|
if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
|
||||||
this._registeredMaps.push(map);
|
this._registeredMaps.push(map);
|
||||||
}
|
}
|
||||||
|
this._ensureThemeObserver();
|
||||||
|
this._attachMapThemeHooks(map);
|
||||||
|
this._applyMapTheme(map);
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
|
||||||
|
// Some maps create tile DOM asynchronously; re-apply after first paint.
|
||||||
|
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this._applyMapTheme(map);
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,6 +528,15 @@ const Settings = {
|
|||||||
if (idx > -1) {
|
if (idx > -1) {
|
||||||
this._registeredMaps.splice(idx, 1);
|
this._registeredMaps.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (map && map._interceptThemeHookBound && typeof map.off === 'function') {
|
||||||
|
const handler = map._interceptThemeHookHandler;
|
||||||
|
['layeradd', 'layerremove', 'zoomend', 'resize', 'load'].forEach((eventName) => {
|
||||||
|
map.off(eventName, handler);
|
||||||
|
});
|
||||||
|
delete map._interceptThemeHookBound;
|
||||||
|
delete map._interceptThemeHookHandler;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,31 +649,29 @@ const Settings = {
|
|||||||
if (customRow) {
|
if (customRow) {
|
||||||
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme select
|
||||||
|
const themeSelect = document.getElementById('themeSelect');
|
||||||
|
if (themeSelect) {
|
||||||
|
themeSelect.value = localStorage.getItem('intercept-theme') || 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animations toggle
|
||||||
|
const animationsEnabled = document.getElementById('animationsEnabled');
|
||||||
|
if (animationsEnabled) {
|
||||||
|
animationsEnabled.checked = localStorage.getItem('intercept-animations') !== 'off';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update map tiles on all known maps
|
* Update map tiles on all known maps
|
||||||
*/
|
*/
|
||||||
_updateMapTiles() {
|
_updateMapTiles() {
|
||||||
// Combine registered maps with common window map variables
|
const allMaps = this._collectMaps();
|
||||||
const windowMaps = [
|
|
||||||
window.map,
|
|
||||||
window.leafletMap,
|
|
||||||
window.aprsMap,
|
|
||||||
window.radarMap,
|
|
||||||
window.vesselMap,
|
|
||||||
window.groundMap,
|
|
||||||
window.groundTrackMap,
|
|
||||||
window.meshMap,
|
|
||||||
window.issMap
|
|
||||||
].filter(m => m && typeof m.eachLayer === 'function');
|
|
||||||
|
|
||||||
// Combine with registered maps, removing duplicates
|
|
||||||
const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])];
|
|
||||||
|
|
||||||
if (allMaps.length === 0) return;
|
if (allMaps.length === 0) return;
|
||||||
|
|
||||||
const config = this.getTileConfig();
|
const config = this.getTileConfig();
|
||||||
|
this._syncRootMapThemeClass(config);
|
||||||
|
|
||||||
allMaps.forEach(map => {
|
allMaps.forEach(map => {
|
||||||
// Remove existing tile layers
|
// Remove existing tile layers
|
||||||
@@ -368,7 +692,10 @@ const Settings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
L.tileLayer(config.url, options).addTo(map);
|
L.tileLayer(config.url, options).addTo(map);
|
||||||
|
this._applyMapTheme(map, config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._applyThemeToAllContainers(config);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -423,10 +750,16 @@ const Settings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Settings modal functions
|
// Settings modal functions
|
||||||
|
let lastSettingsFocusEl = null;
|
||||||
|
|
||||||
function showSettings() {
|
function showSettings() {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
lastSettingsFocusEl = document.activeElement;
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
const content = modal.querySelector('.settings-content');
|
||||||
|
if (content) content.focus();
|
||||||
Settings.init().then(() => {
|
Settings.init().then(() => {
|
||||||
Settings.checkAssets();
|
Settings.checkAssets();
|
||||||
});
|
});
|
||||||
@@ -437,18 +770,27 @@ function hideSettings() {
|
|||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
if (lastSettingsFocusEl && typeof lastSettingsFocusEl.focus === 'function') {
|
||||||
|
lastSettingsFocusEl.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchSettingsTab(tabName) {
|
function switchSettingsTab(tabName) {
|
||||||
// Update tab buttons
|
// Update tab buttons
|
||||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
const isActive = tab.dataset.tab === tabName;
|
||||||
|
tab.classList.toggle('active', isActive);
|
||||||
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update sections
|
// Update sections
|
||||||
document.querySelectorAll('.settings-section').forEach(section => {
|
document.querySelectorAll('.settings-section').forEach(section => {
|
||||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
const isActive = section.id === `settings-${tabName}`;
|
||||||
|
section.classList.toggle('active', isActive);
|
||||||
|
section.hidden = !isActive;
|
||||||
|
section.setAttribute('role', 'tabpanel');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load tools/dependencies when that tab is selected
|
// Load tools/dependencies when that tab is selected
|
||||||
@@ -545,11 +887,6 @@ function loadSettingsTools() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize settings on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
Settings.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Location Settings Functions
|
// Location Settings Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -582,7 +919,7 @@ function loadObserverLocation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync dashboard-specific location keys for backward compatibility
|
// Sync dashboard-specific location keys for backward compatibility
|
||||||
if (lat && lon) {
|
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
|
||||||
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||||
if (!localStorage.getItem('observerLocation')) {
|
if (!localStorage.getItem('observerLocation')) {
|
||||||
localStorage.setItem('observerLocation', locationObj);
|
localStorage.setItem('observerLocation', locationObj);
|
||||||
@@ -833,11 +1170,11 @@ function renderUpdateStatus(data) {
|
|||||||
<div style="display: grid; gap: 8px; font-size: 12px;">
|
<div style="display: grid; gap: 8px; font-size: 12px;">
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-dim);">Current Version</span>
|
<span style="color: var(--text-dim);">Current Version</span>
|
||||||
<span style="font-family: 'Space Mono', monospace; color: var(--text-primary);">v${data.current_version}</span>
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: var(--text-primary);">v${data.current_version}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-dim);">Latest Version</span>
|
<span style="color: var(--text-dim);">Latest Version</span>
|
||||||
<span style="font-family: 'Space Mono', monospace; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
||||||
</div>
|
</div>
|
||||||
${data.last_check ? `
|
${data.last_check ? `
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
@@ -907,12 +1244,17 @@ const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? sw
|
|||||||
function switchSettingsTab(tabName) {
|
function switchSettingsTab(tabName) {
|
||||||
// Update tab buttons
|
// Update tab buttons
|
||||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
const isActive = tab.dataset.tab === tabName;
|
||||||
|
tab.classList.toggle('active', isActive);
|
||||||
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update sections
|
// Update sections
|
||||||
document.querySelectorAll('.settings-section').forEach(section => {
|
document.querySelectorAll('.settings-section').forEach(section => {
|
||||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
const isActive = section.id === `settings-${tabName}`;
|
||||||
|
section.classList.toggle('active', isActive);
|
||||||
|
section.hidden = !isActive;
|
||||||
|
section.setAttribute('role', 'tabpanel');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load content based on tab
|
// Load content based on tab
|
||||||
@@ -983,3 +1325,45 @@ function toggleApiKeyVisibility() {
|
|||||||
if (!input) return;
|
if (!input) return;
|
||||||
input.type = input.type === 'password' ? 'text' : 'password';
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set theme preference from the Display settings tab
|
||||||
|
*/
|
||||||
|
function setThemePreference(value) {
|
||||||
|
document.documentElement.setAttribute('data-theme', value);
|
||||||
|
localStorage.setItem('intercept-theme', value);
|
||||||
|
|
||||||
|
const btn = document.getElementById('themeToggle');
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = value === 'light' ? '🌙' : '☀️';
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: value })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set animations preference from the Display settings tab
|
||||||
|
*/
|
||||||
|
function setAnimationsEnabled(enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
document.documentElement.removeAttribute('data-animations');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-animations', 'off');
|
||||||
|
}
|
||||||
|
localStorage.setItem('intercept-animations', enabled ? 'on' : 'off');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window._settingsEscapeHandlerBound) {
|
||||||
|
window._settingsEscapeHandlerBound = true;
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal && modal.classList.contains('active')) {
|
||||||
|
hideSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
const AppFeedback = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let stackEl = null;
|
||||||
|
let nextToastId = 1;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
ensureStack();
|
||||||
|
installGlobalHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStack() {
|
||||||
|
if (stackEl && document.body.contains(stackEl)) return stackEl;
|
||||||
|
|
||||||
|
stackEl = document.getElementById('appToastStack');
|
||||||
|
if (!stackEl) {
|
||||||
|
stackEl = document.createElement('div');
|
||||||
|
stackEl.id = 'appToastStack';
|
||||||
|
stackEl.className = 'app-toast-stack';
|
||||||
|
document.body.appendChild(stackEl);
|
||||||
|
}
|
||||||
|
return stackEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const type = normalizeType(opts.type);
|
||||||
|
const id = nextToastId++;
|
||||||
|
const durationMs = Number.isFinite(opts.durationMs) ? opts.durationMs : 6500;
|
||||||
|
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = `app-toast ${type}`;
|
||||||
|
root.dataset.toastId = String(id);
|
||||||
|
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.className = 'app-toast-title';
|
||||||
|
titleEl.textContent = String(opts.title || defaultTitle(type));
|
||||||
|
root.appendChild(titleEl);
|
||||||
|
|
||||||
|
const msgEl = document.createElement('div');
|
||||||
|
msgEl.className = 'app-toast-msg';
|
||||||
|
msgEl.textContent = String(opts.message || '');
|
||||||
|
root.appendChild(msgEl);
|
||||||
|
|
||||||
|
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean).slice(0, 3) : [];
|
||||||
|
if (actions.length > 0) {
|
||||||
|
const actionsEl = document.createElement('div');
|
||||||
|
actionsEl.className = 'app-toast-actions';
|
||||||
|
for (const action of actions) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = String(action.label || 'Action');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
if (typeof action.onClick === 'function') {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
removeToast(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(btn);
|
||||||
|
}
|
||||||
|
root.appendChild(actionsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureStack().appendChild(root);
|
||||||
|
|
||||||
|
if (durationMs > 0) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeToast(id) {
|
||||||
|
if (!stackEl) return;
|
||||||
|
const toastEl = stackEl.querySelector(`[data-toast-id="${id}"]`);
|
||||||
|
if (!toastEl) return;
|
||||||
|
toastEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportError(context, error, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const message = extractMessage(error);
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
if (isSettingsError(message)) {
|
||||||
|
actions.push({
|
||||||
|
label: 'Open Settings',
|
||||||
|
onClick: () => {
|
||||||
|
if (typeof showSettings === 'function') {
|
||||||
|
showSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNetworkError(message)) {
|
||||||
|
actions.push({
|
||||||
|
label: 'Retry',
|
||||||
|
onClick: () => {
|
||||||
|
if (typeof opts.onRetry === 'function') {
|
||||||
|
opts.onRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof opts.extraAction === 'function' && opts.extraActionLabel) {
|
||||||
|
actions.push({
|
||||||
|
label: String(opts.extraActionLabel),
|
||||||
|
onClick: opts.extraAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toast({
|
||||||
|
type: 'error',
|
||||||
|
title: context || 'Action Failed',
|
||||||
|
message,
|
||||||
|
actions,
|
||||||
|
durationMs: opts.persistent ? 0 : 8500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installGlobalHandlers() {
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
const target = event && event.target;
|
||||||
|
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = extractMessage(event && event.error) || String(event.message || 'Unknown error');
|
||||||
|
if (shouldIgnore(message)) return;
|
||||||
|
toast({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Unhandled Error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const message = extractMessage(event && event.reason);
|
||||||
|
if (shouldIgnore(message)) return;
|
||||||
|
toast({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Promise Rejection',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(type) {
|
||||||
|
const t = String(type || 'info').toLowerCase();
|
||||||
|
if (t === 'error' || t === 'warning') return t;
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTitle(type) {
|
||||||
|
if (type === 'error') return 'Error';
|
||||||
|
if (type === 'warning') return 'Warning';
|
||||||
|
return 'Notice';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessage(error) {
|
||||||
|
if (!error) return 'Unknown error';
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error instanceof Error) return error.message || error.name;
|
||||||
|
if (typeof error.message === 'string') return error.message;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnore(message) {
|
||||||
|
const text = String(message || '').toLowerCase();
|
||||||
|
return text.includes('script error') || text.includes('resizeobserver loop limit exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCollectionState(container, options) {
|
||||||
|
if (!container) return null;
|
||||||
|
const opts = options || {};
|
||||||
|
const type = String(opts.type || 'empty').toLowerCase();
|
||||||
|
const message = String(opts.message || (type === 'loading' ? 'Loading...' : 'No data available'));
|
||||||
|
const className = opts.className || `app-collection-state is-${type}`;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (container.tagName === 'TBODY') {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'app-collection-state-row';
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
const columns = Number.isFinite(opts.columns) ? opts.columns : 1;
|
||||||
|
cell.colSpan = Math.max(1, columns);
|
||||||
|
const state = document.createElement('div');
|
||||||
|
state.className = className;
|
||||||
|
state.textContent = message;
|
||||||
|
cell.appendChild(state);
|
||||||
|
row.appendChild(cell);
|
||||||
|
container.appendChild(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = document.createElement('div');
|
||||||
|
state.className = className;
|
||||||
|
state.textContent = message;
|
||||||
|
container.appendChild(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetworkError(message) {
|
||||||
|
const text = String(message || '').toLowerCase();
|
||||||
|
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSettingsError(message) {
|
||||||
|
const text = String(message || '').toLowerCase();
|
||||||
|
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
toast,
|
||||||
|
reportError,
|
||||||
|
removeToast,
|
||||||
|
renderCollectionState,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.showAppToast = function(title, message, type) {
|
||||||
|
return AppFeedback.toast({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.reportActionableError = function(context, error, options) {
|
||||||
|
return AppFeedback.reportError(context, error, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.renderCollectionState = function(container, options) {
|
||||||
|
return AppFeedback.renderCollectionState(container, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
AppFeedback.init();
|
||||||
|
});
|
||||||
@@ -81,6 +81,7 @@ const Updater = {
|
|||||||
showUpdateToast(data) {
|
showUpdateToast(data) {
|
||||||
// Remove existing toast if present
|
// Remove existing toast if present
|
||||||
this.hideToast();
|
this.hideToast();
|
||||||
|
const latestVersion = this._escape(data.latest_version || '');
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = 'update-toast';
|
toast.className = 'update-toast';
|
||||||
@@ -99,7 +100,7 @@ const Updater = {
|
|||||||
<button class="update-toast-close" onclick="Updater.dismissUpdate()">×</button>
|
<button class="update-toast-close" onclick="Updater.dismissUpdate()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-toast-body">
|
<div class="update-toast-body">
|
||||||
Version <strong>${data.latest_version}</strong> is ready
|
Version <strong>${latestVersion}</strong> is ready
|
||||||
</div>
|
</div>
|
||||||
<div class="update-toast-actions">
|
<div class="update-toast-actions">
|
||||||
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
|
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
|
||||||
@@ -177,6 +178,9 @@ const Updater = {
|
|||||||
|
|
||||||
const data = this._updateData;
|
const data = this._updateData;
|
||||||
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
||||||
|
const safeCurrentVersion = this._escape(data.current_version || '');
|
||||||
|
const safeLatestVersion = this._escape(data.latest_version || '');
|
||||||
|
const safeReleaseUrl = this._safeUrl(data.release_url || '');
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'update-modal-overlay';
|
modal.className = 'update-modal-overlay';
|
||||||
@@ -203,7 +207,7 @@ const Updater = {
|
|||||||
<div class="update-version-info">
|
<div class="update-version-info">
|
||||||
<div class="update-version-current">
|
<div class="update-version-current">
|
||||||
<span class="update-version-label">Current</span>
|
<span class="update-version-label">Current</span>
|
||||||
<span class="update-version-value">v${data.current_version}</span>
|
<span class="update-version-value">v${safeCurrentVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-version-arrow">
|
<div class="update-version-arrow">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -213,7 +217,7 @@ const Updater = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="update-version-latest">
|
<div class="update-version-latest">
|
||||||
<span class="update-version-label">Latest</span>
|
<span class="update-version-label">Latest</span>
|
||||||
<span class="update-version-value update-version-new">v${data.latest_version}</span>
|
<span class="update-version-value update-version-new">v${safeLatestVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -251,7 +255,7 @@ const Updater = {
|
|||||||
<div class="update-result" id="updateResult" style="display: none;"></div>
|
<div class="update-result" id="updateResult" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-modal-footer">
|
<div class="update-modal-footer">
|
||||||
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
|
<a href="${safeReleaseUrl || '#'}" target="_blank" class="update-modal-link" ${!safeReleaseUrl ? 'style="display:none"' : ''}>
|
||||||
View on GitHub
|
View on GitHub
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
@@ -361,6 +365,8 @@ const Updater = {
|
|||||||
if (!resultEl) return;
|
if (!resultEl) return;
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
resultEl.style.display = 'block';
|
||||||
|
const safeMessage = this._escape(data.message || data.error || 'An error occurred during the update.');
|
||||||
|
const safeDetails = data.details ? this._escape(String(data.details).substring(0, 200)) : '';
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
if (data.updated) {
|
if (data.updated) {
|
||||||
@@ -390,7 +396,7 @@ const Updater = {
|
|||||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
|
<div class="update-result-text">${this._escape(data.message || 'Already up to date.')}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -406,7 +412,7 @@ const Updater = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="update-result-text">
|
<div class="update-result-text">
|
||||||
<strong>Manual update required</strong><br>
|
<strong>Manual update required</strong><br>
|
||||||
${data.message || 'Please download the latest release from GitHub.'}
|
${safeMessage || 'Please download the latest release from GitHub.'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
@@ -421,8 +427,8 @@ const Updater = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="update-result-text">
|
<div class="update-result-text">
|
||||||
<strong>Update failed</strong><br>
|
<strong>Update failed</strong><br>
|
||||||
${data.message || data.error || 'An error occurred during the update.'}
|
${safeMessage}
|
||||||
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
|
${safeDetails ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + safeDetails + '</code>' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -467,6 +473,28 @@ const Updater = {
|
|||||||
return '<p>' + html + '</p>';
|
return '<p>' + html + '</p>';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_escape(value) {
|
||||||
|
return String(value == null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
},
|
||||||
|
|
||||||
|
_safeUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
|
return parsed.href;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual trigger for settings panel
|
* Manual trigger for settings panel
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
/**
|
||||||
|
* Analytics Dashboard Module
|
||||||
|
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
|
||||||
|
*/
|
||||||
|
const Analytics = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let refreshTimer = null;
|
||||||
|
let replayTimer = null;
|
||||||
|
let replaySessions = [];
|
||||||
|
let replayEvents = [];
|
||||||
|
let replayIndex = 0;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
refresh();
|
||||||
|
loadReplaySessions();
|
||||||
|
if (!refreshTimer) {
|
||||||
|
refreshTimer = setInterval(refresh, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
pauseReplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/correlation').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
||||||
|
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
|
||||||
|
if (summary) renderSummary(summary);
|
||||||
|
if (activity) renderSparklines(activity.sparklines || {});
|
||||||
|
if (insights) renderInsights(insights);
|
||||||
|
if (patterns) renderPatterns(patterns.patterns || []);
|
||||||
|
if (alerts) renderAlerts(alerts.events || []);
|
||||||
|
if (correlations) renderCorrelations(correlations);
|
||||||
|
if (geofences) renderGeofences(geofences.zones || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(data) {
|
||||||
|
const counts = data.counts || {};
|
||||||
|
_setText('analyticsCountAdsb', counts.adsb || 0);
|
||||||
|
_setText('analyticsCountAis', counts.ais || 0);
|
||||||
|
_setText('analyticsCountWifi', counts.wifi || 0);
|
||||||
|
_setText('analyticsCountBt', counts.bluetooth || 0);
|
||||||
|
_setText('analyticsCountDsc', counts.dsc || 0);
|
||||||
|
_setText('analyticsCountAcars', counts.acars || 0);
|
||||||
|
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
||||||
|
_setText('analyticsCountAprs', counts.aprs || 0);
|
||||||
|
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
||||||
|
|
||||||
|
const health = data.health || {};
|
||||||
|
const container = document.getElementById('analyticsHealth');
|
||||||
|
if (container) {
|
||||||
|
let html = '';
|
||||||
|
const modeLabels = {
|
||||||
|
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
||||||
|
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
||||||
|
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
|
||||||
|
};
|
||||||
|
for (const [mode, info] of Object.entries(health)) {
|
||||||
|
if (mode === 'sdr_devices') continue;
|
||||||
|
const running = info && info.running;
|
||||||
|
const label = modeLabels[mode] || mode;
|
||||||
|
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const squawks = data.squawks || [];
|
||||||
|
const sqSection = document.getElementById('analyticsSquawkSection');
|
||||||
|
const sqList = document.getElementById('analyticsSquawkList');
|
||||||
|
if (sqSection && sqList) {
|
||||||
|
if (squawks.length > 0) {
|
||||||
|
sqSection.style.display = '';
|
||||||
|
sqList.innerHTML = squawks.map(s =>
|
||||||
|
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
||||||
|
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
sqSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSparklines(sparklines) {
|
||||||
|
const map = {
|
||||||
|
adsb: 'analyticsSparkAdsb',
|
||||||
|
ais: 'analyticsSparkAis',
|
||||||
|
wifi: 'analyticsSparkWifi',
|
||||||
|
bluetooth: 'analyticsSparkBt',
|
||||||
|
dsc: 'analyticsSparkDsc',
|
||||||
|
acars: 'analyticsSparkAcars',
|
||||||
|
vdl2: 'analyticsSparkVdl2',
|
||||||
|
aprs: 'analyticsSparkAprs',
|
||||||
|
meshtastic: 'analyticsSparkMesh',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [mode, elId] of Object.entries(map)) {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
if (!el) continue;
|
||||||
|
const data = sparklines[mode] || [];
|
||||||
|
if (data.length < 2) {
|
||||||
|
el.innerHTML = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const w = 100;
|
||||||
|
const h = 24;
|
||||||
|
const step = w / (data.length - 1);
|
||||||
|
const points = data.map((v, i) =>
|
||||||
|
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
||||||
|
).join(' ');
|
||||||
|
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInsights(data) {
|
||||||
|
const cards = data.cards || [];
|
||||||
|
const topChanges = data.top_changes || [];
|
||||||
|
const cardsEl = document.getElementById('analyticsInsights');
|
||||||
|
const changesEl = document.getElementById('analyticsTopChanges');
|
||||||
|
|
||||||
|
if (cardsEl) {
|
||||||
|
if (!cards.length) {
|
||||||
|
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
|
||||||
|
} else {
|
||||||
|
cardsEl.innerHTML = cards.map(c => {
|
||||||
|
const sev = _esc(c.severity || 'low');
|
||||||
|
const title = _esc(c.title || 'Insight');
|
||||||
|
const value = _esc(c.value || '--');
|
||||||
|
const label = _esc(c.label || '');
|
||||||
|
const detail = _esc(c.detail || '');
|
||||||
|
return '<div class="analytics-insight-card ' + sev + '">' +
|
||||||
|
'<div class="insight-title">' + title + '</div>' +
|
||||||
|
'<div class="insight-value">' + value + '</div>' +
|
||||||
|
'<div class="insight-label">' + label + '</div>' +
|
||||||
|
'<div class="insight-detail">' + detail + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changesEl) {
|
||||||
|
if (!topChanges.length) {
|
||||||
|
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
|
||||||
|
} else {
|
||||||
|
changesEl.innerHTML = topChanges.map(item => {
|
||||||
|
const mode = _esc(item.mode_label || item.mode || '');
|
||||||
|
const deltaRaw = Number(item.delta || 0);
|
||||||
|
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
|
||||||
|
const delta = _esc(item.signed_delta || String(deltaRaw));
|
||||||
|
const recentAvg = _esc(item.recent_avg);
|
||||||
|
const prevAvg = _esc(item.previous_avg);
|
||||||
|
return '<div class="analytics-change-row">' +
|
||||||
|
'<span class="mode">' + mode + '</span>' +
|
||||||
|
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
|
||||||
|
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPatterns(patterns) {
|
||||||
|
const container = document.getElementById('analyticsPatternList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!patterns || patterns.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabels = {
|
||||||
|
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
|
||||||
|
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = patterns
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
container.innerHTML = sorted.map(p => {
|
||||||
|
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
|
||||||
|
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
|
||||||
|
const period = _humanPeriod(Number(p.period_seconds || 0));
|
||||||
|
const occurrences = Number(p.occurrences || 0);
|
||||||
|
const deviceId = _shortId(p.device_id || '--');
|
||||||
|
return '<div class="analytics-pattern-item">' +
|
||||||
|
'<div class="pattern-main">' +
|
||||||
|
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
|
||||||
|
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="pattern-meta">' +
|
||||||
|
'<span>Period: ' + _esc(period) + '</span>' +
|
||||||
|
'<span>Hits: ' + _esc(occurrences) + '</span>' +
|
||||||
|
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlerts(events) {
|
||||||
|
const container = document.getElementById('analyticsAlertFeed');
|
||||||
|
if (!container) return;
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = events.slice(0, 20).map(e => {
|
||||||
|
const sev = e.severity || 'medium';
|
||||||
|
const title = e.title || e.event_type || 'Alert';
|
||||||
|
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
||||||
|
return '<div class="analytics-alert-item">' +
|
||||||
|
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
||||||
|
'<span>' + _esc(title) + '</span>' +
|
||||||
|
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCorrelations(data) {
|
||||||
|
const container = document.getElementById('analyticsCorrelations');
|
||||||
|
if (!container) return;
|
||||||
|
const pairs = (data && data.correlations) || [];
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = pairs.slice(0, 20).map(p => {
|
||||||
|
const conf = Math.round((p.confidence || 0) * 100);
|
||||||
|
return '<div class="analytics-correlation-pair">' +
|
||||||
|
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
||||||
|
'<span style="color:var(--text-dim)">↔</span>' +
|
||||||
|
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||||
|
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||||
|
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeofences(zones) {
|
||||||
|
const container = document.getElementById('analyticsGeofenceList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!zones || zones.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = zones.map(z =>
|
||||||
|
'<div class="geofence-zone-item">' +
|
||||||
|
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||||
|
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||||
|
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGeofence() {
|
||||||
|
const name = prompt('Zone name:');
|
||||||
|
if (!name) return;
|
||||||
|
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||||
|
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||||
|
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||||
|
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||||
|
alert('Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/analytics/geofences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGeofence(id) {
|
||||||
|
if (!confirm('Delete this geofence zone?')) return;
|
||||||
|
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportData(mode) {
|
||||||
|
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||||
|
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||||
|
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTarget() {
|
||||||
|
const input = document.getElementById('analyticsTargetQuery');
|
||||||
|
const summaryEl = document.getElementById('analyticsTargetSummary');
|
||||||
|
const q = (input && input.value || '').trim();
|
||||||
|
if (!q) {
|
||||||
|
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
||||||
|
renderTargetResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const results = data.results || [];
|
||||||
|
if (summaryEl) {
|
||||||
|
const modeCounts = data.mode_counts || {};
|
||||||
|
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
||||||
|
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
||||||
|
}
|
||||||
|
renderTargetResults(results);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (summaryEl) summaryEl.textContent = 'Search failed';
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargetResults(results) {
|
||||||
|
const container = document.getElementById('analyticsTargetResults');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!results || !results.length) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = results.map((item) => {
|
||||||
|
const title = _esc(item.title || item.id || 'Entity');
|
||||||
|
const subtitle = _esc(item.subtitle || '');
|
||||||
|
const mode = _esc(item.mode || 'unknown');
|
||||||
|
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
||||||
|
const lastSeen = _esc(item.last_seen || '');
|
||||||
|
return '<div class="analytics-target-item">' +
|
||||||
|
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
||||||
|
'<div class="meta"><span>' + subtitle + '</span>' +
|
||||||
|
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
||||||
|
(confidence ? '<span>' + confidence + '</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReplaySessions() {
|
||||||
|
const select = document.getElementById('analyticsReplaySelect');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
fetch('/recordings?limit=60')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
||||||
|
|
||||||
|
if (!replaySessions.length) {
|
||||||
|
select.innerHTML = '<option value="">No recordings</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = replaySessions.map((rec) => {
|
||||||
|
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
||||||
|
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
||||||
|
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
||||||
|
select.value = pendingReplay;
|
||||||
|
localStorage.removeItem('analyticsReplaySession');
|
||||||
|
loadReplay();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReplay() {
|
||||||
|
pauseReplay();
|
||||||
|
replayEvents = [];
|
||||||
|
replayIndex = 0;
|
||||||
|
|
||||||
|
const select = document.getElementById('analyticsReplaySelect');
|
||||||
|
const meta = document.getElementById('analyticsReplayMeta');
|
||||||
|
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||||
|
if (!select || !meta || !timeline) return;
|
||||||
|
|
||||||
|
const id = select.value;
|
||||||
|
if (!id) {
|
||||||
|
meta.textContent = 'Select a recording';
|
||||||
|
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.textContent = 'Loading replay events...';
|
||||||
|
|
||||||
|
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
replayEvents = data.events || [];
|
||||||
|
replayIndex = 0;
|
||||||
|
if (!replayEvents.length) {
|
||||||
|
meta.textContent = 'No events found in selected recording';
|
||||||
|
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = replaySessions.find((s) => s.id === id);
|
||||||
|
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
||||||
|
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
||||||
|
renderReplayWindow();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
meta.textContent = 'Replay load failed';
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playReplay() {
|
||||||
|
if (!replayEvents.length) {
|
||||||
|
loadReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replayTimer) return;
|
||||||
|
|
||||||
|
replayTimer = setInterval(() => {
|
||||||
|
if (replayIndex >= replayEvents.length - 1) {
|
||||||
|
pauseReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replayIndex += 1;
|
||||||
|
renderReplayWindow();
|
||||||
|
}, 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseReplay() {
|
||||||
|
if (replayTimer) {
|
||||||
|
clearInterval(replayTimer);
|
||||||
|
replayTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepReplay() {
|
||||||
|
if (!replayEvents.length) {
|
||||||
|
loadReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseReplay();
|
||||||
|
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
||||||
|
renderReplayWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReplayWindow() {
|
||||||
|
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||||
|
const meta = document.getElementById('analyticsReplayMeta');
|
||||||
|
if (!timeline || !meta) return;
|
||||||
|
|
||||||
|
const total = replayEvents.length;
|
||||||
|
if (!total) {
|
||||||
|
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, replayIndex - 15);
|
||||||
|
const end = Math.min(total, replayIndex + 20);
|
||||||
|
const windowed = replayEvents.slice(start, end);
|
||||||
|
|
||||||
|
timeline.innerHTML = windowed.map((row, i) => {
|
||||||
|
const absolute = start + i;
|
||||||
|
const active = absolute === replayIndex;
|
||||||
|
const eventType = _esc(row.event_type || 'event');
|
||||||
|
const mode = _esc(row.mode || '--');
|
||||||
|
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
||||||
|
const detail = summarizeReplayEvent(row.event || {});
|
||||||
|
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
||||||
|
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
||||||
|
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeReplayEvent(event) {
|
||||||
|
if (!event || typeof event !== 'object') return 'No details';
|
||||||
|
if (event.callsign) return `Callsign ${event.callsign}`;
|
||||||
|
if (event.icao) return `ICAO ${event.icao}`;
|
||||||
|
if (event.ssid) return `SSID ${event.ssid}`;
|
||||||
|
if (event.bssid) return `BSSID ${event.bssid}`;
|
||||||
|
if (event.address) return `Address ${event.address}`;
|
||||||
|
if (event.name) return `Name ${event.name}`;
|
||||||
|
const keys = Object.keys(event);
|
||||||
|
if (!keys.length) return 'No fields';
|
||||||
|
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setText(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _shortId(value) {
|
||||||
|
const text = String(value || '');
|
||||||
|
if (text.length <= 18) return text;
|
||||||
|
return text.slice(0, 8) + '...' + text.slice(-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _humanPeriod(seconds) {
|
||||||
|
if (!isFinite(seconds) || seconds <= 0) return '--';
|
||||||
|
if (seconds < 60) return Math.round(seconds) + 's';
|
||||||
|
const mins = seconds / 60;
|
||||||
|
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
|
||||||
|
const hours = mins / 60;
|
||||||
|
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
destroy,
|
||||||
|
refresh,
|
||||||
|
addGeofence,
|
||||||
|
deleteGeofence,
|
||||||
|
exportData,
|
||||||
|
searchTarget,
|
||||||
|
loadReplay,
|
||||||
|
playReplay,
|
||||||
|
pauseReplay,
|
||||||
|
stepReplay,
|
||||||
|
loadReplaySessions,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -28,7 +28,7 @@ const BluetoothMode = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Zone counts for proximity display
|
// Zone counts for proximity display
|
||||||
let zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 };
|
let zoneCounts = { immediate: 0, near: 0, far: 0 };
|
||||||
|
|
||||||
// New visualization components
|
// New visualization components
|
||||||
let radarInitialized = false;
|
let radarInitialized = false;
|
||||||
@@ -36,6 +36,13 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
// Device list filter
|
// Device list filter
|
||||||
let currentDeviceFilter = 'all';
|
let currentDeviceFilter = 'all';
|
||||||
|
let currentSearchTerm = '';
|
||||||
|
let visibleDeviceCount = 0;
|
||||||
|
let pendingDeviceFlush = false;
|
||||||
|
let selectedDeviceNeedsRefresh = false;
|
||||||
|
let filterListenersBound = false;
|
||||||
|
let listListenersBound = false;
|
||||||
|
const pendingDeviceIds = new Set();
|
||||||
|
|
||||||
// Agent support
|
// Agent support
|
||||||
let showAllAgentsMode = false;
|
let showAllAgentsMode = false;
|
||||||
@@ -111,6 +118,7 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
// Initialize device list filters
|
// Initialize device list filters
|
||||||
initDeviceFilters();
|
initDeviceFilters();
|
||||||
|
initListInteractions();
|
||||||
|
|
||||||
// Set initial panel states
|
// Set initial panel states
|
||||||
updateVisualizationPanels();
|
updateVisualizationPanels();
|
||||||
@@ -120,24 +128,62 @@ const BluetoothMode = (function() {
|
|||||||
* Initialize device list filter buttons
|
* Initialize device list filter buttons
|
||||||
*/
|
*/
|
||||||
function initDeviceFilters() {
|
function initDeviceFilters() {
|
||||||
|
if (filterListenersBound) return;
|
||||||
const filterContainer = document.getElementById('btDeviceFilters');
|
const filterContainer = document.getElementById('btDeviceFilters');
|
||||||
if (!filterContainer) return;
|
if (filterContainer) {
|
||||||
|
filterContainer.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.bt-filter-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
filterContainer.addEventListener('click', (e) => {
|
const filter = btn.dataset.filter;
|
||||||
const btn = e.target.closest('.bt-filter-btn');
|
if (!filter) return;
|
||||||
if (!btn) return;
|
|
||||||
|
|
||||||
const filter = btn.dataset.filter;
|
// Update active state
|
||||||
if (!filter) return;
|
filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
// Update active state
|
// Apply filter
|
||||||
filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active'));
|
currentDeviceFilter = filter;
|
||||||
btn.classList.add('active');
|
applyDeviceFilter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply filter
|
const searchInput = document.getElementById('btDeviceSearch');
|
||||||
currentDeviceFilter = filter;
|
if (searchInput) {
|
||||||
applyDeviceFilter();
|
searchInput.addEventListener('input', () => {
|
||||||
});
|
currentSearchTerm = searchInput.value.trim().toLowerCase();
|
||||||
|
applyDeviceFilter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filterListenersBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initListInteractions() {
|
||||||
|
if (listListenersBound) return;
|
||||||
|
if (deviceContainer) {
|
||||||
|
deviceContainer.addEventListener('click', (event) => {
|
||||||
|
const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
|
||||||
|
if (locateBtn) {
|
||||||
|
event.preventDefault();
|
||||||
|
locateById(locateBtn.dataset.locateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = event.target.closest('.bt-device-row[data-bt-device-id]');
|
||||||
|
if (!row) return;
|
||||||
|
selectDevice(row.dataset.btDeviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackerList = document.getElementById('btTrackerList');
|
||||||
|
if (trackerList) {
|
||||||
|
trackerList.addEventListener('click', (event) => {
|
||||||
|
const row = event.target.closest('.bt-tracker-item[data-device-id]');
|
||||||
|
if (!row) return;
|
||||||
|
selectDevice(row.dataset.deviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
listListenersBound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,34 +193,53 @@ const BluetoothMode = (function() {
|
|||||||
if (!deviceContainer) return;
|
if (!deviceContainer) return;
|
||||||
|
|
||||||
const cards = deviceContainer.querySelectorAll('[data-bt-device-id]');
|
const cards = deviceContainer.querySelectorAll('[data-bt-device-id]');
|
||||||
|
let visibleCount = 0;
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
const isNew = card.dataset.isNew === 'true';
|
const isNew = card.dataset.isNew === 'true';
|
||||||
const hasName = card.dataset.hasName === 'true';
|
const hasName = card.dataset.hasName === 'true';
|
||||||
const rssi = parseInt(card.dataset.rssi) || -100;
|
const rssi = parseInt(card.dataset.rssi) || -100;
|
||||||
const isTracker = card.dataset.isTracker === 'true';
|
const isTracker = card.dataset.isTracker === 'true';
|
||||||
|
const searchHaystack = (card.dataset.search || '').toLowerCase();
|
||||||
|
|
||||||
let visible = true;
|
let matchesFilter = true;
|
||||||
switch (currentDeviceFilter) {
|
switch (currentDeviceFilter) {
|
||||||
case 'new':
|
case 'new':
|
||||||
visible = isNew;
|
matchesFilter = isNew;
|
||||||
break;
|
break;
|
||||||
case 'named':
|
case 'named':
|
||||||
visible = hasName;
|
matchesFilter = hasName;
|
||||||
break;
|
break;
|
||||||
case 'strong':
|
case 'strong':
|
||||||
visible = rssi >= -70;
|
matchesFilter = rssi >= -70;
|
||||||
break;
|
break;
|
||||||
case 'trackers':
|
case 'trackers':
|
||||||
visible = isTracker;
|
matchesFilter = isTracker;
|
||||||
break;
|
break;
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
visible = true;
|
matchesFilter = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm);
|
||||||
|
const visible = matchesFilter && matchesSearch;
|
||||||
card.style.display = visible ? '' : 'none';
|
card.style.display = visible ? '' : 'none';
|
||||||
|
if (visible) visibleCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
visibleDeviceCount = visibleCount;
|
||||||
|
|
||||||
|
let stateEl = deviceContainer.querySelector('.bt-device-filter-state');
|
||||||
|
if (visibleCount === 0 && devices.size > 0) {
|
||||||
|
if (!stateEl) {
|
||||||
|
stateEl = document.createElement('div');
|
||||||
|
stateEl.className = 'bt-device-filter-state app-collection-state is-empty';
|
||||||
|
deviceContainer.appendChild(stateEl);
|
||||||
|
}
|
||||||
|
stateEl.textContent = 'No devices match current filters';
|
||||||
|
} else if (stateEl) {
|
||||||
|
stateEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Update visible count
|
// Update visible count
|
||||||
updateFilteredCount();
|
updateFilteredCount();
|
||||||
}
|
}
|
||||||
@@ -186,12 +251,8 @@ const BluetoothMode = (function() {
|
|||||||
const countEl = document.getElementById('btDeviceListCount');
|
const countEl = document.getElementById('btDeviceListCount');
|
||||||
if (!countEl || !deviceContainer) return;
|
if (!countEl || !deviceContainer) return;
|
||||||
|
|
||||||
if (currentDeviceFilter === 'all') {
|
const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0;
|
||||||
countEl.textContent = devices.size;
|
countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size;
|
||||||
} else {
|
|
||||||
const visible = deviceContainer.querySelectorAll('[data-bt-device-id]:not([style*="display: none"])').length;
|
|
||||||
countEl.textContent = visible + '/' + devices.size;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -309,28 +370,18 @@ const BluetoothMode = (function() {
|
|||||||
* Update proximity zone counts (simple HTML, no canvas)
|
* Update proximity zone counts (simple HTML, no canvas)
|
||||||
*/
|
*/
|
||||||
function updateProximityZones() {
|
function updateProximityZones() {
|
||||||
zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 };
|
zoneCounts = { immediate: 0, near: 0, far: 0 };
|
||||||
|
|
||||||
devices.forEach(device => {
|
devices.forEach(device => {
|
||||||
const rssi = device.rssi_current;
|
const rssi = device.rssi_current;
|
||||||
if (rssi == null) return;
|
if (rssi == null) return;
|
||||||
|
|
||||||
if (rssi >= -40) zoneCounts.veryClose++;
|
if (rssi >= -50) zoneCounts.immediate++;
|
||||||
else if (rssi >= -55) zoneCounts.close++;
|
else if (rssi >= -70) zoneCounts.near++;
|
||||||
else if (rssi >= -70) zoneCounts.nearby++;
|
|
||||||
else zoneCounts.far++;
|
else zoneCounts.far++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update DOM elements
|
updateProximityZoneCounts(zoneCounts);
|
||||||
const veryCloseEl = document.getElementById('btZoneVeryClose');
|
|
||||||
const closeEl = document.getElementById('btZoneClose');
|
|
||||||
const nearbyEl = document.getElementById('btZoneNearby');
|
|
||||||
const farEl = document.getElementById('btZoneFar');
|
|
||||||
|
|
||||||
if (veryCloseEl) veryCloseEl.textContent = zoneCounts.veryClose;
|
|
||||||
if (closeEl) closeEl.textContent = zoneCounts.close;
|
|
||||||
if (nearbyEl) nearbyEl.textContent = zoneCounts.nearby;
|
|
||||||
if (farEl) farEl.textContent = zoneCounts.far;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Currently selected device
|
// Currently selected device
|
||||||
@@ -356,7 +407,9 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
// Update panel elements
|
// Update panel elements
|
||||||
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
||||||
document.getElementById('btDetailAddress').textContent = device.address;
|
document.getElementById('btDetailAddress').textContent = isUuidAddress(device)
|
||||||
|
? 'CB: ' + device.address
|
||||||
|
: device.address;
|
||||||
|
|
||||||
// RSSI
|
// RSSI
|
||||||
const rssiEl = document.getElementById('btDetailRssi');
|
const rssiEl = document.getElementById('btDetailRssi');
|
||||||
@@ -458,8 +511,98 @@ const BluetoothMode = (function() {
|
|||||||
? new Date(device.last_seen).toLocaleTimeString()
|
? new Date(device.last_seen).toLocaleTimeString()
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
|
// New stat cells
|
||||||
|
document.getElementById('btDetailTxPower').textContent = device.tx_power != null
|
||||||
|
? device.tx_power + ' dBm' : '--';
|
||||||
|
document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null
|
||||||
|
? device.seen_rate.toFixed(1) + '/min' : '--';
|
||||||
|
|
||||||
|
// Stability from variance
|
||||||
|
const stabilityEl = document.getElementById('btDetailStability');
|
||||||
|
if (device.rssi_variance != null) {
|
||||||
|
let stabLabel, stabColor;
|
||||||
|
if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; }
|
||||||
|
else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; }
|
||||||
|
else { stabLabel = 'Unstable'; stabColor = '#ef4444'; }
|
||||||
|
stabilityEl.textContent = stabLabel;
|
||||||
|
stabilityEl.style.color = stabColor;
|
||||||
|
} else {
|
||||||
|
stabilityEl.textContent = '--';
|
||||||
|
stabilityEl.style.color = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance with confidence
|
||||||
|
const distEl = document.getElementById('btDetailDistance');
|
||||||
|
if (device.estimated_distance_m != null) {
|
||||||
|
const confPct = Math.round((device.distance_confidence || 0) * 100);
|
||||||
|
distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%';
|
||||||
|
} else {
|
||||||
|
distEl.textContent = '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appearance badge
|
||||||
|
if (device.appearance_name) {
|
||||||
|
badgesHtml += '<span class="bt-detail-badge flag">' + escapeHtml(device.appearance_name) + '</span>';
|
||||||
|
badgesEl.innerHTML = badgesHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster indicator
|
||||||
|
const macClusterEl = document.getElementById('btDetailMacCluster');
|
||||||
|
if (macClusterEl) {
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
macClusterEl.textContent = device.mac_cluster_count + ' MACs';
|
||||||
|
macClusterEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
macClusterEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service data inspector
|
||||||
|
const inspectorEl = document.getElementById('btDetailServiceInspector');
|
||||||
|
const inspectorContent = document.getElementById('btInspectorContent');
|
||||||
|
if (inspectorEl && inspectorContent) {
|
||||||
|
const hasData = device.manufacturer_bytes || device.appearance != null ||
|
||||||
|
(device.service_data && Object.keys(device.service_data).length > 0);
|
||||||
|
if (hasData) {
|
||||||
|
inspectorEl.style.display = '';
|
||||||
|
let inspHtml = '';
|
||||||
|
if (device.appearance != null) {
|
||||||
|
const name = device.appearance_name || '';
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Appearance</span><span class="bt-inspector-value">0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.manufacturer_bytes) {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Mfr Data</span><span class="bt-inspector-value">' + escapeHtml(device.manufacturer_bytes) + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.service_data) {
|
||||||
|
Object.entries(device.service_data).forEach(([uuid, hex]) => {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">' + escapeHtml(uuid) + '</span><span class="bt-inspector-value">' + escapeHtml(hex) + '</span></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
inspectorContent.innerHTML = inspHtml;
|
||||||
|
} else {
|
||||||
|
inspectorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateWatchlistButton(device);
|
updateWatchlistButton(device);
|
||||||
|
|
||||||
|
// IRK
|
||||||
|
const irkContainer = document.getElementById('btDetailIrk');
|
||||||
|
if (irkContainer) {
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkContainer.style.display = 'block';
|
||||||
|
const irkVal = document.getElementById('btDetailIrkValue');
|
||||||
|
if (irkVal) {
|
||||||
|
const label = device.irk_source_name
|
||||||
|
? device.irk_source_name + ' — ' + device.irk_hex
|
||||||
|
: device.irk_hex;
|
||||||
|
irkVal.textContent = label;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
irkContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const servicesContainer = document.getElementById('btDetailServices');
|
const servicesContainer = document.getElementById('btDetailServices');
|
||||||
const servicesList = document.getElementById('btDetailServicesList');
|
const servicesList = document.getElementById('btDetailServicesList');
|
||||||
@@ -600,9 +743,25 @@ const BluetoothMode = (function() {
|
|||||||
if (parts.length === 6) {
|
if (parts.length === 6) {
|
||||||
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
||||||
}
|
}
|
||||||
|
// CoreBluetooth UUID format (8-4-4-4-12)
|
||||||
|
if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) {
|
||||||
|
return address.substring(0, 8) + '...';
|
||||||
|
}
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUuidAddress(device) {
|
||||||
|
return device.address_type === 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(device) {
|
||||||
|
if (!device || !device.address) return '--';
|
||||||
|
if (isUuidAddress(device)) {
|
||||||
|
return device.address.substring(0, 8) + '-...' + device.address.slice(-4);
|
||||||
|
}
|
||||||
|
return device.address;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check system capabilities
|
* Check system capabilities
|
||||||
*/
|
*/
|
||||||
@@ -660,6 +819,12 @@ const BluetoothMode = (function() {
|
|||||||
hideCapabilityWarning();
|
hideCapabilityWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide Ubertooth option based on capabilities
|
||||||
|
const ubertoothOption = document.getElementById('btScanModeUbertooth');
|
||||||
|
if (ubertoothOption) {
|
||||||
|
ubertoothOption.style.display = data.has_ubertooth ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (scanModeSelect && data.preferred_backend) {
|
if (scanModeSelect && data.preferred_backend) {
|
||||||
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
||||||
if (option) option.selected = true;
|
if (option) option.selected = true;
|
||||||
@@ -802,9 +967,20 @@ const BluetoothMode = (function() {
|
|||||||
if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
|
if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
|
||||||
|
|
||||||
if (scanning && deviceContainer) {
|
if (scanning && deviceContainer) {
|
||||||
deviceContainer.innerHTML = '';
|
pendingDeviceIds.clear();
|
||||||
|
selectedDeviceNeedsRefresh = false;
|
||||||
|
pendingDeviceFlush = false;
|
||||||
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' });
|
||||||
|
} else {
|
||||||
|
deviceContainer.innerHTML = '';
|
||||||
|
}
|
||||||
devices.clear();
|
devices.clear();
|
||||||
resetStats();
|
resetStats();
|
||||||
|
} else if (!scanning && deviceContainer && devices.size === 0) {
|
||||||
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusDot = document.getElementById('statusDot');
|
const statusDot = document.getElementById('statusDot');
|
||||||
@@ -820,8 +996,10 @@ const BluetoothMode = (function() {
|
|||||||
weak: 0,
|
weak: 0,
|
||||||
trackers: []
|
trackers: []
|
||||||
};
|
};
|
||||||
|
visibleDeviceCount = 0;
|
||||||
updateVisualizationPanels();
|
updateVisualizationPanels();
|
||||||
updateProximityZones();
|
updateProximityZones();
|
||||||
|
updateFilteredCount();
|
||||||
|
|
||||||
// Clear radar
|
// Clear radar
|
||||||
if (radarInitialized && typeof ProximityRadar !== 'undefined') {
|
if (radarInitialized && typeof ProximityRadar !== 'undefined') {
|
||||||
@@ -970,14 +1148,40 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
function handleDeviceUpdate(device) {
|
function handleDeviceUpdate(device) {
|
||||||
devices.set(device.device_id, device);
|
devices.set(device.device_id, device);
|
||||||
renderDevice(device);
|
pendingDeviceIds.add(device.device_id);
|
||||||
updateDeviceCount();
|
if (selectedDeviceId === device.device_id) {
|
||||||
updateStatsFromDevices();
|
selectedDeviceNeedsRefresh = true;
|
||||||
updateVisualizationPanels();
|
}
|
||||||
updateProximityZones();
|
scheduleDeviceFlush();
|
||||||
|
}
|
||||||
|
|
||||||
// Update new proximity radar
|
function scheduleDeviceFlush() {
|
||||||
updateRadar();
|
if (pendingDeviceFlush) return;
|
||||||
|
pendingDeviceFlush = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
pendingDeviceFlush = false;
|
||||||
|
|
||||||
|
pendingDeviceIds.forEach((deviceId) => {
|
||||||
|
const device = devices.get(deviceId);
|
||||||
|
if (device) {
|
||||||
|
renderDevice(device, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pendingDeviceIds.clear();
|
||||||
|
|
||||||
|
applyDeviceFilter();
|
||||||
|
updateDeviceCount();
|
||||||
|
updateStatsFromDevices();
|
||||||
|
updateVisualizationPanels();
|
||||||
|
updateProximityZones();
|
||||||
|
updateRadar();
|
||||||
|
|
||||||
|
if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) {
|
||||||
|
showDeviceDetail(selectedDeviceId);
|
||||||
|
}
|
||||||
|
selectedDeviceNeedsRefresh = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1030,13 +1234,41 @@ const BluetoothMode = (function() {
|
|||||||
if (mediumCount) mediumCount.textContent = deviceStats.medium;
|
if (mediumCount) mediumCount.textContent = deviceStats.medium;
|
||||||
if (weakCount) weakCount.textContent = deviceStats.weak;
|
if (weakCount) weakCount.textContent = deviceStats.weak;
|
||||||
|
|
||||||
|
// Device summary strip
|
||||||
|
const totalEl = document.getElementById('btSummaryTotal');
|
||||||
|
const newEl = document.getElementById('btSummaryNew');
|
||||||
|
const trackersEl = document.getElementById('btSummaryTrackers');
|
||||||
|
const strongestEl = document.getElementById('btSummaryStrongest');
|
||||||
|
if (totalEl || newEl || trackersEl || strongestEl) {
|
||||||
|
let newCount = 0;
|
||||||
|
let strongest = null;
|
||||||
|
devices.forEach(d => {
|
||||||
|
if (!d.in_baseline) newCount++;
|
||||||
|
if (d.rssi_current != null) {
|
||||||
|
strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (totalEl) totalEl.textContent = devices.size;
|
||||||
|
if (newEl) newEl.textContent = newCount;
|
||||||
|
if (trackersEl) trackersEl.textContent = deviceStats.trackers.length;
|
||||||
|
if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`;
|
||||||
|
}
|
||||||
|
|
||||||
// Tracker Detection - Enhanced display with confidence and evidence
|
// Tracker Detection - Enhanced display with confidence and evidence
|
||||||
const trackerList = document.getElementById('btTrackerList');
|
const trackerList = document.getElementById('btTrackerList');
|
||||||
if (trackerList) {
|
if (trackerList) {
|
||||||
if (devices.size === 0) {
|
if (devices.size === 0) {
|
||||||
trackerList.innerHTML = '<div style="color:#666;padding:10px;text-align:center;font-size:11px;">Start scanning to detect trackers</div>';
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' });
|
||||||
|
} else {
|
||||||
|
trackerList.innerHTML = '<div class="app-collection-state is-empty">Start scanning to detect trackers</div>';
|
||||||
|
}
|
||||||
} else if (deviceStats.trackers.length === 0) {
|
} else if (deviceStats.trackers.length === 0) {
|
||||||
trackerList.innerHTML = '<div style="color:#22c55e;padding:10px;text-align:center;font-size:11px;">No trackers detected</div>';
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' });
|
||||||
|
} else {
|
||||||
|
trackerList.innerHTML = '<div class="app-collection-state is-empty">No trackers detected</div>';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Sort by risk score (highest first), then confidence
|
// Sort by risk score (highest first), then confidence
|
||||||
const sortedTrackers = [...deviceStats.trackers].sort((a, b) => {
|
const sortedTrackers = [...deviceStats.trackers].sort((a, b) => {
|
||||||
@@ -1048,48 +1280,38 @@ const BluetoothMode = (function() {
|
|||||||
return confB - confA;
|
return confB - confA;
|
||||||
});
|
});
|
||||||
|
|
||||||
trackerList.innerHTML = sortedTrackers.map(t => {
|
trackerList.innerHTML = sortedTrackers.map((t) => {
|
||||||
// Get tracker type badge color based on confidence
|
|
||||||
const confidence = t.tracker_confidence || 'low';
|
const confidence = t.tracker_confidence || 'low';
|
||||||
const confColor = confidence === 'high' ? '#ef4444' :
|
|
||||||
confidence === 'medium' ? '#f97316' : '#eab308';
|
|
||||||
const confBg = confidence === 'high' ? 'rgba(239,68,68,0.2)' :
|
|
||||||
confidence === 'medium' ? 'rgba(249,115,22,0.2)' : 'rgba(234,179,8,0.2)';
|
|
||||||
|
|
||||||
// Risk score indicator
|
|
||||||
const riskScore = t.risk_score || 0;
|
const riskScore = t.risk_score || 0;
|
||||||
const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#666';
|
|
||||||
|
|
||||||
// Tracker type label
|
|
||||||
const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker';
|
const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker';
|
||||||
|
|
||||||
// Build evidence tooltip (first 2 items)
|
|
||||||
const evidence = (t.tracker_evidence || []).slice(0, 2);
|
const evidence = (t.tracker_evidence || []).slice(0, 2);
|
||||||
const evidenceHtml = evidence.length > 0
|
const evidenceHtml = evidence.length > 0
|
||||||
? '<div style="font-size:9px;color:#888;margin-top:3px;font-style:italic;">' +
|
? `<div class="bt-tracker-evidence">${evidence.map((e) => `• ${escapeHtml(e)}`).join('<br>')}</div>`
|
||||||
evidence.map(e => '• ' + escapeHtml(e)).join('<br>') +
|
: '';
|
||||||
'</div>'
|
const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low';
|
||||||
|
const riskHtml = riskScore >= 0.3
|
||||||
|
? `<span class="bt-tracker-risk bt-risk-${riskClass}">RISK ${Math.round(riskScore * 100)}%</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const deviceIdEscaped = escapeHtml(t.device_id).replace(/'/g, "\\'");
|
return `
|
||||||
|
<div class="bt-tracker-item bt-tracker-confidence-${escapeHtml(confidence)}" data-device-id="${escapeAttr(t.device_id)}" role="button" tabindex="0" data-keyboard-activate="true">
|
||||||
return '<div class="bt-tracker-item" style="padding:8px;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer;" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')">' +
|
<div class="bt-tracker-row-top">
|
||||||
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
|
<div class="bt-tracker-left">
|
||||||
'<div style="display:flex;align-items:center;gap:6px;">' +
|
<span class="bt-tracker-confidence">${escapeHtml(confidence.toUpperCase())}</span>
|
||||||
'<span style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:2px 5px;border-radius:3px;font-weight:600;">' + confidence.toUpperCase() + '</span>' +
|
<span class="bt-tracker-type">${escapeHtml(trackerType)}</span>
|
||||||
'<span style="color:#fff;font-size:11px;">' + escapeHtml(trackerType) + '</span>' +
|
</div>
|
||||||
'</div>' +
|
<div class="bt-tracker-right">
|
||||||
'<div style="display:flex;align-items:center;gap:8px;">' +
|
${riskHtml}
|
||||||
(riskScore >= 0.3 ? '<span style="color:' + riskColor + ';font-size:9px;font-weight:600;">RISK ' + Math.round(riskScore * 100) + '%</span>' : '') +
|
<span class="bt-tracker-rssi">${t.rssi_current != null ? t.rssi_current : '--'} dBm</span>
|
||||||
'<span style="color:#666;font-size:10px;">' + (t.rssi_current || '--') + ' dBm</span>' +
|
</div>
|
||||||
'</div>' +
|
</div>
|
||||||
'</div>' +
|
<div class="bt-tracker-row-bottom">
|
||||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
<span class="bt-tracker-address">${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))}</span>
|
||||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' +
|
<span class="bt-tracker-seen">Seen ${t.seen_count || 0}x</span>
|
||||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
</div>
|
||||||
'</div>' +
|
${evidenceHtml}
|
||||||
evidenceHtml +
|
</div>
|
||||||
'</div>';
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1100,12 +1322,14 @@ const BluetoothMode = (function() {
|
|||||||
updateFilteredCount();
|
updateFilteredCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDevice(device) {
|
function renderDevice(device, reapplyFilter = true) {
|
||||||
if (!deviceContainer) {
|
if (!deviceContainer) {
|
||||||
deviceContainer = document.getElementById('btDeviceListContent');
|
deviceContainer = document.getElementById('btDeviceListContent');
|
||||||
if (!deviceContainer) return;
|
if (!deviceContainer) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove());
|
||||||
|
|
||||||
const escapedId = CSS.escape(device.device_id);
|
const escapedId = CSS.escape(device.device_id);
|
||||||
const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]');
|
const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]');
|
||||||
const cardHtml = createSimpleDeviceCard(device);
|
const cardHtml = createSimpleDeviceCard(device);
|
||||||
@@ -1116,8 +1340,7 @@ const BluetoothMode = (function() {
|
|||||||
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-apply filter after rendering
|
if (reapplyFilter) {
|
||||||
if (currentDeviceFilter !== 'all') {
|
|
||||||
applyDeviceFilter();
|
applyDeviceFilter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1142,10 +1365,17 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
const displayName = device.name || formatDeviceId(device.address);
|
const displayName = device.name || formatDeviceId(device.address);
|
||||||
const name = escapeHtml(displayName);
|
const name = escapeHtml(displayName);
|
||||||
const addr = escapeHtml(device.address || 'Unknown');
|
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||||
const seenCount = device.seen_count || 0;
|
const seenCount = device.seen_count || 0;
|
||||||
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
const searchIndex = [
|
||||||
|
displayName,
|
||||||
|
device.address,
|
||||||
|
device.manufacturer_name,
|
||||||
|
device.tracker_name,
|
||||||
|
device.tracker_type,
|
||||||
|
agentName
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
|
||||||
// Protocol badge - compact
|
// Protocol badge - compact
|
||||||
const protoBadge = protocol === 'ble'
|
const protoBadge = protocol === 'ble'
|
||||||
@@ -1167,6 +1397,12 @@ const BluetoothMode = (function() {
|
|||||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRK badge - show if paired IRK is available
|
||||||
|
let irkBadge = '';
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkBadge = '<span class="bt-irk-badge">IRK</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Risk badge - show if risk score is significant
|
// Risk badge - show if risk score is significant
|
||||||
let riskBadge = '';
|
let riskBadge = '';
|
||||||
if (riskScore >= 0.3) {
|
if (riskScore >= 0.3) {
|
||||||
@@ -1184,9 +1420,36 @@ const BluetoothMode = (function() {
|
|||||||
statusDot = '<span class="bt-status-dot known"></span>';
|
statusDot = '<span class="bt-status-dot known"></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distance display
|
||||||
|
const distM = device.estimated_distance_m;
|
||||||
|
let distStr = '';
|
||||||
|
if (distM != null) {
|
||||||
|
distStr = '~' + distM.toFixed(1) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavioral flag badges
|
||||||
|
const hFlags = device.heuristic_flags || [];
|
||||||
|
let flagBadges = '';
|
||||||
|
if (device.is_persistent || hFlags.includes('persistent')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||||
|
}
|
||||||
|
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||||
|
}
|
||||||
|
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster badge
|
||||||
|
let clusterBadge = '';
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Build secondary info line
|
// Build secondary info line
|
||||||
let secondaryParts = [addr];
|
let secondaryParts = [addr];
|
||||||
if (mfr) secondaryParts.push(mfr);
|
if (mfr) secondaryParts.push(mfr);
|
||||||
|
if (distStr) secondaryParts.push(distStr);
|
||||||
secondaryParts.push('Seen ' + seenCount + '×');
|
secondaryParts.push('Seen ' + seenCount + '×');
|
||||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||||
// Add agent name if not Local
|
// Add agent name if not Local
|
||||||
@@ -1199,13 +1462,16 @@ const BluetoothMode = (function() {
|
|||||||
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
|
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
|
||||||
isTracker ? '#f97316' : rssiColor;
|
isTracker ? '#f97316' : rssiColor;
|
||||||
|
|
||||||
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeHtml(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')" style="border-left-color:' + borderColor + ';">' +
|
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeAttr(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" role="button" tabindex="0" data-keyboard-activate="true" style="border-left-color:' + borderColor + ';">' +
|
||||||
'<div class="bt-row-main">' +
|
'<div class="bt-row-main">' +
|
||||||
'<div class="bt-row-left">' +
|
'<div class="bt-row-left">' +
|
||||||
protoBadge +
|
protoBadge +
|
||||||
'<span class="bt-device-name">' + name + '</span>' +
|
'<span class="bt-device-name">' + name + '</span>' +
|
||||||
trackerBadge +
|
trackerBadge +
|
||||||
|
irkBadge +
|
||||||
riskBadge +
|
riskBadge +
|
||||||
|
flagBadges +
|
||||||
|
clusterBadge +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-right">' +
|
'<div class="bt-row-right">' +
|
||||||
'<div class="bt-rssi-container">' +
|
'<div class="bt-rssi-container">' +
|
||||||
@@ -1216,6 +1482,11 @@ const BluetoothMode = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
||||||
|
'<div class="bt-row-actions">' +
|
||||||
|
'<button type="button" class="bt-locate-btn" data-locate-id="' + escapeAttr(device.device_id) + '">' +
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
|
||||||
|
'Locate</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1506,10 @@ const BluetoothMode = (function() {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(text) {
|
||||||
|
return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
async function setBaseline() {
|
async function setBaseline() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/bluetooth/baseline/set', { method: 'POST' });
|
const response = await fetch('/api/bluetooth/baseline/set', { method: 'POST' });
|
||||||
@@ -1295,6 +1570,18 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the service data inspector panel
|
||||||
|
*/
|
||||||
|
function toggleServiceInspector() {
|
||||||
|
const content = document.getElementById('btInspectorContent');
|
||||||
|
const arrow = document.getElementById('btInspectorArrow');
|
||||||
|
if (!content) return;
|
||||||
|
const open = content.style.display === 'none';
|
||||||
|
content.style.display = open ? '' : 'none';
|
||||||
|
if (arrow) arrow.classList.toggle('open', open);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Agent Handling
|
// Agent Handling
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -1332,15 +1619,19 @@ const BluetoothMode = (function() {
|
|||||||
*/
|
*/
|
||||||
function clearData() {
|
function clearData() {
|
||||||
devices.clear();
|
devices.clear();
|
||||||
|
pendingDeviceIds.clear();
|
||||||
|
pendingDeviceFlush = false;
|
||||||
|
selectedDeviceNeedsRefresh = false;
|
||||||
resetStats();
|
resetStats();
|
||||||
|
clearSelection();
|
||||||
|
|
||||||
if (deviceContainer) {
|
if (deviceContainer) {
|
||||||
deviceContainer.innerHTML = '';
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
|
||||||
|
} else {
|
||||||
|
deviceContainer.innerHTML = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDeviceCount();
|
|
||||||
updateProximityZones();
|
|
||||||
updateRadar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1381,7 +1672,15 @@ const BluetoothMode = (function() {
|
|||||||
// Re-render device list
|
// Re-render device list
|
||||||
if (deviceContainer) {
|
if (deviceContainer) {
|
||||||
deviceContainer.innerHTML = '';
|
deviceContainer.innerHTML = '';
|
||||||
devices.forEach(device => renderDevice(device));
|
devices.forEach(device => renderDevice(device, false));
|
||||||
|
applyDeviceFilter();
|
||||||
|
if (devices.size === 0 && typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDeviceId && !devices.has(selectedDeviceId)) {
|
||||||
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDeviceCount();
|
updateDeviceCount();
|
||||||
@@ -1391,6 +1690,49 @@ const BluetoothMode = (function() {
|
|||||||
updateRadar();
|
updateRadar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand off a device to BT Locate mode by device_id lookup.
|
||||||
|
*/
|
||||||
|
function locateById(deviceId) {
|
||||||
|
console.log('[BT] locateById called with:', deviceId);
|
||||||
|
const device = devices.get(deviceId);
|
||||||
|
if (!device) {
|
||||||
|
console.warn('[BT] Device not found in map for id:', deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doLocateHandoff(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand off the currently selected device to BT Locate mode.
|
||||||
|
*/
|
||||||
|
function locateDevice() {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const device = devices.get(selectedDeviceId);
|
||||||
|
if (!device) return;
|
||||||
|
doLocateHandoff(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLocateHandoff(device) {
|
||||||
|
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
||||||
|
if (typeof BtLocate !== 'undefined') {
|
||||||
|
BtLocate.handoff({
|
||||||
|
device_id: device.device_id,
|
||||||
|
device_key: device.device_key || null,
|
||||||
|
mac_address: device.address,
|
||||||
|
address_type: device.address_type || null,
|
||||||
|
irk_hex: device.irk_hex || null,
|
||||||
|
known_name: device.name || null,
|
||||||
|
known_manufacturer: device.manufacturer_name || null,
|
||||||
|
last_known_rssi: device.rssi_current,
|
||||||
|
tx_power: device.tx_power || null,
|
||||||
|
appearance_name: device.appearance_name || null,
|
||||||
|
fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
|
||||||
|
mac_cluster_count: device.mac_cluster_count || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
@@ -1404,6 +1746,9 @@ const BluetoothMode = (function() {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
copyAddress,
|
copyAddress,
|
||||||
toggleWatchlist,
|
toggleWatchlist,
|
||||||
|
locateDevice,
|
||||||
|
locateById,
|
||||||
|
toggleServiceInspector,
|
||||||
|
|
||||||
// Agent handling
|
// Agent handling
|
||||||
handleAgentChange,
|
handleAgentChange,
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* GPS Mode
|
||||||
|
* Live GPS data display with satellite sky view, signal strength bars,
|
||||||
|
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GPS = (function() {
|
||||||
|
let connected = false;
|
||||||
|
let lastPosition = null;
|
||||||
|
let lastSky = null;
|
||||||
|
let skyPollTimer = null;
|
||||||
|
|
||||||
|
// Constellation color map
|
||||||
|
const CONST_COLORS = {
|
||||||
|
'GPS': '#00d4ff',
|
||||||
|
'GLONASS': '#00ff88',
|
||||||
|
'Galileo': '#ff8800',
|
||||||
|
'BeiDou': '#ff4466',
|
||||||
|
'SBAS': '#ffdd00',
|
||||||
|
'QZSS': '#cc66ff',
|
||||||
|
};
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
drawEmptySkyView();
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Redraw sky view when theme changes
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (lastSky) {
|
||||||
|
drawSkyView(lastSky.satellites || []);
|
||||||
|
} else {
|
||||||
|
drawEmptySkyView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
updateConnectionUI(false, false, 'connecting');
|
||||||
|
fetch('/gps/auto-connect', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'connected') {
|
||||||
|
connected = true;
|
||||||
|
updateConnectionUI(true, data.has_fix);
|
||||||
|
if (data.position) {
|
||||||
|
lastPosition = data.position;
|
||||||
|
updatePositionUI(data.position);
|
||||||
|
}
|
||||||
|
if (data.sky) {
|
||||||
|
lastSky = data.sky;
|
||||||
|
updateSkyUI(data.sky);
|
||||||
|
}
|
||||||
|
subscribeToStream();
|
||||||
|
startSkyPolling();
|
||||||
|
// Ensure the global GPS stream is running
|
||||||
|
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
||||||
|
startGpsStream();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connected = false;
|
||||||
|
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
connected = false;
|
||||||
|
updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
unsubscribeFromStream();
|
||||||
|
stopSkyPolling();
|
||||||
|
fetch('/gps/stop', { method: 'POST' })
|
||||||
|
.then(() => {
|
||||||
|
connected = false;
|
||||||
|
updateConnectionUI(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGpsStreamData(data) {
|
||||||
|
if (!connected) return;
|
||||||
|
if (data.type === 'position') {
|
||||||
|
lastPosition = data;
|
||||||
|
updatePositionUI(data);
|
||||||
|
updateConnectionUI(true, true);
|
||||||
|
} else if (data.type === 'sky') {
|
||||||
|
lastSky = data;
|
||||||
|
updateSkyUI(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSkyPolling() {
|
||||||
|
stopSkyPolling();
|
||||||
|
// Poll satellite data every 5 seconds as a reliable fallback
|
||||||
|
// SSE stream may miss sky updates due to queue contention with position messages
|
||||||
|
pollSatellites();
|
||||||
|
skyPollTimer = setInterval(pollSatellites, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSkyPolling() {
|
||||||
|
if (skyPollTimer) {
|
||||||
|
clearInterval(skyPollTimer);
|
||||||
|
skyPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollSatellites() {
|
||||||
|
if (!connected) return;
|
||||||
|
fetch('/gps/satellites')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'ok' && data.sky) {
|
||||||
|
lastSky = data.sky;
|
||||||
|
updateSkyUI(data.sky);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToStream() {
|
||||||
|
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||||
|
if (typeof addGpsStreamSubscriber === 'function') {
|
||||||
|
addGpsStreamSubscriber(onGpsStreamData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeFromStream() {
|
||||||
|
if (typeof removeGpsStreamSubscriber === 'function') {
|
||||||
|
removeGpsStreamSubscriber(onGpsStreamData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// UI Updates
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function updateConnectionUI(isConnected, hasFix, state, message) {
|
||||||
|
const dot = document.getElementById('gpsStatusDot');
|
||||||
|
const text = document.getElementById('gpsStatusText');
|
||||||
|
const connectBtn = document.getElementById('gpsConnectBtn');
|
||||||
|
const disconnectBtn = document.getElementById('gpsDisconnectBtn');
|
||||||
|
const devicePath = document.getElementById('gpsDevicePath');
|
||||||
|
|
||||||
|
if (dot) {
|
||||||
|
dot.className = 'gps-status-dot';
|
||||||
|
if (state === 'connecting') dot.classList.add('waiting');
|
||||||
|
else if (state === 'error') dot.classList.add('error');
|
||||||
|
else if (isConnected && hasFix) dot.classList.add('connected');
|
||||||
|
else if (isConnected) dot.classList.add('waiting');
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
if (state === 'connecting') text.textContent = 'Connecting...';
|
||||||
|
else if (state === 'error') text.textContent = message || 'Connection failed';
|
||||||
|
else if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||||
|
else if (isConnected) text.textContent = 'Connected (No Fix)';
|
||||||
|
else text.textContent = 'Disconnected';
|
||||||
|
}
|
||||||
|
if (connectBtn) {
|
||||||
|
connectBtn.style.display = isConnected ? 'none' : '';
|
||||||
|
connectBtn.disabled = state === 'connecting';
|
||||||
|
}
|
||||||
|
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
||||||
|
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositionUI(pos) {
|
||||||
|
// Sidebar fields
|
||||||
|
setText('gpsLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||||
|
setText('gpsHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||||
|
setText('gpsClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||||
|
|
||||||
|
// Fix type
|
||||||
|
const fixEl = document.getElementById('gpsFixType');
|
||||||
|
if (fixEl) {
|
||||||
|
const fq = pos.fix_quality;
|
||||||
|
if (fq === 3) fixEl.innerHTML = '<span class="gps-fix-badge fix-3d">3D FIX</span>';
|
||||||
|
else if (fq === 2) fixEl.innerHTML = '<span class="gps-fix-badge fix-2d">2D FIX</span>';
|
||||||
|
else fixEl.innerHTML = '<span class="gps-fix-badge no-fix">NO FIX</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error estimates
|
||||||
|
const eph = (pos.epx != null && pos.epy != null) ? Math.sqrt(pos.epx * pos.epx + pos.epy * pos.epy) : null;
|
||||||
|
setText('gpsEph', eph != null ? eph.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---');
|
||||||
|
|
||||||
|
// GPS time
|
||||||
|
if (pos.timestamp) {
|
||||||
|
const t = new Date(pos.timestamp);
|
||||||
|
setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visuals: position panel
|
||||||
|
setText('gpsVisPosLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsVisPosLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsVisPosAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsVisPosSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||||
|
setText('gpsVisPosHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||||
|
setText('gpsVisPosClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||||
|
|
||||||
|
// Visuals: fix badge
|
||||||
|
const visFixEl = document.getElementById('gpsVisFixBadge');
|
||||||
|
if (visFixEl) {
|
||||||
|
const fq = pos.fix_quality;
|
||||||
|
if (fq === 3) { visFixEl.textContent = '3D FIX'; visFixEl.className = 'gps-fix-badge fix-3d'; }
|
||||||
|
else if (fq === 2) { visFixEl.textContent = '2D FIX'; visFixEl.className = 'gps-fix-badge fix-2d'; }
|
||||||
|
else { visFixEl.textContent = 'NO FIX'; visFixEl.className = 'gps-fix-badge no-fix'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visuals: GPS time
|
||||||
|
if (pos.timestamp) {
|
||||||
|
const t = new Date(pos.timestamp);
|
||||||
|
setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSkyUI(sky) {
|
||||||
|
// Sidebar sat counts
|
||||||
|
setText('gpsSatUsed', sky.usat != null ? sky.usat : '-');
|
||||||
|
setText('gpsSatTotal', sky.nsat != null ? sky.nsat : '-');
|
||||||
|
|
||||||
|
// DOP values
|
||||||
|
setDop('gpsHdop', sky.hdop);
|
||||||
|
setDop('gpsVdop', sky.vdop);
|
||||||
|
setDop('gpsPdop', sky.pdop);
|
||||||
|
setDop('gpsTdop', sky.tdop);
|
||||||
|
setDop('gpsGdop', sky.gdop);
|
||||||
|
|
||||||
|
// Visuals
|
||||||
|
drawSkyView(sky.satellites || []);
|
||||||
|
drawSignalBars(sky.satellites || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDop(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
if (val == null) { el.textContent = '---'; el.className = 'gps-info-value gps-mono'; return; }
|
||||||
|
el.textContent = val.toFixed(1);
|
||||||
|
let cls = 'gps-info-value gps-mono ';
|
||||||
|
if (val <= 2) cls += 'gps-dop-good';
|
||||||
|
else if (val <= 5) cls += 'gps-dop-moderate';
|
||||||
|
else cls += 'gps-dop-poor';
|
||||||
|
el.className = cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Sky View Polar Plot
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function drawEmptySkyView() {
|
||||||
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
drawSkyViewBase(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSkyView(satellites) {
|
||||||
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const r = Math.min(cx, cy) - 24;
|
||||||
|
|
||||||
|
drawSkyViewBase(canvas);
|
||||||
|
|
||||||
|
// Plot satellites
|
||||||
|
satellites.forEach(sat => {
|
||||||
|
if (sat.elevation == null || sat.azimuth == null) return;
|
||||||
|
|
||||||
|
const elRad = (90 - sat.elevation) / 90;
|
||||||
|
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
||||||
|
const px = cx + r * elRad * Math.cos(azRad);
|
||||||
|
const py = cy + r * elRad * Math.sin(azRad);
|
||||||
|
|
||||||
|
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||||
|
const dotSize = sat.used ? 6 : 4;
|
||||||
|
|
||||||
|
// Draw dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
|
if (sat.used) {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRN label
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = '8px Roboto Condensed, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||||
|
|
||||||
|
// SNR value
|
||||||
|
if (sat.snr != null) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.font = '7px Roboto Condensed, monospace';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSkyViewBase(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const r = Math.min(cx, cy) - 24;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
|
||||||
|
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
|
||||||
|
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||||
|
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Elevation rings (0, 30, 60, 90)
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
[90, 60, 30].forEach(el => {
|
||||||
|
const gr = r * (1 - el / 90);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = dimColor;
|
||||||
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizon circle
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Cardinal directions
|
||||||
|
ctx.fillStyle = secondaryColor;
|
||||||
|
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('N', cx, cy - r - 12);
|
||||||
|
ctx.fillText('S', cx, cy + r + 12);
|
||||||
|
ctx.fillText('E', cx + r + 12, cy);
|
||||||
|
ctx.fillText('W', cx - r - 12, cy);
|
||||||
|
|
||||||
|
// Crosshairs
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - r);
|
||||||
|
ctx.lineTo(cx, cy + r);
|
||||||
|
ctx.moveTo(cx - r, cy);
|
||||||
|
ctx.lineTo(cx + r, cy);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Zenith dot
|
||||||
|
ctx.fillStyle = dimColor;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Signal Strength Bars
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function drawSignalBars(satellites) {
|
||||||
|
const container = document.getElementById('gpsSignalBars');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (satellites.length === 0) return;
|
||||||
|
|
||||||
|
// Sort: used first, then by PRN
|
||||||
|
const sorted = [...satellites].sort((a, b) => {
|
||||||
|
if (a.used !== b.used) return a.used ? -1 : 1;
|
||||||
|
return a.prn - b.prn;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxSnr = 50; // dB-Hz typical max for display
|
||||||
|
|
||||||
|
sorted.forEach(sat => {
|
||||||
|
const snr = sat.snr || 0;
|
||||||
|
const heightPct = Math.min(snr / maxSnr * 100, 100);
|
||||||
|
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||||
|
const constClass = 'gps-const-' + (sat.constellation || 'GPS').toLowerCase();
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'gps-signal-bar-wrap';
|
||||||
|
|
||||||
|
const snrLabel = document.createElement('span');
|
||||||
|
snrLabel.className = 'gps-signal-snr';
|
||||||
|
snrLabel.textContent = snr > 0 ? Math.round(snr) : '';
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'gps-signal-bar ' + constClass + (sat.used ? '' : ' unused');
|
||||||
|
bar.style.height = Math.max(heightPct, 2) + '%';
|
||||||
|
bar.title = `PRN ${sat.prn} (${sat.constellation}) - ${Math.round(snr)} dB-Hz${sat.used ? ' [USED]' : ''}`;
|
||||||
|
|
||||||
|
const prn = document.createElement('span');
|
||||||
|
prn.className = 'gps-signal-prn';
|
||||||
|
prn.textContent = sat.prn;
|
||||||
|
|
||||||
|
wrap.appendChild(snrLabel);
|
||||||
|
wrap.appendChild(bar);
|
||||||
|
wrap.appendChild(prn);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Cleanup
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
unsubscribeFromStream();
|
||||||
|
stopSkyPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
connect: connect,
|
||||||
|
disconnect: disconnect,
|
||||||
|
destroy: destroy,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1483,7 +1483,7 @@ function drawAudioVisualizer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||||
ctx.font = '8px Space Mono';
|
ctx.font = '8px Roboto Condensed';
|
||||||
ctx.fillText('0', 2, canvas.height - 2);
|
ctx.fillText('0', 2, canvas.height - 2);
|
||||||
ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
|
ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
|
||||||
ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
|
ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ const Meshtastic = (function() {
|
|||||||
|
|
||||||
// Position is nested in the response
|
// Position is nested in the response
|
||||||
const pos = info.position;
|
const pos = info.position;
|
||||||
if (pos && pos.latitude && pos.longitude) {
|
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
|
||||||
if (posRow) posRow.style.display = 'flex';
|
if (posRow) posRow.style.display = 'flex';
|
||||||
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,677 @@
|
|||||||
|
/**
|
||||||
|
* Space Weather Mode — IIFE module
|
||||||
|
* Polls /space-weather/data every 5 min, renders dashboard with Chart.js
|
||||||
|
*/
|
||||||
|
const SpaceWeather = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let _initialized = false;
|
||||||
|
let _pollTimer = null;
|
||||||
|
let _autoRefresh = true;
|
||||||
|
const POLL_INTERVAL = 5 * 60 * 1000; // 5 min
|
||||||
|
|
||||||
|
// Chart.js instances
|
||||||
|
let _kpChart = null;
|
||||||
|
let _windChart = null;
|
||||||
|
let _xrayChart = null;
|
||||||
|
|
||||||
|
// Current image selections
|
||||||
|
let _solarImageKey = 'sdo_193';
|
||||||
|
let _drapFreq = 'drap_global';
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!_initialized) {
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
_startAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
_stopAutoRefresh();
|
||||||
|
_destroyCharts();
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
_fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSolarImage(key) {
|
||||||
|
_solarImageKey = key;
|
||||||
|
_updateSolarImageTabs();
|
||||||
|
const frame = document.getElementById('swSolarImageFrame');
|
||||||
|
if (frame) {
|
||||||
|
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||||
|
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||||
|
img.src = '/space-weather/image/' + key + '?t=' + Date.now();
|
||||||
|
img.alt = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDrapFreq(key) {
|
||||||
|
_drapFreq = key;
|
||||||
|
_updateDrapTabs();
|
||||||
|
const frame = document.getElementById('swDrapImageFrame');
|
||||||
|
if (frame) {
|
||||||
|
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||||
|
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||||
|
img.src = '/space-weather/image/' + key + '?t=' + Date.now();
|
||||||
|
img.alt = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
const cb = document.getElementById('swAutoRefresh');
|
||||||
|
_autoRefresh = cb ? cb.checked : !_autoRefresh;
|
||||||
|
if (_autoRefresh) _startAutoRefresh();
|
||||||
|
else _stopAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Polling
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _startAutoRefresh() {
|
||||||
|
_stopAutoRefresh();
|
||||||
|
if (_autoRefresh) {
|
||||||
|
_pollTimer = setInterval(_fetchData, POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopAutoRefresh() {
|
||||||
|
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fetchData() {
|
||||||
|
fetch('/space-weather/data')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
_renderAll(data);
|
||||||
|
_updateTimestamp();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.warn('SpaceWeather fetch error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Master render
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderAll(data) {
|
||||||
|
_renderHeaderStrip(data);
|
||||||
|
_renderScales(data);
|
||||||
|
_renderBandConditions(data);
|
||||||
|
_renderKpChart(data);
|
||||||
|
_renderWindChart(data);
|
||||||
|
_renderXrayChart(data);
|
||||||
|
_renderFlareProb(data);
|
||||||
|
_renderSolarImage();
|
||||||
|
_renderDrapImage();
|
||||||
|
_renderAuroraImage();
|
||||||
|
_renderAlerts(data);
|
||||||
|
_renderRegions(data);
|
||||||
|
_updateSidebar(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Header strip
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderHeaderStrip(data) {
|
||||||
|
var sfi = '--', kp = '--', aIndex = '--', ssn = '--', wind = '--', bz = '--';
|
||||||
|
|
||||||
|
// SFI from band_conditions (HamQSL) or flux
|
||||||
|
if (data.band_conditions && data.band_conditions.sfi) {
|
||||||
|
sfi = data.band_conditions.sfi;
|
||||||
|
} else if (data.flux && data.flux.length > 1) {
|
||||||
|
var last = data.flux[data.flux.length - 1];
|
||||||
|
sfi = last[1] || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kp from kp_index
|
||||||
|
if (data.kp_index && data.kp_index.length > 1) {
|
||||||
|
var lastKp = data.kp_index[data.kp_index.length - 1];
|
||||||
|
kp = lastKp[1] || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A-index from band_conditions
|
||||||
|
if (data.band_conditions && data.band_conditions.aindex) {
|
||||||
|
aIndex = data.band_conditions.aindex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sunspot number
|
||||||
|
if (data.band_conditions && data.band_conditions.sunspots) {
|
||||||
|
ssn = data.band_conditions.sunspots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solar wind speed — last non-null entry
|
||||||
|
if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
|
||||||
|
for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
|
||||||
|
if (data.solar_wind_plasma[i][2]) {
|
||||||
|
wind = Math.round(parseFloat(data.solar_wind_plasma[i][2]));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMF Bz — last non-null entry
|
||||||
|
if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
|
||||||
|
for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
|
||||||
|
if (data.solar_wind_mag[j][3]) {
|
||||||
|
bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setText('swStripSfi', sfi);
|
||||||
|
_setText('swStripKp', kp);
|
||||||
|
_setText('swStripA', aIndex);
|
||||||
|
_setText('swStripSsn', ssn);
|
||||||
|
_setText('swStripWind', wind !== '--' ? wind + ' km/s' : '--');
|
||||||
|
_setText('swStripBz', bz !== '--' ? bz + ' nT' : '--');
|
||||||
|
|
||||||
|
// Color Kp by severity
|
||||||
|
var kpEl = document.getElementById('swStripKp');
|
||||||
|
if (kpEl) {
|
||||||
|
var kpNum = parseFloat(kp);
|
||||||
|
kpEl.className = 'sw-header-value';
|
||||||
|
if (kpNum >= 7) kpEl.classList.add('accent-red');
|
||||||
|
else if (kpNum >= 5) kpEl.classList.add('accent-orange');
|
||||||
|
else if (kpNum >= 4) kpEl.classList.add('accent-yellow');
|
||||||
|
else kpEl.classList.add('accent-green');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color Bz — negative is bad
|
||||||
|
var bzEl = document.getElementById('swStripBz');
|
||||||
|
if (bzEl) {
|
||||||
|
var bzNum = parseFloat(bz);
|
||||||
|
bzEl.className = 'sw-header-value';
|
||||||
|
if (bzNum < -10) bzEl.classList.add('accent-red');
|
||||||
|
else if (bzNum < -5) bzEl.classList.add('accent-orange');
|
||||||
|
else if (bzNum < 0) bzEl.classList.add('accent-yellow');
|
||||||
|
else bzEl.classList.add('accent-green');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// NOAA Scales
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderScales(data) {
|
||||||
|
if (!data.scales) return;
|
||||||
|
var s = data.scales;
|
||||||
|
// Structure: { "0": { R: {Scale, Text}, S: {Scale, Text}, G: {Scale, Text} }, ... }
|
||||||
|
// Key "0" = current conditions
|
||||||
|
var current = s['0'];
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
var scaleMap = {
|
||||||
|
'G': { el: 'swScaleG', label: 'Geomagnetic Storms' },
|
||||||
|
'S': { el: 'swScaleS', label: 'Solar Radiation' },
|
||||||
|
'R': { el: 'swScaleR', label: 'Radio Blackouts' }
|
||||||
|
};
|
||||||
|
['G', 'S', 'R'].forEach(function (k) {
|
||||||
|
var info = scaleMap[k];
|
||||||
|
var scaleData = current[k];
|
||||||
|
var val = '0', text = info.label;
|
||||||
|
if (scaleData) {
|
||||||
|
val = String(scaleData.Scale || '0').replace(/[^0-9]/g, '') || '0';
|
||||||
|
if (scaleData.Text && scaleData.Text !== 'none') {
|
||||||
|
text = scaleData.Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var el = document.getElementById(info.el);
|
||||||
|
if (el) {
|
||||||
|
el.querySelector('.sw-scale-value').textContent = k + val;
|
||||||
|
el.querySelector('.sw-scale-value').className = 'sw-scale-value sw-scale-' + val;
|
||||||
|
var descEl = el.querySelector('.sw-scale-desc');
|
||||||
|
if (descEl) descEl.textContent = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Band conditions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderBandConditions(data) {
|
||||||
|
var grid = document.getElementById('swBandGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
if (!data.band_conditions || !data.band_conditions.bands || data.band_conditions.bands.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="sw-empty" style="grid-column:1/-1">No band data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Group by band name, collect day/night
|
||||||
|
var bands = {};
|
||||||
|
data.band_conditions.bands.forEach(function (b) {
|
||||||
|
if (!bands[b.name]) bands[b.name] = {};
|
||||||
|
bands[b.name][b.time.toLowerCase()] = b.condition;
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '<div class="sw-band-header">Band</div><div class="sw-band-header" style="text-align:center">Day</div><div class="sw-band-header" style="text-align:center">Night</div>';
|
||||||
|
Object.keys(bands).forEach(function (name) {
|
||||||
|
html += '<div class="sw-band-name">' + name + '</div>';
|
||||||
|
['day', 'night'].forEach(function (t) {
|
||||||
|
var cond = bands[name][t] || '--';
|
||||||
|
var cls = 'sw-band-cond';
|
||||||
|
var cl = cond.toLowerCase();
|
||||||
|
if (cl === 'good') cls += ' sw-band-good';
|
||||||
|
else if (cl === 'fair') cls += ' sw-band-fair';
|
||||||
|
else if (cl === 'poor') cls += ' sw-band-poor';
|
||||||
|
html += '<div class="' + cls + '">' + cond + '</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
grid.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Kp bar chart
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderKpChart(data) {
|
||||||
|
var canvas = document.getElementById('swKpChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!data.kp_index || data.kp_index.length < 2) return;
|
||||||
|
|
||||||
|
var rows = data.kp_index.slice(1); // skip header
|
||||||
|
var labels = [];
|
||||||
|
var values = [];
|
||||||
|
var colors = [];
|
||||||
|
|
||||||
|
// Take last 24 entries
|
||||||
|
var subset = rows.slice(-24);
|
||||||
|
subset.forEach(function (r) {
|
||||||
|
var dt = r[0] || '';
|
||||||
|
labels.push(dt.slice(5, 16)); // MM-DD HH:MM
|
||||||
|
var v = parseFloat(r[1]) || 0;
|
||||||
|
values.push(v);
|
||||||
|
if (v >= 7) colors.push('#ff3366');
|
||||||
|
else if (v >= 5) colors.push('#ff8800');
|
||||||
|
else if (v >= 4) colors.push('#ffcc00');
|
||||||
|
else colors.push('#00ff88');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
|
||||||
|
_kpChart = new Chart(canvas, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderWidth: 0,
|
||||||
|
barPercentage: 0.8
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: _chartOpts('Kp', 0, 9, false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Solar wind chart
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderWindChart(data) {
|
||||||
|
var canvas = document.getElementById('swWindChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!data.solar_wind_plasma || data.solar_wind_plasma.length < 2) return;
|
||||||
|
|
||||||
|
var rows = data.solar_wind_plasma.slice(1);
|
||||||
|
var labels = [];
|
||||||
|
var speedData = [];
|
||||||
|
var densityData = [];
|
||||||
|
|
||||||
|
// Sample every 3rd point to avoid overcrowding
|
||||||
|
for (var i = 0; i < rows.length; i += 3) {
|
||||||
|
var r = rows[i];
|
||||||
|
labels.push(r[0] ? r[0].slice(11, 16) : '');
|
||||||
|
speedData.push(r[2] ? parseFloat(r[2]) : null);
|
||||||
|
densityData.push(r[1] ? parseFloat(r[1]) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_windChart) { _windChart.destroy(); _windChart = null; }
|
||||||
|
_windChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Speed (km/s)',
|
||||||
|
data: speedData,
|
||||||
|
borderColor: '#00ccff',
|
||||||
|
backgroundColor: '#00ccff22',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Density (p/cm³)',
|
||||||
|
data: densityData,
|
||||||
|
borderColor: '#ff8800',
|
||||||
|
borderWidth: 1,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderDash: [4, 2],
|
||||||
|
tension: 0.3,
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: 'top', labels: { color: '#888', font: { size: 10 }, boxWidth: 12, padding: 8 } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
|
||||||
|
y: { display: true, position: 'left', ticks: { color: '#00ccff', font: { size: 9 } }, grid: { color: '#ffffff08' }, title: { display: false } },
|
||||||
|
y1: { display: true, position: 'right', ticks: { color: '#ff8800', font: { size: 9 } }, grid: { drawOnChartArea: false } }
|
||||||
|
},
|
||||||
|
interaction: { mode: 'index', intersect: false }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// X-ray flux chart
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderXrayChart(data) {
|
||||||
|
var canvas = document.getElementById('swXrayChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!data.xrays || data.xrays.length < 2) return;
|
||||||
|
|
||||||
|
// New format: array of objects with time_tag, flux, energy
|
||||||
|
// Filter to short-wavelength (0.1-0.8nm) only
|
||||||
|
var filtered = data.xrays.filter(function (r) {
|
||||||
|
return r.energy && r.energy === '0.1-0.8nm';
|
||||||
|
});
|
||||||
|
if (filtered.length === 0) filtered = data.xrays;
|
||||||
|
|
||||||
|
var labels = [];
|
||||||
|
var values = [];
|
||||||
|
|
||||||
|
// Sample every 3rd point
|
||||||
|
for (var i = 0; i < filtered.length; i += 3) {
|
||||||
|
var r = filtered[i];
|
||||||
|
var tag = r.time_tag || '';
|
||||||
|
labels.push(tag.slice(11, 16));
|
||||||
|
values.push(r.flux ? parseFloat(r.flux) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
|
||||||
|
_xrayChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'X-Ray Flux (W/m²)',
|
||||||
|
data: values,
|
||||||
|
borderColor: '#ff3366',
|
||||||
|
backgroundColor: '#ff336622',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
type: 'logarithmic',
|
||||||
|
ticks: {
|
||||||
|
color: '#888',
|
||||||
|
font: { size: 9 },
|
||||||
|
callback: function (v) {
|
||||||
|
if (v >= 1e-4) return 'X';
|
||||||
|
if (v >= 1e-5) return 'M';
|
||||||
|
if (v >= 1e-6) return 'C';
|
||||||
|
if (v >= 1e-7) return 'B';
|
||||||
|
if (v >= 1e-8) return 'A';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: '#ffffff08' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Flare probability
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderFlareProb(data) {
|
||||||
|
var el = document.getElementById('swFlareProb');
|
||||||
|
if (!el) return;
|
||||||
|
if (!data.flare_probability || data.flare_probability.length === 0) {
|
||||||
|
el.innerHTML = '<div class="sw-empty">No flare data</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// New format: array of objects with date, c_class_1_day, m_class_1_day, x_class_1_day, etc.
|
||||||
|
var latest = data.flare_probability.slice(-3);
|
||||||
|
var html = '<table class="sw-prob-table"><thead><tr>';
|
||||||
|
html += '<th>Date</th><th>C 1-day</th><th>M 1-day</th><th>X 1-day</th><th>Proton</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
latest.forEach(function (row) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + _escHtml(row.date || '--') + '</td>';
|
||||||
|
html += '<td>' + _escHtml(row.c_class_1_day || '--') + '%</td>';
|
||||||
|
html += '<td>' + _escHtml(row.m_class_1_day || '--') + '%</td>';
|
||||||
|
html += '<td>' + _escHtml(row.x_class_1_day || '--') + '%</td>';
|
||||||
|
html += '<td>' + _escHtml(row['10mev_protons_1_day'] || '--') + '%</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Images
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderSolarImage() {
|
||||||
|
selectSolarImage(_solarImageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderDrapImage() {
|
||||||
|
selectDrapFreq(_drapFreq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderAuroraImage() {
|
||||||
|
var frame = document.getElementById('swAuroraFrame');
|
||||||
|
if (!frame) return;
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||||
|
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load aurora image</div>'; };
|
||||||
|
img.src = '/space-weather/image/aurora_north?t=' + Date.now();
|
||||||
|
img.alt = 'Aurora Forecast';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSolarImageTabs() {
|
||||||
|
document.querySelectorAll('.sw-solar-tab').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.key === _solarImageKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateDrapTabs() {
|
||||||
|
document.querySelectorAll('.sw-drap-freq-btn').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.key === _drapFreq);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Alerts
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderAlerts(data) {
|
||||||
|
var el = document.getElementById('swAlertsList');
|
||||||
|
if (!el) return;
|
||||||
|
if (!data.alerts || data.alerts.length === 0) {
|
||||||
|
el.innerHTML = '<div class="sw-empty">No active alerts</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
// Show latest 10
|
||||||
|
var items = data.alerts.slice(0, 10);
|
||||||
|
items.forEach(function (a) {
|
||||||
|
var msg = a.message || a.product_text || '';
|
||||||
|
// Truncate long messages
|
||||||
|
if (msg.length > 300) msg = msg.substring(0, 300) + '...';
|
||||||
|
html += '<div class="sw-alert-item">';
|
||||||
|
html += '<div class="sw-alert-type">' + _escHtml(a.product_id || 'Alert') + '</div>';
|
||||||
|
html += '<div class="sw-alert-time">' + _escHtml(a.issue_datetime || '') + '</div>';
|
||||||
|
html += '<div class="sw-alert-msg">' + _escHtml(msg) + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Active regions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderRegions(data) {
|
||||||
|
var el = document.getElementById('swRegionsBody');
|
||||||
|
if (!el) return;
|
||||||
|
if (!data.solar_regions || data.solar_regions.length === 0) {
|
||||||
|
el.innerHTML = '<tr><td colspan="5" class="sw-empty">No active regions</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// New format: array of objects with region, observed_date, location, longitude, area, etc.
|
||||||
|
// De-duplicate by region number (keep latest observed_date per region)
|
||||||
|
var byRegion = {};
|
||||||
|
data.solar_regions.forEach(function (r) {
|
||||||
|
var key = r.region || '';
|
||||||
|
if (!byRegion[key] || (r.observed_date > byRegion[key].observed_date)) {
|
||||||
|
byRegion[key] = r;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var regions = Object.values(byRegion);
|
||||||
|
var html = '';
|
||||||
|
regions.forEach(function (r) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + _escHtml(String(r.region || '')) + '</td>';
|
||||||
|
html += '<td>' + _escHtml(r.observed_date || '') + '</td>';
|
||||||
|
html += '<td>' + _escHtml(r.location || '') + '</td>';
|
||||||
|
html += '<td>' + _escHtml(String(r.longitude || '')) + '</td>';
|
||||||
|
html += '<td>' + _escHtml(String(r.area || '')) + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Sidebar quick status
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _updateSidebar(data) {
|
||||||
|
var sfi = '--', kp = '--', aIdx = '--', ssn = '--', wind = '--', bz = '--';
|
||||||
|
|
||||||
|
if (data.band_conditions) {
|
||||||
|
if (data.band_conditions.sfi) sfi = data.band_conditions.sfi;
|
||||||
|
if (data.band_conditions.aindex) aIdx = data.band_conditions.aindex;
|
||||||
|
if (data.band_conditions.sunspots) ssn = data.band_conditions.sunspots;
|
||||||
|
}
|
||||||
|
if (data.kp_index && data.kp_index.length > 1) {
|
||||||
|
kp = data.kp_index[data.kp_index.length - 1][1] || '--';
|
||||||
|
}
|
||||||
|
if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
|
||||||
|
for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
|
||||||
|
if (data.solar_wind_plasma[i][2]) {
|
||||||
|
wind = Math.round(parseFloat(data.solar_wind_plasma[i][2])) + ' km/s';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
|
||||||
|
for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
|
||||||
|
if (data.solar_wind_mag[j][3]) {
|
||||||
|
bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1) + ' nT';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setText('swSidebarSfi', sfi);
|
||||||
|
_setText('swSidebarKp', kp);
|
||||||
|
_setText('swSidebarA', aIdx);
|
||||||
|
_setText('swSidebarSsn', ssn);
|
||||||
|
_setText('swSidebarWind', wind);
|
||||||
|
_setText('swSidebarBz', bz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _setText(id, text) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escHtml(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateTimestamp() {
|
||||||
|
var el = document.getElementById('swLastUpdate');
|
||||||
|
if (el) el.textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _chartOpts(yLabel, yMin, yMax, showLegend) {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: !!showLegend, labels: { color: '#888', font: { size: 10 } } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxRotation: 45, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
|
||||||
|
y: { display: true, min: yMin, max: yMax, ticks: { color: '#888', font: { size: 9 }, stepSize: 1 }, grid: { color: '#ffffff08' } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _destroyCharts() {
|
||||||
|
if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
|
||||||
|
if (_windChart) { _windChart.destroy(); _windChart = null; }
|
||||||
|
if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Expose public API
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
destroy: destroy,
|
||||||
|
refresh: refresh,
|
||||||
|
selectSolarImage: selectSolarImage,
|
||||||
|
selectDrapFreq: selectDrapFreq,
|
||||||
|
toggleAutoRefresh: toggleAutoRefresh
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -84,7 +84,7 @@ const SpyStations = (function() {
|
|||||||
modeContainer.innerHTML = modes.map(m => `
|
modeContainer.innerHTML = modes.map(m => `
|
||||||
<label class="inline-checkbox">
|
<label class="inline-checkbox">
|
||||||
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
|
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
|
||||||
<span style="font-family: 'Space Mono', monospace; font-size: 10px;">${m}</span>
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 10px;">${m}</span>
|
||||||
</label>
|
</label>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const SSTVGeneral = (function() {
|
|||||||
const deviceSelect = document.getElementById('deviceSelect');
|
const deviceSelect = document.getElementById('deviceSelect');
|
||||||
|
|
||||||
const frequency = parseFloat(freqInput?.value || '14.230');
|
const frequency = parseFloat(freqInput?.value || '14.230');
|
||||||
const modulation = modSelect?.value || 'usb';
|
const modulation = modSelect?.value || 'fm';
|
||||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||||
|
|
||||||
updateStatusUI('connecting', 'Starting...');
|
updateStatusUI('connecting', 'Starting...');
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ const SSTV = (function() {
|
|||||||
let issUpdateInterval = null;
|
let issUpdateInterval = null;
|
||||||
let countdownInterval = null;
|
let countdownInterval = null;
|
||||||
let nextPassData = null;
|
let nextPassData = null;
|
||||||
|
let pendingMapInvalidate = false;
|
||||||
|
|
||||||
// ISS frequency
|
// ISS frequency
|
||||||
const ISS_FREQ = 145.800;
|
const ISS_FREQ = 145.800;
|
||||||
|
const ISS_MODULATION = 'fm';
|
||||||
|
|
||||||
// Signal scope state
|
// Signal scope state
|
||||||
let sstvScopeCtx = null;
|
let sstvScopeCtx = null;
|
||||||
@@ -44,6 +46,22 @@ const SSTV = (function() {
|
|||||||
initMap();
|
initMap();
|
||||||
startIssTracking();
|
startIssTracking();
|
||||||
startCountdown();
|
startCountdown();
|
||||||
|
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
|
||||||
|
setTimeout(() => invalidateMap(), 80);
|
||||||
|
setTimeout(() => invalidateMap(), 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMapContainerVisible() {
|
||||||
|
if (!issMap || typeof issMap.getContainer !== 'function') return false;
|
||||||
|
const container = issMap.getContainer();
|
||||||
|
if (!container) return false;
|
||||||
|
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
|
||||||
|
if (container.style && container.style.display === 'none') return false;
|
||||||
|
if (typeof window.getComputedStyle === 'function') {
|
||||||
|
const style = window.getComputedStyle(container);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,6 +237,14 @@ const SSTV = (function() {
|
|||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
dashArray: '5, 5'
|
dashArray: '5, 5'
|
||||||
}).addTo(issMap);
|
}).addTo(issMap);
|
||||||
|
|
||||||
|
issMap.on('resize moveend zoomend', () => {
|
||||||
|
if (pendingMapInvalidate) invalidateMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial layout passes for first-time mode load.
|
||||||
|
setTimeout(() => invalidateMap(), 40);
|
||||||
|
setTimeout(() => invalidateMap(), 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -430,6 +456,7 @@ const SSTV = (function() {
|
|||||||
*/
|
*/
|
||||||
function updateMap() {
|
function updateMap() {
|
||||||
if (!issMap || !issPosition) return;
|
if (!issMap || !issPosition) return;
|
||||||
|
if (pendingMapInvalidate) invalidateMap();
|
||||||
|
|
||||||
const lat = issPosition.lat;
|
const lat = issPosition.lat;
|
||||||
const lon = issPosition.lon;
|
const lon = issPosition.lon;
|
||||||
@@ -489,8 +516,12 @@ const SSTV = (function() {
|
|||||||
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan map to follow ISS
|
// Pan map to follow ISS only when the map pane is currently renderable.
|
||||||
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
if (isMapContainerVisible()) {
|
||||||
|
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||||
|
} else {
|
||||||
|
pendingMapInvalidate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -544,7 +575,7 @@ const SSTV = (function() {
|
|||||||
const response = await fetch('/sstv/start', {
|
const response = await fetch('/sstv/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ frequency, device })
|
body: JSON.stringify({ frequency, modulation: ISS_MODULATION, device })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -554,9 +585,11 @@ const SSTV = (function() {
|
|||||||
if (typeof reserveDevice === 'function') {
|
if (typeof reserveDevice === 'function') {
|
||||||
reserveDevice(device, 'sstv');
|
reserveDevice(device, 'sstv');
|
||||||
}
|
}
|
||||||
updateStatusUI('listening', `${frequency} MHz`);
|
const tunedFrequency = Number(data.frequency || frequency);
|
||||||
|
const modulationText = String(data.modulation || ISS_MODULATION).toUpperCase();
|
||||||
|
updateStatusUI('listening', `${tunedFrequency.toFixed(3)} MHz ${modulationText}`);
|
||||||
startStream();
|
startStream();
|
||||||
showNotification('SSTV', `Listening on ${frequency} MHz`);
|
showNotification('SSTV', `Listening on ${tunedFrequency.toFixed(3)} MHz ${modulationText}`);
|
||||||
} else {
|
} else {
|
||||||
updateStatusUI('idle', 'Start failed');
|
updateStatusUI('idle', 'Start failed');
|
||||||
showStatusMessage(data.message || 'Failed to start decoder', 'error');
|
showStatusMessage(data.message || 'Failed to start decoder', 'error');
|
||||||
@@ -1310,6 +1343,20 @@ const SSTV = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate ISS map size after pane/layout changes.
|
||||||
|
*/
|
||||||
|
function invalidateMap() {
|
||||||
|
if (!issMap) return false;
|
||||||
|
if (!isMapContainerVisible()) {
|
||||||
|
pendingMapInvalidate = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
issMap.invalidateSize({ pan: false, animate: false });
|
||||||
|
pendingMapInvalidate = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
@@ -1325,7 +1372,8 @@ const SSTV = (function() {
|
|||||||
useGPS,
|
useGPS,
|
||||||
updateTLE,
|
updateTLE,
|
||||||
stopIssTracking,
|
stopIssTracking,
|
||||||
stopCountdown
|
stopCountdown,
|
||||||
|
invalidateMap
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -1754,7 +1754,7 @@ const SubGhz = (function() {
|
|||||||
// Grid
|
// Grid
|
||||||
ctx.strokeStyle = '#1a1f2e';
|
ctx.strokeStyle = '#1a1f2e';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.font = '10px JetBrains Mono, monospace';
|
ctx.font = '10px Roboto Condensed, monospace';
|
||||||
ctx.fillStyle = '#666';
|
ctx.fillStyle = '#666';
|
||||||
|
|
||||||
for (let db = powerMin; db <= powerMax; db += 20) {
|
for (let db = powerMin; db <= powerMax; db += 20) {
|
||||||
@@ -1824,7 +1824,7 @@ const SubGhz = (function() {
|
|||||||
ctx.lineTo(x + 4, y - 2);
|
ctx.lineTo(x + 4, y - 2);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.font = '9px JetBrains Mono, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.fillStyle = 'rgba(255, 170, 0, 0.8)';
|
ctx.fillStyle = 'rgba(255, 170, 0, 0.8)';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(peak.freq.toFixed(1), x, y - 10);
|
ctx.fillText(peak.freq.toFixed(1), x, y - 10);
|
||||||
|
|||||||