mirror of
https://github.com/smittix/intercept.git
synced 2026-06-15 00:53:37 -07:00
Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24332a4e23 | |||
| ebc5754684 | |||
| 340b300aa4 | |||
| bf7026cc9f | |||
| 1b04b52509 | |||
| fca334f472 | |||
| d81d644319 | |||
| 400cf1114f | |||
| fec38adc78 | |||
| 993a7d2626 | |||
| dbe09411ac | |||
| 0afc47fcdd | |||
| 4862b285a8 | |||
| 41dd1555d7 | |||
| 0cf3a25ac6 | |||
| 3674b6e2d6 | |||
| 4c9bcb00c3 | |||
| 2067d0bf84 | |||
| c0fa59d10e | |||
| 37add84d59 | |||
| c23019b8c0 | |||
| b4edd35f5f | |||
| 812f85b9a9 | |||
| 77888b7d88 | |||
| 4a38d7512d | |||
| 5d0df18dac | |||
| d18e38800e | |||
| 76e595aaec | |||
| dfb9897fa1 | |||
| 82ad784fcb | |||
| 4bd7077d64 | |||
| 3f6b9cc5ef | |||
| 0742647571 | |||
| 33090419df | |||
| 4042d0e5f1 | |||
| d3a0b41fba | |||
| 2fefea5618 | |||
| d75f7c794f | |||
| 503b91ea87 | |||
| 43db7c309d | |||
| 6e57927409 | |||
| a404f5ded9 | |||
| f6a6aab623 | |||
| 2cfbc0addc | |||
| 07d6ef984e | |||
| 50227ccae6 | |||
| 8f3c636c61 | |||
| 42761bbdbc | |||
| 0f2eba302c | |||
| 83dd58721f | |||
| d658d0b81e | |||
| e04113628a | |||
| b1e92326b6 | |||
| 9ac63bd75f | |||
| f795180c7d | |||
| d1f1ce1f4b | |||
| 334073089f | |||
| df634dc741 | |||
| a76dfde02d | |||
| 36f8349bc7 | |||
| 130a3a2d8e | |||
| bd6fa27970 | |||
| 630bc2971a | |||
| 7182f7803a | |||
| a64a7c414c | |||
| f0cc396a6b | |||
| 5f588a5513 | |||
| 599df7734b | |||
| 49fa02142d | |||
| 333dc00ee2 | |||
| 2bc71e44ad | |||
| 92265da5fb | |||
| 9c1516c086 | |||
| cd7940bdc2 | |||
| 4a5f3e1802 | |||
| 1b5bf4c061 | |||
| 384d02649a | |||
| d51da40a67 | |||
| 3a6bd3711e | |||
| d28d371caf | |||
| 05d96b6077 | |||
| f6197592bb | |||
| aca7f56808 | |||
| 872cc806eb | |||
| 7b847e0541 | |||
| 17b46a13c2 | |||
| ede3a5841b | |||
| 7270f827a9 | |||
| 468812bc09 | |||
| 7bef63aede | |||
| 21dec0d53a | |||
| 52997b3c78 | |||
| 765e1384b5 | |||
| e18f85370f | |||
| a0604a43c0 | |||
| 9cb44c6273 | |||
| eacf6d4970 | |||
| 07ae227cee | |||
| 18ef6218d8 | |||
| 0c7ac816e9 | |||
| 8e204725b2 | |||
| 40acca20b2 | |||
| ae804f92b2 | |||
| 0a6effccae | |||
| 0cf73b1234 | |||
| 8d354755f0 | |||
| 166f598386 | |||
| 6e51739654 | |||
| ec22823e59 | |||
| 87cd10194f | |||
| 933575b480 | |||
| a4218c0c33 | |||
| c67fa39e30 | |||
| 9f7dc8f995 | |||
| d1dd1ad4da | |||
| c7fdea856d | |||
| a7307dbf3a | |||
| 55ff644a8a | |||
| 3d90e03ca9 | |||
| 069e87f9ba | |||
| f3c5d124b5 | |||
| d821e19334 | |||
| d15b4efc97 | |||
| a3ad49a441 | |||
| fb95e465a3 | |||
| ab0a03b313 | |||
| f396ff7b66 | |||
| 52cb47e5c9 | |||
| 003b44c62e | |||
| 92caef5cb7 |
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.12.0] - 2026-01-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **ISS SSTV Decoder Mode** - Receive Slow Scan Television transmissions from the ISS
|
||||||
|
- Real-time ISS tracking globe with accurate position via N2YO API
|
||||||
|
- Leaflet world map showing ISS ground track and current position
|
||||||
|
- Location settings for ISS pass predictions
|
||||||
|
- Integration with satellite tracking TLE data
|
||||||
|
- **GitHub Update Notifications** - Automatic new version alerts
|
||||||
|
- Checks for updates on app startup
|
||||||
|
- Unobtrusive notification when new releases are available
|
||||||
|
- Configurable check interval via settings
|
||||||
|
- **Meshtastic Enhancements**
|
||||||
|
- QR code support for easy device sharing
|
||||||
|
- Telemetry display with battery, voltage, and environmental data
|
||||||
|
- Traceroute visualization for mesh network topology
|
||||||
|
- Improved node synchronization between map and top bar
|
||||||
|
- **UI Improvements**
|
||||||
|
- New Space category for satellite and ISS-related modes
|
||||||
|
- Pulsating ring effect for tracked aircraft/vessels
|
||||||
|
- Map marker highlighting for selected aircraft in ADS-B
|
||||||
|
- Consolidated settings and dependencies into single modal
|
||||||
|
- **Auto-Update TLE Data** - Satellite tracking data updates automatically on app startup
|
||||||
|
- **GPS Auto-Connect** - AIS dashboard now connects to gpsd automatically
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Utility Meters** - Added device grouping by ID with consumption trends
|
||||||
|
- **Utility Meters** - Device intelligence and manufacturer information display
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SoapySDR** - Module detection on macOS with Homebrew
|
||||||
|
- **dump1090** - Build failures in Docker containers
|
||||||
|
- **dump1090** - Build failures on Kali Linux and newer GCC versions
|
||||||
|
- **Flask** - Ensure Flask 3.0+ compatibility in setup script
|
||||||
|
- **psycopg2** - Now optional for Flask/Werkzeug compatibility
|
||||||
|
- **Bias-T** - Setting now properly passed to ADS-B and AIS dashboards
|
||||||
|
- **Dark Mode Maps** - Removed CSS filter that was inverting dark tiles
|
||||||
|
- **Map Tiles** - Fixed CARTO tile URLs and added cache-busting
|
||||||
|
- **Meshtastic** - Traceroute button and dark mode map fixes
|
||||||
|
- **ADS-B Dashboard** - Height adjustment to prevent bottom controls cutoff
|
||||||
|
- **Audio Visualizer** - Now works without spectrum canvas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.11.0] - 2026-01-28
|
## [2.11.0] - 2026-01-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-1
@@ -67,7 +67,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
&& cd dump1090 \
|
&& cd dump1090 \
|
||||||
&& make \
|
&& sed -i 's/-Werror//g' Makefile \
|
||||||
|
&& make BLADERF=no RTLSDR=yes \
|
||||||
&& cp dump1090 /usr/bin/dump1090-fa \
|
&& cp dump1090 /usr/bin/dump1090-fa \
|
||||||
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
||||||
&& rm -rf /tmp/dump1090 \
|
&& rm -rf /tmp/dump1090 \
|
||||||
|
|||||||
@@ -63,18 +63,59 @@ cd intercept
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||||
|
|
||||||
### ADS-B History (Optional)
|
### ADS-B History (Optional)
|
||||||
|
|
||||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start with ADS-B history and Postgres
|
# Start with ADS-B history and Postgres
|
||||||
docker compose --profile history up -d
|
docker compose --profile history up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open **/adsb/history** for the reporting dashboard.
|
Set the following environment variables (for example in a `.env` file):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other ADS-B Settings
|
||||||
|
|
||||||
|
Set these as environment variables for either local installs or Docker:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||||
|
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||||
|
|
||||||
|
**Local install example**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker example (.env)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||||
|
```
|
||||||
|
|
||||||
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **/adsb/history** for the reporting dashboard.
|
||||||
|
|
||||||
### Open the Interface
|
### Open the Interface
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-01-11_fae1348c",
|
"version": "2026-02-01_ba81b697",
|
||||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
from utils.process import cleanup_stale_processes
|
from utils.process import cleanup_stale_processes
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
@@ -38,6 +38,7 @@ from utils.constants import (
|
|||||||
MAX_BT_DEVICE_AGE_SECONDS,
|
MAX_BT_DEVICE_AGE_SECONDS,
|
||||||
MAX_VESSEL_AGE_SECONDS,
|
MAX_VESSEL_AGE_SECONDS,
|
||||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||||
|
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
@@ -175,6 +176,11 @@ dsc_lock = threading.Lock()
|
|||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Deauth Attack Detection
|
||||||
|
deauth_detector = None
|
||||||
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
deauth_detector_lock = threading.Lock()
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL STATE DICTIONARIES
|
# GLOBAL STATE DICTIONARIES
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -204,6 +210,9 @@ ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessel
|
|||||||
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
||||||
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
||||||
|
|
||||||
|
# Deauth alerts - using DataStore for automatic cleanup
|
||||||
|
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
||||||
|
|
||||||
# Satellite state
|
# Satellite state
|
||||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||||
|
|
||||||
@@ -215,6 +224,53 @@ cleanup_manager.register(bt_beacons)
|
|||||||
cleanup_manager.register(adsb_aircraft)
|
cleanup_manager.register(adsb_aircraft)
|
||||||
cleanup_manager.register(ais_vessels)
|
cleanup_manager.register(ais_vessels)
|
||||||
cleanup_manager.register(dsc_messages)
|
cleanup_manager.register(dsc_messages)
|
||||||
|
cleanup_manager.register(deauth_alerts)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SDR DEVICE REGISTRY
|
||||||
|
# ============================================
|
||||||
|
# Tracks which mode is using which SDR device to prevent conflicts
|
||||||
|
# Key: device_index (int), Value: mode_name (str)
|
||||||
|
sdr_device_registry: dict[int, str] = {}
|
||||||
|
sdr_device_registry_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||||
|
"""Claim an SDR device for a mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: The SDR device index to claim
|
||||||
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Error message if device is in use, None if successfully claimed
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
if device_index in sdr_device_registry:
|
||||||
|
in_use_by = sdr_device_registry[device_index]
|
||||||
|
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||||
|
sdr_device_registry[device_index] = mode_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def release_sdr_device(device_index: int) -> None:
|
||||||
|
"""Release an SDR device from the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: The SDR device index to release
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
sdr_device_registry.pop(device_index, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sdr_device_status() -> dict[int, str]:
|
||||||
|
"""Get current SDR device allocations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping device indices to mode names
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
return dict(sdr_device_registry)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -222,9 +278,13 @@ cleanup_manager.register(dsc_messages)
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def require_login():
|
def require_login():
|
||||||
# Routes that don't require login (to avoid infinite redirect loop)
|
# Routes that don't require login (to avoid infinite redirect loop)
|
||||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||||
|
|
||||||
|
# Allow audio streaming endpoints without session auth
|
||||||
|
if request.path.startswith('/listening/audio/'):
|
||||||
|
return None
|
||||||
|
|
||||||
# Controller API endpoints use API key auth, not session auth
|
# Controller API endpoints use API key auth, not session auth
|
||||||
# Allow agent push/pull endpoints without session login
|
# Allow agent push/pull endpoints without session login
|
||||||
@@ -279,7 +339,14 @@ def index() -> str:
|
|||||||
'rtlamr': check_tool('rtlamr')
|
'rtlamr': check_tool('rtlamr')
|
||||||
}
|
}
|
||||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
tools=tools,
|
||||||
|
devices=devices,
|
||||||
|
version=VERSION,
|
||||||
|
changelog=CHANGELOG,
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/favicon.svg')
|
@app.route('/favicon.svg')
|
||||||
@@ -294,6 +361,22 @@ def get_devices() -> Response:
|
|||||||
return jsonify([d.to_dict() for d in devices])
|
return jsonify([d.to_dict() for d in devices])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/devices/status')
|
||||||
|
def get_devices_status() -> Response:
|
||||||
|
"""Get all SDR devices with usage status."""
|
||||||
|
devices = SDRFactory.detect_devices()
|
||||||
|
registry = get_sdr_device_status()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for device in devices:
|
||||||
|
d = device.to_dict()
|
||||||
|
d['in_use'] = device.index in registry
|
||||||
|
d['used_by'] = registry.get(device.index)
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices/debug')
|
@app.route('/devices/debug')
|
||||||
def get_devices_debug() -> Response:
|
def get_devices_debug() -> Response:
|
||||||
"""Get detailed SDR device detection diagnostics."""
|
"""Get detailed SDR device detection diagnostics."""
|
||||||
@@ -566,19 +649,21 @@ def health_check() -> Response:
|
|||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder and WiFi processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
|
|
||||||
# Import adsb and ais modules to reset their state
|
# Import adsb and ais modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
from routes import ais as ais_module
|
from routes import ais as ais_module
|
||||||
|
from utils.bluetooth import reset_bluetooth_scanner
|
||||||
|
|
||||||
killed = []
|
killed = []
|
||||||
processes_to_kill = [
|
processes_to_kill = [
|
||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
|
'hcitool', 'bluetoothctl'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -622,6 +707,30 @@ def kill_all() -> Response:
|
|||||||
dsc_process = None
|
dsc_process = None
|
||||||
dsc_rtl_process = None
|
dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Reset Bluetooth state (legacy)
|
||||||
|
with bt_lock:
|
||||||
|
if bt_process:
|
||||||
|
try:
|
||||||
|
bt_process.terminate()
|
||||||
|
bt_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
bt_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
bt_process = None
|
||||||
|
|
||||||
|
# Reset Bluetooth v2 scanner
|
||||||
|
try:
|
||||||
|
reset_bluetooth_scanner()
|
||||||
|
killed.append('bluetooth_scanner')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear SDR device registry
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
sdr_device_registry.clear()
|
||||||
|
|
||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
@@ -714,6 +823,22 @@ def main() -> None:
|
|||||||
from routes import register_blueprints
|
from routes import register_blueprints
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Update TLE data in background thread (non-blocking)
|
||||||
|
def update_tle_background():
|
||||||
|
try:
|
||||||
|
from routes.satellite import refresh_tle_data
|
||||||
|
print("Updating satellite TLE data from CelesTrak...")
|
||||||
|
updated = refresh_tle_data()
|
||||||
|
if updated:
|
||||||
|
print(f"TLE data updated for: {', '.join(updated)}")
|
||||||
|
else:
|
||||||
|
print("TLE update: No satellites updated (may be offline)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"TLE update failed (will use cached data): {e}")
|
||||||
|
|
||||||
|
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
|
||||||
|
tle_thread.start()
|
||||||
|
|
||||||
# Initialize WebSocket for audio streaming
|
# Initialize WebSocket for audio streaming
|
||||||
try:
|
try:
|
||||||
from routes.audio_websocket import init_audio_websocket
|
from routes.audio_websocket import init_audio_websocket
|
||||||
|
|||||||
@@ -7,10 +7,46 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.11.0"
|
VERSION = "2.13.1"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.13.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Help modal system with keyboard shortcuts reference",
|
||||||
|
"Main Dashboard button in navigation bar",
|
||||||
|
"Settings modal accessible from all dashboards",
|
||||||
|
"Dashboard CSS improvements and consistency fixes",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.13.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"WiFi client display in AP detail drawer",
|
||||||
|
"Real-time client updates via SSE streaming",
|
||||||
|
"Probed SSID badges for connected clients",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.12.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Bug fixes and improvements",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.12.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"ISS SSTV decoder with real-time ISS tracking globe",
|
||||||
|
"GitHub update notifications for new releases",
|
||||||
|
"Meshtastic QR code support and telemetry display",
|
||||||
|
"New Space category with reorganized UI",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"date": "January 2026",
|
"date": "January 2026",
|
||||||
@@ -61,16 +97,6 @@ CHANGELOG = [
|
|||||||
"Risk scoring and threat classification",
|
"Risk scoring and threat classification",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"version": "2.7.0",
|
|
||||||
"date": "November 2025",
|
|
||||||
"highlights": [
|
|
||||||
"Multi-SDR hardware support via SoapySDR",
|
|
||||||
"LimeSDR, HackRF, Airspy, SDRplay support",
|
|
||||||
"Improved aircraft database with photo lookup",
|
|
||||||
"GPS auto-detection and integration",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -139,6 +165,7 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
|||||||
# ADS-B settings
|
# ADS-B settings
|
||||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||||
|
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
|
||||||
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
||||||
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||||
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||||
@@ -149,11 +176,19 @@ ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
|||||||
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
||||||
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||||
|
|
||||||
|
# Observer location settings
|
||||||
|
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
||||||
|
|
||||||
# Satellite settings
|
# Satellite settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||||
|
|
||||||
|
# Update checking
|
||||||
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||||
|
|
||||||
# Admin credentials
|
# Admin credentials
|
||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|||||||
+21
-10
@@ -1,18 +1,29 @@
|
|||||||
# TLE data for satellite tracking (updated periodically)
|
# TLE data for satellite tracking (updated periodically)
|
||||||
|
# To update: click "Update TLE" in satellite dashboard or SSTV mode
|
||||||
|
# Data source: CelesTrak (celestrak.org)
|
||||||
TLE_SATELLITES = {
|
TLE_SATELLITES = {
|
||||||
'ISS': ('ISS (ZARYA)',
|
'ISS': ('ISS (ZARYA)',
|
||||||
'1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
|
'1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991',
|
||||||
'2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
|
'2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'),
|
||||||
|
'NOAA-15': ('NOAA 15',
|
||||||
|
'1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999',
|
||||||
|
'2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'),
|
||||||
|
'NOAA-18': ('NOAA 18',
|
||||||
|
'1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996',
|
||||||
|
'2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668'),
|
||||||
|
'NOAA-19': ('NOAA 19',
|
||||||
|
'1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998',
|
||||||
|
'2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447'),
|
||||||
'NOAA-20': ('NOAA 20 (JPSS-1)',
|
'NOAA-20': ('NOAA 20 (JPSS-1)',
|
||||||
'1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995',
|
||||||
'2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
'2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'),
|
||||||
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
||||||
'1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995',
|
||||||
'2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
'2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'),
|
||||||
'METEOR-M2': ('METEOR-M 2',
|
'METEOR-M2': ('METEOR-M 2',
|
||||||
'1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990',
|
||||||
'2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
|
'2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'),
|
||||||
'METEOR-M2-3': ('METEOR-M2 3',
|
'METEOR-M2-3': ('METEOR-M2 3',
|
||||||
'1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
||||||
'2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
|
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ services:
|
|||||||
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
# - INTERCEPT_ADSB_DB_USER=intercept
|
# - INTERCEPT_ADSB_DB_USER=intercept
|
||||||
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
|
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
|
# Shared observer location across modules
|
||||||
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
# Network mode for WiFi scanning (requires host network)
|
# Network mode for WiFi scanning (requires host network)
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -68,6 +72,10 @@ services:
|
|||||||
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
- INTERCEPT_ADSB_DB_USER=intercept
|
- INTERCEPT_ADSB_DB_USER=intercept
|
||||||
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
|
# Shared observer location across modules
|
||||||
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
# iNTERCEPT UI Guide
|
||||||
|
|
||||||
|
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Design Tokens](#design-tokens)
|
||||||
|
2. [Base Templates](#base-templates)
|
||||||
|
3. [Navigation](#navigation)
|
||||||
|
4. [Components](#components)
|
||||||
|
5. [Adding a New Module Page](#adding-a-new-module-page)
|
||||||
|
6. [Adding a New Dashboard](#adding-a-new-dashboard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Backgrounds (layered depth) */
|
||||||
|
--bg-primary: #0a0c10; /* Darkest - page background */
|
||||||
|
--bg-secondary: #0f1218; /* Panels, sidebars */
|
||||||
|
--bg-tertiary: #151a23; /* Cards, elevated elements */
|
||||||
|
--bg-card: #121620; /* Card backgrounds */
|
||||||
|
--bg-elevated: #1a202c; /* Hover states, modals */
|
||||||
|
|
||||||
|
/* Accent Colors */
|
||||||
|
--accent-cyan: #4a9eff; /* Primary action color */
|
||||||
|
--accent-green: #22c55e; /* Success, online status */
|
||||||
|
--accent-red: #ef4444; /* Error, danger, stop */
|
||||||
|
--accent-orange: #f59e0b; /* Warning */
|
||||||
|
--accent-amber: #d4a853; /* Secondary highlight */
|
||||||
|
|
||||||
|
/* Text Hierarchy */
|
||||||
|
--text-primary: #e8eaed; /* Main content */
|
||||||
|
--text-secondary: #9ca3af; /* Secondary content */
|
||||||
|
--text-dim: #4b5563; /* Disabled, placeholder */
|
||||||
|
--text-muted: #374151; /* Barely visible */
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--status-online: #22c55e;
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-error: #ef4444;
|
||||||
|
--status-offline: #6b7280;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Font Families */
|
||||||
|
--font-sans: 'Inter', -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
--text-4xl: 30px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
```css
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Light Theme
|
||||||
|
|
||||||
|
The design system supports light/dark themes via `data-theme` attribute:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-theme="dark"> <!-- or "light" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Toggle with JavaScript:
|
||||||
|
```javascript
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base Templates
|
||||||
|
|
||||||
|
### `templates/layout/base.html`
|
||||||
|
|
||||||
|
The main base template for standard pages. Use for pages with sidebar + content layout.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}My Page Title{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% set active_mode = 'mymode' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
<div class="app-sidebar">
|
||||||
|
<!-- Sidebar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-container">
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<!-- Page content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Page-specific JavaScript
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `templates/layout/base_dashboard.html`
|
||||||
|
|
||||||
|
Extended base for full-screen dashboards (maps, visualizations).
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY DASHBOARD{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block stats_strip %}
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats bar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<div class="dashboard-map-container">
|
||||||
|
<!-- Main visualization -->
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-sidebar">
|
||||||
|
<!-- Sidebar panels -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Including Navigation
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% set active_mode = 'pager' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valid `active_mode` Values
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `pager` | Pager decoding |
|
||||||
|
| `sensor` | 433MHz sensors |
|
||||||
|
| `rtlamr` | Utility meters |
|
||||||
|
| `adsb` | Aircraft tracking |
|
||||||
|
| `ais` | Vessel tracking |
|
||||||
|
| `aprs` | Amateur radio |
|
||||||
|
| `wifi` | WiFi scanning |
|
||||||
|
| `bluetooth` | Bluetooth scanning |
|
||||||
|
| `tscm` | Counter-surveillance |
|
||||||
|
| `satellite` | Satellite tracking |
|
||||||
|
| `sstv` | ISS SSTV |
|
||||||
|
| `listening` | Listening post |
|
||||||
|
| `spystations` | Spy stations |
|
||||||
|
| `meshtastic` | Mesh networking |
|
||||||
|
|
||||||
|
### Navigation Groups
|
||||||
|
|
||||||
|
The navigation is organized into groups:
|
||||||
|
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||||
|
- **Wireless**: WiFi, Bluetooth
|
||||||
|
- **Security**: TSCM
|
||||||
|
- **Space**: Satellite, ISS SSTV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Card / Panel
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
|
||||||
|
<p>Panel content here</p>
|
||||||
|
{% endcall %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```html
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>PANEL TITLE</span>
|
||||||
|
<div class="panel-indicator active"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<p>Content here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% include 'components/empty_state.html' with context %}
|
||||||
|
{# Or with variables: #}
|
||||||
|
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
|
||||||
|
{% include 'components/empty_state.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# Inline spinner #}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
|
||||||
|
{# With text #}
|
||||||
|
{% with text='Loading data...', size='lg' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{# Full overlay #}
|
||||||
|
{% with overlay=true, text='Please wait...' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Badge
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% with status='online', text='Connected', id='connectionStatus' %}
|
||||||
|
{% include 'components/status_badge.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status values: `online`, `offline`, `warning`, `error`, `inactive`
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Primary action -->
|
||||||
|
<button class="btn btn-primary">Start Tracking</button>
|
||||||
|
|
||||||
|
<!-- Secondary action -->
|
||||||
|
<button class="btn btn-secondary">Cancel</button>
|
||||||
|
|
||||||
|
<!-- Danger action -->
|
||||||
|
<button class="btn btn-danger">Stop</button>
|
||||||
|
|
||||||
|
<!-- Ghost/subtle -->
|
||||||
|
<button class="btn btn-ghost">Settings</button>
|
||||||
|
|
||||||
|
<!-- Sizes -->
|
||||||
|
<button class="btn btn-primary btn-sm">Small</button>
|
||||||
|
<button class="btn btn-primary btn-lg">Large</button>
|
||||||
|
|
||||||
|
<!-- Icon button -->
|
||||||
|
<button class="btn btn-icon btn-secondary">
|
||||||
|
<span class="icon">...</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="badge">Default</span>
|
||||||
|
<span class="badge badge-primary">Primary</span>
|
||||||
|
<span class="badge badge-success">Online</span>
|
||||||
|
<span class="badge badge-warning">Warning</span>
|
||||||
|
<span class="badge badge-danger">Error</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Groups
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="frequency">Frequency (MHz)</label>
|
||||||
|
<input type="text" id="frequency" value="153.350">
|
||||||
|
<span class="form-help">Enter frequency in MHz</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="gain">Gain</label>
|
||||||
|
<select id="gain">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="30">30 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="alerts">
|
||||||
|
<span>Enable alerts</span>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats Strip
|
||||||
|
|
||||||
|
Used in dashboards for horizontal statistics display:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="stats-strip">
|
||||||
|
<div class="stats-strip-inner">
|
||||||
|
<div class="strip-stat">
|
||||||
|
<span class="strip-value" id="count">0</span>
|
||||||
|
<span class="strip-label">COUNT</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<div class="strip-status">
|
||||||
|
<div class="status-dot active" id="statusDot"></div>
|
||||||
|
<span id="statusText">TRACKING</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Module Page
|
||||||
|
|
||||||
|
### 1. Create the Route
|
||||||
|
|
||||||
|
In `routes/mymodule.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
|
||||||
|
|
||||||
|
@mymodule_bp.route('/dashboard')
|
||||||
|
def dashboard():
|
||||||
|
return render_template('mymodule_dashboard.html',
|
||||||
|
offline_settings=get_offline_settings())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register the Blueprint
|
||||||
|
|
||||||
|
In `routes/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from routes.mymodule import mymodule_bp
|
||||||
|
app.register_blueprint(mymodule_bp)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create the Template
|
||||||
|
|
||||||
|
Option A: Simple page extending base.html
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block title %}My Module{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Your content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B: Full-screen dashboard
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY MODULE{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<!-- Your dashboard content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add to Navigation
|
||||||
|
|
||||||
|
In `templates/partials/nav.html`, add your module to the appropriate group:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
onclick="switchMode('mymodule')">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if it's a dashboard link:
|
||||||
|
```html
|
||||||
|
<a href="/mymodule/dashboard"
|
||||||
|
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Stylesheet
|
||||||
|
|
||||||
|
In `static/css/mymodule.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Module Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
/* Your styles using design tokens */
|
||||||
|
.mymodule-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Dashboard
|
||||||
|
|
||||||
|
For full-screen dashboards like ADSB, AIS, or Satellite:
|
||||||
|
|
||||||
|
### 1. Create the Template
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MY DASHBOARD // iNTERCEPT</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
|
||||||
|
<!-- Design tokens (required) -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- External libraries if needed -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Dashboard styles -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Background effects -->
|
||||||
|
<div class="radar-bg"></div>
|
||||||
|
<div class="scanline"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<a href="/" style="color: inherit; text-decoration: none;">
|
||||||
|
MY DASHBOARD
|
||||||
|
<span>// iNTERCEPT</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
||||||
|
<a href="/" class="back-link">Main Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Unified Navigation -->
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
|
||||||
|
<!-- Stats Strip -->
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard Content -->
|
||||||
|
<main class="dashboard">
|
||||||
|
<!-- Your dashboard layout -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dashboard JavaScript
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the Stylesheet
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Dashboard Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Dashboard-specific aliases */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
--bg-card: var(--bg-tertiary);
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your dashboard styles */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### DO
|
||||||
|
|
||||||
|
- Use design tokens for all colors, spacing, and typography
|
||||||
|
- Include the nav partial on all pages for consistent navigation
|
||||||
|
- Set `active_mode` before including the nav partial
|
||||||
|
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
|
||||||
|
- Support both light and dark themes
|
||||||
|
- Test on mobile viewports
|
||||||
|
|
||||||
|
### DON'T
|
||||||
|
|
||||||
|
- Hardcode color values - use CSS variables
|
||||||
|
- Create new color variations without adding to tokens
|
||||||
|
- Duplicate navigation markup - use the partial
|
||||||
|
- Skip the favicon and design tokens imports
|
||||||
|
- Use inline styles for layout (use utility classes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/
|
||||||
|
├── layout/
|
||||||
|
│ ├── base.html # Standard page base
|
||||||
|
│ └── base_dashboard.html # Dashboard page base
|
||||||
|
├── partials/
|
||||||
|
│ ├── nav.html # Unified navigation
|
||||||
|
│ ├── page_header.html # Page title component
|
||||||
|
│ └── settings-modal.html # Settings modal
|
||||||
|
├── components/
|
||||||
|
│ ├── card.html # Panel/card component
|
||||||
|
│ ├── empty_state.html # Empty state placeholder
|
||||||
|
│ ├── loading.html # Loading spinner
|
||||||
|
│ ├── stats_strip.html # Stats bar component
|
||||||
|
│ └── status_badge.html # Status indicator
|
||||||
|
├── index.html # Main dashboard
|
||||||
|
├── adsb_dashboard.html # Aircraft tracking
|
||||||
|
├── ais_dashboard.html # Vessel tracking
|
||||||
|
└── satellite_dashboard.html # Satellite tracking
|
||||||
|
|
||||||
|
static/css/
|
||||||
|
├── core/
|
||||||
|
│ ├── variables.css # Design tokens
|
||||||
|
│ ├── base.css # Reset & typography
|
||||||
|
│ ├── components.css # Component styles
|
||||||
|
│ └── layout.css # Layout styles
|
||||||
|
├── index.css # Main dashboard styles
|
||||||
|
├── adsb_dashboard.css # Aircraft dashboard
|
||||||
|
├── ais_dashboard.css # Vessel dashboard
|
||||||
|
├── satellite_dashboard.css # Satellite dashboard
|
||||||
|
└── responsive.css # Responsive breakpoints
|
||||||
|
```
|
||||||
+76
-43
@@ -61,55 +61,88 @@ INTERCEPT automatically detects known trackers:
|
|||||||
|
|
||||||
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)
|
||||||
2. **Check Tools** - Ensure dump1090 or readsb is installed
|
2. **Check Tools** - Ensure dump1090 or readsb is installed
|
||||||
3. **Set Location** - Choose location source:
|
3. **Set Location** - Choose location source:
|
||||||
- **Manual Entry** - Type coordinates directly
|
- **Manual Entry** - Type coordinates directly
|
||||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
- **Shared Location** - By default, the observer location is shared across modules
|
||||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
||||||
|
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||||
|
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||||
6. **Click Aircraft** - Click markers for detailed information
|
6. **Click Aircraft** - Click markers for detailed information
|
||||||
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
|
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
|
||||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||||
|
|
||||||
|
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
|
||||||
|
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
||||||
|
|
||||||
### Emergency Squawks
|
### Emergency Squawks
|
||||||
|
|
||||||
The system highlights aircraft transmitting emergency squawks:
|
The system highlights aircraft transmitting emergency squawks:
|
||||||
- **7500** - Hijack
|
- **7500** - Hijack
|
||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
### Enable History
|
### Enable History
|
||||||
|
|
||||||
Set the following environment variables (Docker recommended):
|
Set the following environment variables (Docker recommended):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||||
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||||
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||||
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||||
|
|
||||||
### Docker Setup
|
### Other ADS-B Settings
|
||||||
|
|
||||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
```bash
|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||||
docker compose up -d
|
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||||
```
|
|
||||||
|
**Local install example**
|
||||||
### Using the History Dashboard
|
|
||||||
|
```bash
|
||||||
1. Open **/adsb/history**
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
2. Use **Start Tracking** to run ADS-B in headless mode
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
3. View aircraft history and timelines
|
python app.py
|
||||||
4. Stop tracking when desired (session history is recorded)
|
```
|
||||||
|
|
||||||
|
**Docker example (.env)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Setup
|
||||||
|
|
||||||
|
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile history up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the History Dashboard
|
||||||
|
|
||||||
|
1. Open **/adsb/history**
|
||||||
|
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||||
|
3. View aircraft history and timelines
|
||||||
|
4. Stop tracking when desired (session history is recorded)
|
||||||
|
|
||||||
## Satellite Mode
|
## Satellite Mode
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 694 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 585 KiB After Width: | Height: | Size: 694 KiB |
+7
-1
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">12+</span>
|
<span class="stat-value">15+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -142,6 +142,12 @@
|
|||||||
<h3>Meshtastic</h3>
|
<h3>Meshtastic</h3>
|
||||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+279
-41
@@ -872,6 +872,150 @@ class ModeManager:
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# WiFi Monitor Mode
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def toggle_monitor_mode(self, params: dict) -> dict:
|
||||||
|
"""Enable or disable monitor mode on a WiFi interface."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
action = params.get('action', 'start')
|
||||||
|
interface = params.get('interface', '')
|
||||||
|
kill_processes = params.get('kill_processes', False)
|
||||||
|
|
||||||
|
# Validate interface name (alphanumeric, underscore, dash only)
|
||||||
|
if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface):
|
||||||
|
return {'status': 'error', 'message': 'Invalid interface name'}
|
||||||
|
|
||||||
|
airmon_path = self._get_tool_path('airmon-ng')
|
||||||
|
iw_path = self._get_tool_path('iw')
|
||||||
|
|
||||||
|
if action == 'start':
|
||||||
|
if airmon_path:
|
||||||
|
try:
|
||||||
|
# Get interfaces before
|
||||||
|
def get_wireless_interfaces():
|
||||||
|
interfaces = set()
|
||||||
|
try:
|
||||||
|
for iface in os.listdir('/sys/class/net'):
|
||||||
|
if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface:
|
||||||
|
interfaces.add(iface)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
interfaces_before = get_wireless_interfaces()
|
||||||
|
|
||||||
|
# Kill interfering processes if requested
|
||||||
|
if kill_processes:
|
||||||
|
subprocess.run([airmon_path, 'check', 'kill'],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
|
||||||
|
# Start monitor mode
|
||||||
|
result = subprocess.run([airmon_path, 'start', interface],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
interfaces_after = get_wireless_interfaces()
|
||||||
|
|
||||||
|
# Find the new monitor interface
|
||||||
|
new_interfaces = interfaces_after - interfaces_before
|
||||||
|
monitor_iface = None
|
||||||
|
|
||||||
|
if new_interfaces:
|
||||||
|
for iface in new_interfaces:
|
||||||
|
if 'mon' in iface:
|
||||||
|
monitor_iface = iface
|
||||||
|
break
|
||||||
|
if not monitor_iface:
|
||||||
|
monitor_iface = list(new_interfaces)[0]
|
||||||
|
|
||||||
|
# Try to parse from airmon-ng output
|
||||||
|
if not monitor_iface:
|
||||||
|
patterns = [
|
||||||
|
r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b',
|
||||||
|
r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)',
|
||||||
|
r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)',
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, output, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
candidate = match.group(1)
|
||||||
|
if candidate and not candidate[0].isdigit():
|
||||||
|
monitor_iface = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback: check if original interface is in monitor mode
|
||||||
|
if not monitor_iface:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['iwconfig', interface],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if 'Mode:Monitor' in result.stdout:
|
||||||
|
monitor_iface = interface
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Last resort: try common naming
|
||||||
|
if not monitor_iface:
|
||||||
|
potential = interface + 'mon'
|
||||||
|
if os.path.exists(f'/sys/class/net/{potential}'):
|
||||||
|
monitor_iface = potential
|
||||||
|
|
||||||
|
if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'):
|
||||||
|
all_wireless = list(get_wireless_interfaces())
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
||||||
|
}
|
||||||
|
|
||||||
|
self.wifi_monitor_interface = monitor_iface
|
||||||
|
self._capabilities = None # Invalidate cache so interfaces refresh
|
||||||
|
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||||
|
return {'status': 'success', 'monitor_interface': monitor_iface}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error enabling monitor mode: {e}")
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
|
elif iw_path:
|
||||||
|
try:
|
||||||
|
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||||
|
subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True)
|
||||||
|
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||||
|
self.wifi_monitor_interface = interface
|
||||||
|
self._capabilities = None # Invalidate cache
|
||||||
|
return {'status': 'success', 'monitor_interface': interface}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
else:
|
||||||
|
return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'}
|
||||||
|
|
||||||
|
else: # stop
|
||||||
|
current_iface = getattr(self, 'wifi_monitor_interface', None) or interface
|
||||||
|
if airmon_path:
|
||||||
|
try:
|
||||||
|
subprocess.run([airmon_path, 'stop', current_iface],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
self.wifi_monitor_interface = None
|
||||||
|
self._capabilities = None # Invalidate cache
|
||||||
|
return {'status': 'success', 'message': 'Monitor mode disabled'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
elif iw_path:
|
||||||
|
try:
|
||||||
|
subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True)
|
||||||
|
subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True)
|
||||||
|
subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True)
|
||||||
|
self.wifi_monitor_interface = None
|
||||||
|
self._capabilities = None # Invalidate cache
|
||||||
|
return {'status': 'success', 'message': 'Monitor mode disabled'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
|
return {'status': 'error', 'message': 'Unknown action'}
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Mode-specific implementations
|
# Mode-specific implementations
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -914,26 +1058,34 @@ class ModeManager:
|
|||||||
"""Internal mode stop - terminates processes and cleans up."""
|
"""Internal mode stop - terminates processes and cleans up."""
|
||||||
logger.info(f"Stopping mode {mode}")
|
logger.info(f"Stopping mode {mode}")
|
||||||
|
|
||||||
# Signal stop
|
# Signal stop first - this unblocks any waiting threads
|
||||||
if mode in self.stop_events:
|
if mode in self.stop_events:
|
||||||
self.stop_events[mode].set()
|
self.stop_events[mode].set()
|
||||||
|
|
||||||
# Terminate process if running
|
# Terminate process if running
|
||||||
if mode in self.processes:
|
if mode in self.processes:
|
||||||
proc = self.processes[mode]
|
proc = self.processes[mode]
|
||||||
if proc and proc.poll() is None:
|
try:
|
||||||
proc.terminate()
|
if proc and proc.poll() is None:
|
||||||
try:
|
proc.terminate()
|
||||||
proc.wait(timeout=3)
|
try:
|
||||||
except subprocess.TimeoutExpired:
|
proc.wait(timeout=2)
|
||||||
proc.kill()
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except (OSError, ProcessLookupError) as e:
|
||||||
|
# Process already dead or inaccessible
|
||||||
|
logger.debug(f"Process cleanup for {mode}: {e}")
|
||||||
del self.processes[mode]
|
del self.processes[mode]
|
||||||
|
|
||||||
# Wait for output thread
|
# Wait for output thread (short timeout since stop event is set)
|
||||||
if mode in self.output_threads:
|
if mode in self.output_threads:
|
||||||
thread = self.output_threads[mode]
|
thread = self.output_threads[mode]
|
||||||
if thread and thread.is_alive():
|
if thread and thread.is_alive():
|
||||||
thread.join(timeout=2)
|
thread.join(timeout=1)
|
||||||
del self.output_threads[mode]
|
del self.output_threads[mode]
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
@@ -1137,10 +1289,16 @@ class ModeManager:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass # Not JSON, ignore
|
pass # Not JSON, ignore
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
# Bad file descriptor or closed file - process was terminated
|
||||||
|
logger.debug(f"Sensor output reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Sensor output reader error: {e}")
|
logger.error(f"Sensor output reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Sensor output reader stopped")
|
logger.info("Sensor output reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2102,15 +2260,24 @@ class ModeManager:
|
|||||||
|
|
||||||
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
|
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
# Bad file descriptor or closed file - process was terminated
|
||||||
|
logger.debug(f"Pager reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Pager reader error: {e}")
|
logger.error(f"Pager reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'pager_rtl' in self.processes:
|
if 'pager_rtl' in self.processes:
|
||||||
rtl_proc = self.processes['pager_rtl']
|
try:
|
||||||
if rtl_proc.poll() is None:
|
rtl_proc = self.processes['pager_rtl']
|
||||||
rtl_proc.terminate()
|
if rtl_proc.poll() is None:
|
||||||
del self.processes['pager_rtl']
|
rtl_proc.terminate()
|
||||||
|
del self.processes['pager_rtl']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Pager reader stopped")
|
logger.info("Pager reader stopped")
|
||||||
|
|
||||||
def _parse_pager_message(self, line: str) -> dict | None:
|
def _parse_pager_message(self, line: str) -> dict | None:
|
||||||
@@ -2492,10 +2659,15 @@ class ModeManager:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"ACARS reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ACARS reader error: {e}")
|
logger.error(f"ACARS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("ACARS reader stopped")
|
logger.info("ACARS reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2632,15 +2804,23 @@ class ModeManager:
|
|||||||
|
|
||||||
logger.debug(f"APRS: {callsign}")
|
logger.debug(f"APRS: {callsign}")
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"APRS reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"APRS reader error: {e}")
|
logger.error(f"APRS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'aprs_rtl' in self.processes:
|
if 'aprs_rtl' in self.processes:
|
||||||
rtl_proc = self.processes['aprs_rtl']
|
try:
|
||||||
if rtl_proc.poll() is None:
|
rtl_proc = self.processes['aprs_rtl']
|
||||||
rtl_proc.terminate()
|
if rtl_proc.poll() is None:
|
||||||
del self.processes['aprs_rtl']
|
rtl_proc.terminate()
|
||||||
|
del self.processes['aprs_rtl']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("APRS reader stopped")
|
logger.info("APRS reader stopped")
|
||||||
|
|
||||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||||
@@ -2788,15 +2968,23 @@ class ModeManager:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"RTLAMR reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"RTLAMR reader error: {e}")
|
logger.error(f"RTLAMR reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'rtlamr_tcp' in self.processes:
|
if 'rtlamr_tcp' in self.processes:
|
||||||
tcp_proc = self.processes['rtlamr_tcp']
|
try:
|
||||||
if tcp_proc.poll() is None:
|
tcp_proc = self.processes['rtlamr_tcp']
|
||||||
tcp_proc.terminate()
|
if tcp_proc.poll() is None:
|
||||||
del self.processes['rtlamr_tcp']
|
tcp_proc.terminate()
|
||||||
|
del self.processes['rtlamr_tcp']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("RTLAMR reader stopped")
|
logger.info("RTLAMR reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2901,10 +3089,15 @@ class ModeManager:
|
|||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("DSCDecoder not available (missing scipy/numpy)")
|
logger.warning("DSCDecoder not available (missing scipy/numpy)")
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"DSC reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DSC reader error: {e}")
|
logger.error(f"DSC reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("DSC reader stopped")
|
logger.info("DSC reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -3629,6 +3822,12 @@ class InterceptAgentHandler(BaseHTTPRequestHandler):
|
|||||||
config.push_interval = int(body['push_interval'])
|
config.push_interval = int(body['push_interval'])
|
||||||
self._send_json({'status': 'updated', 'config': config.to_dict()})
|
self._send_json({'status': 'updated', 'config': config.to_dict()})
|
||||||
|
|
||||||
|
elif path == '/wifi/monitor':
|
||||||
|
# Enable/disable monitor mode on WiFi interface
|
||||||
|
result = mode_manager.toggle_monitor_mode(body)
|
||||||
|
status = 200 if result.get('status') == 'success' else 400
|
||||||
|
self._send_json(result, status)
|
||||||
|
|
||||||
elif path.startswith('/') and path.count('/') == 2:
|
elif path.startswith('/') and path.count('/') == 2:
|
||||||
# /{mode}/start or /{mode}/stop
|
# /{mode}/start or /{mode}/stop
|
||||||
parts = path.split('/')
|
parts = path.split('/')
|
||||||
@@ -3794,19 +3993,53 @@ def main():
|
|||||||
print(" Press Ctrl+C to stop")
|
print(" Press Ctrl+C to stop")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Handle shutdown
|
# Shutdown flag
|
||||||
|
shutdown_requested = threading.Event()
|
||||||
|
|
||||||
|
# Handle shutdown - run cleanup in separate thread to avoid blocking
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
|
if shutdown_requested.is_set():
|
||||||
|
# Already shutting down, force exit
|
||||||
|
print("\nForce exit...")
|
||||||
|
os._exit(1)
|
||||||
|
shutdown_requested.set()
|
||||||
print("\nShutting down...")
|
print("\nShutting down...")
|
||||||
# Stop all running modes
|
|
||||||
for mode in list(mode_manager.running_modes.keys()):
|
def cleanup():
|
||||||
mode_manager.stop_mode(mode)
|
# Stop all running modes first (they have subprocesses)
|
||||||
if data_push_loop:
|
for mode in list(mode_manager.running_modes.keys()):
|
||||||
data_push_loop.stop()
|
try:
|
||||||
if push_client:
|
mode_manager.stop_mode(mode)
|
||||||
push_client.stop()
|
except Exception as e:
|
||||||
gps_manager.stop()
|
logger.debug(f"Error stopping {mode}: {e}")
|
||||||
httpd.shutdown()
|
|
||||||
sys.exit(0)
|
# Stop push services
|
||||||
|
if data_push_loop:
|
||||||
|
try:
|
||||||
|
data_push_loop.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if push_client:
|
||||||
|
try:
|
||||||
|
push_client.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stop GPS
|
||||||
|
try:
|
||||||
|
gps_manager.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Shutdown HTTP server
|
||||||
|
try:
|
||||||
|
httpd.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Run cleanup in background thread so signal handler returns quickly
|
||||||
|
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
||||||
|
cleanup_thread.start()
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
@@ -3815,9 +4048,14 @@ def main():
|
|||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
except Exception:
|
||||||
if push_client:
|
pass
|
||||||
push_client.stop()
|
|
||||||
|
# Give cleanup thread time to finish
|
||||||
|
if shutdown_requested.is_set():
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print("Agent stopped.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.10.0"
|
version = "2.13.1"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -26,7 +26,7 @@ classifiers = [
|
|||||||
"Topic :: System :: Networking :: Monitoring",
|
"Topic :: System :: Networking :: Monitoring",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=2.0.0",
|
"flask>=3.0.0",
|
||||||
"skyfield>=1.45",
|
"skyfield>=1.45",
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
"Werkzeug>=3.1.5",
|
"Werkzeug>=3.1.5",
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
flask>=2.0.0
|
flask>=3.0.0
|
||||||
flask-limiter>=2.5.4
|
flask-limiter>=2.5.4
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
Werkzeug>=3.1.5
|
Werkzeug>=3.1.5
|
||||||
@@ -23,6 +23,12 @@ pyserial>=3.5
|
|||||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||||
meshtastic>=2.0.0
|
meshtastic>=2.0.0
|
||||||
|
|
||||||
|
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||||
|
scapy>=2.4.5
|
||||||
|
|
||||||
|
# QR code generation for Meshtastic channels (optional)
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
|
||||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||||
# pytest>=7.0.0
|
# pytest>=7.0.0
|
||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ def register_blueprints(app):
|
|||||||
from .spy_stations import spy_stations_bp
|
from .spy_stations import spy_stations_bp
|
||||||
from .controller import controller_bp
|
from .controller import controller_bp
|
||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
|
from .updater import updater_bp
|
||||||
|
from .sstv import sstv_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -47,6 +49,8 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
app.register_blueprint(controller_bp) # Remote agent controller
|
app.register_blueprint(controller_bp) # Remote agent controller
|
||||||
app.register_blueprint(offline_bp) # Offline mode settings
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
|
app.register_blueprint(updater_bp) # GitHub update checking
|
||||||
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
+31
-2
@@ -43,6 +43,9 @@ DEFAULT_ACARS_FREQUENCIES = [
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
acars_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def find_acarsdec():
|
def find_acarsdec():
|
||||||
"""Find acarsdec binary."""
|
"""Find acarsdec binary."""
|
||||||
@@ -175,7 +178,7 @@ def acars_status() -> Response:
|
|||||||
@acars_bp.route('/start', methods=['POST'])
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
def start_acars() -> Response:
|
def start_acars() -> Response:
|
||||||
"""Start ACARS decoder."""
|
"""Start ACARS decoder."""
|
||||||
global acars_message_count, acars_last_message_time
|
global acars_message_count, acars_last_message_time, acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
@@ -202,6 +205,18 @@ def start_acars() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
acars_active_device = device_int
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
if isinstance(frequencies, str):
|
if isinstance(frequencies, str):
|
||||||
@@ -282,7 +297,10 @@ def start_acars() -> Response:
|
|||||||
time.sleep(PROCESS_START_WAIT)
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died
|
# Process died - release device
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
@@ -310,6 +328,10 @@ def start_acars() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -317,6 +339,8 @@ def start_acars() -> Response:
|
|||||||
@acars_bp.route('/stop', methods=['POST'])
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
def stop_acars() -> Response:
|
def stop_acars() -> Response:
|
||||||
"""Stop ACARS decoder."""
|
"""Stop ACARS decoder."""
|
||||||
|
global acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if not app_module.acars_process:
|
if not app_module.acars_process:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -334,6 +358,11 @@ def stop_acars() -> Response:
|
|||||||
|
|
||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+531
-471
File diff suppressed because it is too large
Load Diff
+24
-1
@@ -15,6 +15,7 @@ from typing import Generator
|
|||||||
from flask import Blueprint, jsonify, request, Response, render_template
|
from flask import Blueprint, jsonify, request, Response, render_template
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
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 format_sse
|
||||||
@@ -369,6 +370,16 @@ def start_ais():
|
|||||||
app_module.ais_process = None
|
app_module.ais_process = None
|
||||||
logger.info("Killed existing AIS process")
|
logger.info("Killed existing AIS process")
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Build command using SDR abstraction
|
# Build command using SDR abstraction
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
@@ -399,6 +410,8 @@ def start_ais():
|
|||||||
time.sleep(2.0)
|
time.sleep(2.0)
|
||||||
|
|
||||||
if app_module.ais_process.poll() is not None:
|
if app_module.ais_process.poll() is not None:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.ais_process.stderr:
|
if app_module.ais_process.stderr:
|
||||||
try:
|
try:
|
||||||
@@ -424,6 +437,8 @@ def start_ais():
|
|||||||
'port': tcp_port
|
'port': tcp_port
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -447,6 +462,11 @@ def stop_ais():
|
|||||||
pass
|
pass
|
||||||
app_module.ais_process = None
|
app_module.ais_process = None
|
||||||
logger.info("AIS process stopped")
|
logger.info("AIS process stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if ais_active_device is not None:
|
||||||
|
app_module.release_sdr_device(ais_active_device)
|
||||||
|
|
||||||
ais_running = False
|
ais_running = False
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
|
|
||||||
@@ -480,4 +500,7 @@ def stream_ais():
|
|||||||
@ais_bp.route('/dashboard')
|
@ais_bp.route('/dashboard')
|
||||||
def ais_dashboard():
|
def ais_dashboard():
|
||||||
"""Popout AIS dashboard."""
|
"""Popout AIS dashboard."""
|
||||||
return render_template('ais_dashboard.html')
|
return render_template(
|
||||||
|
'ais_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
|
|||||||
@@ -228,9 +228,13 @@ def init_audio_websocket(app: Flask):
|
|||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "timed out" not in str(e).lower():
|
msg = str(e).lower()
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
if "connection closed" in msg:
|
||||||
|
logger.info("WebSocket closed by client")
|
||||||
|
break
|
||||||
|
if "timed out" not in msg:
|
||||||
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
|
||||||
# Stream audio data if active
|
# Stream audio data if active
|
||||||
if streaming and proc and proc.poll() is None:
|
if streaming and proc and proc.poll() is None:
|
||||||
|
|||||||
+66
-1
@@ -91,6 +91,17 @@ def register_agent():
|
|||||||
if not base_url:
|
if not base_url:
|
||||||
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
try:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
||||||
|
if not parsed.netloc:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
|
|
||||||
# Check if agent already exists
|
# Check if agent already exists
|
||||||
existing = get_agent_by_name(name)
|
existing = get_agent_by_name(name)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -128,9 +139,12 @@ def register_agent():
|
|||||||
update_agent(agent_id, update_last_seen=True)
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
|
message = 'Agent registered successfully'
|
||||||
|
if capabilities is None:
|
||||||
|
message += ' (could not connect - agent may be offline)'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': 'Agent registered successfully',
|
'message': message,
|
||||||
'agent': agent
|
'agent': agent
|
||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
@@ -466,6 +480,57 @@ def proxy_mode_data(agent_id: int, mode: str):
|
|||||||
}), 502
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||||
|
def proxy_wifi_monitor(agent_id: int):
|
||||||
|
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.post('/wifi/monitor', data)
|
||||||
|
|
||||||
|
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
|
||||||
|
if result.get('status') == 'success':
|
||||||
|
try:
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
if metadata.get('healthy'):
|
||||||
|
caps = metadata.get('capabilities') or {}
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-fatal if refresh fails
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': result.get('status', 'error'),
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'monitor_interface': result.get('monitor_interface'),
|
||||||
|
'message': result.get('message')
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Push Data Ingestion
|
# Push Data Ingestion
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+29
-16
@@ -47,6 +47,9 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
|
|||||||
# Module state (track if running independent of process state)
|
# Module state (track if running independent of process state)
|
||||||
dsc_running = False
|
dsc_running = False
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
dsc_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_dsc_decoder_path() -> str | None:
|
def _get_dsc_decoder_path() -> str | None:
|
||||||
"""Get path to DSC decoder."""
|
"""Get path to DSC decoder."""
|
||||||
@@ -309,21 +312,18 @@ def start_decoding() -> Response:
|
|||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Check if device is in use by AIS
|
# Check if device is available using centralized registry
|
||||||
try:
|
global dsc_active_device
|
||||||
from routes import ais as ais_module
|
device_int = int(device)
|
||||||
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
|
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||||
# AIS is running - check if same device
|
if error:
|
||||||
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
|
return jsonify({
|
||||||
return jsonify({
|
'status': 'error',
|
||||||
'status': 'error',
|
'error_type': 'DEVICE_BUSY',
|
||||||
'error_type': 'DEVICE_BUSY',
|
'message': error
|
||||||
'message': f'SDR device {device} is in use by AIS tracking',
|
}), 409
|
||||||
'suggestion': 'Use a different SDR device or stop AIS tracking first',
|
|
||||||
'in_use_by': 'ais'
|
dsc_active_device = device_int
|
||||||
}), 409
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.dsc_queue.empty():
|
while not app_module.dsc_queue.empty():
|
||||||
@@ -408,11 +408,19 @@ def start_decoding() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
# Release device on failure
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Tool not found: {e.filename}'
|
'message': f'Tool not found: {e.filename}'
|
||||||
}), 400
|
}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
logger.error(f"Failed to start DSC decoder: {e}")
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -423,7 +431,7 @@ def start_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stop', methods=['POST'])
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
"""Stop DSC decoder."""
|
"""Stop DSC decoder."""
|
||||||
global dsc_running
|
global dsc_running, dsc_active_device
|
||||||
|
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
if not app_module.dsc_process:
|
if not app_module.dsc_process:
|
||||||
@@ -460,6 +468,11 @@ def stop_decoding() -> Response:
|
|||||||
app_module.dsc_process = None
|
app_module.dsc_process = None
|
||||||
app_module.dsc_rtl_process = None
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+720
-257
File diff suppressed because it is too large
Load Diff
+571
-9
@@ -3,8 +3,9 @@
|
|||||||
Provides endpoints for connecting to Meshtastic devices, configuring
|
Provides endpoints for connecting to Meshtastic devices, configuring
|
||||||
channels with encryption keys, and streaming received messages.
|
channels with encryption keys, and streaming received messages.
|
||||||
|
|
||||||
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
|
Supports multiple connection types:
|
||||||
connected via USB/Serial.
|
- USB/Serial: Physical device connected via USB
|
||||||
|
- TCP: WiFi-enabled devices accessible via IP address
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -57,13 +58,45 @@ def _message_callback(msg: MeshtasticMessage) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/ports')
|
||||||
|
def list_ports():
|
||||||
|
"""
|
||||||
|
List available serial ports that may have Meshtastic devices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of available serial ports.
|
||||||
|
"""
|
||||||
|
if not is_meshtastic_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'ports': [],
|
||||||
|
'message': 'Meshtastic SDK not installed'
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from meshtastic.util import findPorts
|
||||||
|
ports = findPorts()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'ports': ports,
|
||||||
|
'count': len(ports)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing ports: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'ports': [],
|
||||||
|
'message': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@meshtastic_bp.route('/status')
|
@meshtastic_bp.route('/status')
|
||||||
def get_status():
|
def get_status():
|
||||||
"""
|
"""
|
||||||
Get Meshtastic connection status.
|
Get Meshtastic connection status.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with connection status, device info, and node information.
|
JSON with connection status, device info, connection type, and node information.
|
||||||
"""
|
"""
|
||||||
if not is_meshtastic_available():
|
if not is_meshtastic_available():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -79,6 +112,7 @@ def get_status():
|
|||||||
'available': True,
|
'available': True,
|
||||||
'running': False,
|
'running': False,
|
||||||
'device': None,
|
'device': None,
|
||||||
|
'connection_type': None,
|
||||||
'node_info': None,
|
'node_info': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -88,6 +122,7 @@ def get_status():
|
|||||||
'available': True,
|
'available': True,
|
||||||
'running': client.is_running,
|
'running': client.is_running,
|
||||||
'device': client.device_path,
|
'device': client.device_path,
|
||||||
|
'connection_type': client.connection_type,
|
||||||
'error': client.error,
|
'error': client.error,
|
||||||
'node_info': node_info.to_dict() if node_info else None,
|
'node_info': node_info.to_dict() if node_info else None,
|
||||||
})
|
})
|
||||||
@@ -99,13 +134,20 @@ def start_mesh():
|
|||||||
Start Meshtastic listener.
|
Start Meshtastic listener.
|
||||||
|
|
||||||
Connects to a Meshtastic device and begins receiving messages.
|
Connects to a Meshtastic device and begins receiving messages.
|
||||||
The device must be connected via USB/Serial.
|
Supports both USB/Serial and TCP connections.
|
||||||
|
|
||||||
JSON body (optional):
|
JSON body (optional):
|
||||||
{
|
{
|
||||||
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
|
"connection_type": "serial", // 'serial' (default) or 'tcp'
|
||||||
|
"device": "/dev/ttyUSB0", // Serial port path. Auto-discovers if not provided.
|
||||||
|
"hostname": "192.168.1.100" // IP address or hostname for TCP connections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Serial (auto-discover): {}
|
||||||
|
Serial (specific port): {"device": "/dev/ttyUSB0"}
|
||||||
|
TCP: {"connection_type": "tcp", "hostname": "192.168.1.100"}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with connection status.
|
JSON with connection status.
|
||||||
"""
|
"""
|
||||||
@@ -119,7 +161,8 @@ def start_mesh():
|
|||||||
if client and client.is_running:
|
if client and client.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'device': client.device_path
|
'device': client.device_path,
|
||||||
|
'connection_type': client.connection_type
|
||||||
})
|
})
|
||||||
|
|
||||||
# Clear queue and history
|
# Clear queue and history
|
||||||
@@ -130,18 +173,46 @@ def start_mesh():
|
|||||||
break
|
break
|
||||||
_recent_messages.clear()
|
_recent_messages.clear()
|
||||||
|
|
||||||
# Get optional device path
|
# Parse connection parameters
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
connection_type = data.get('connection_type', 'serial').lower().strip()
|
||||||
device = data.get('device')
|
device = data.get('device')
|
||||||
|
hostname = data.get('hostname')
|
||||||
|
|
||||||
# Validate device path if provided
|
# Validate connection type
|
||||||
|
if connection_type not in ('serial', 'tcp'):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate TCP parameters
|
||||||
|
if connection_type == 'tcp':
|
||||||
|
if not hostname:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'hostname is required for TCP connections'
|
||||||
|
}), 400
|
||||||
|
hostname = str(hostname).strip()
|
||||||
|
if not hostname:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'hostname cannot be empty'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate serial device path if provided
|
||||||
if device:
|
if device:
|
||||||
device = str(device).strip()
|
device = str(device).strip()
|
||||||
if not device:
|
if not device:
|
||||||
device = None
|
device = None
|
||||||
|
|
||||||
# Start client
|
# Start client
|
||||||
success = start_meshtastic(device=device, callback=_message_callback)
|
success = start_meshtastic(
|
||||||
|
device=device,
|
||||||
|
callback=_message_callback,
|
||||||
|
connection_type=connection_type,
|
||||||
|
hostname=hostname
|
||||||
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
client = get_meshtastic_client()
|
client = get_meshtastic_client()
|
||||||
@@ -149,6 +220,7 @@ def start_mesh():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'device': client.device_path if client else None,
|
'device': client.device_path if client else None,
|
||||||
|
'connection_type': client.connection_type if client else None,
|
||||||
'node_info': node_info.to_dict() if node_info else None,
|
'node_info': node_info.to_dict() if node_info else None,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -489,3 +561,493 @@ def get_nodes():
|
|||||||
'count': len(nodes_list),
|
'count': len(nodes_list),
|
||||||
'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
|
'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/traceroute', methods=['POST'])
|
||||||
|
def send_traceroute():
|
||||||
|
"""
|
||||||
|
Send a traceroute request to a mesh node.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"destination": "!a1b2c3d4", // Required: target node ID
|
||||||
|
"hop_limit": 7 // Optional: max hops (1-7, default 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with traceroute request status.
|
||||||
|
"""
|
||||||
|
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 to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
destination = data.get('destination')
|
||||||
|
|
||||||
|
if not destination:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Destination node ID is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
hop_limit = data.get('hop_limit', 7)
|
||||||
|
if not isinstance(hop_limit, int) or not 1 <= hop_limit <= 7:
|
||||||
|
hop_limit = 7
|
||||||
|
|
||||||
|
success, error = client.send_traceroute(destination, hop_limit=hop_limit)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'sent',
|
||||||
|
'destination': destination,
|
||||||
|
'hop_limit': hop_limit
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error or 'Failed to send traceroute'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/traceroute/results')
|
||||||
|
def get_traceroute_results():
|
||||||
|
"""
|
||||||
|
Get recent traceroute results.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
limit: Maximum number of results to return (default: 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of traceroute results.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device',
|
||||||
|
'results': []
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
limit = request.args.get('limit', 10, type=int)
|
||||||
|
results = client.get_traceroute_results(limit=limit)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'results': [r.to_dict() for r in results],
|
||||||
|
'count': len(results)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/position/request', methods=['POST'])
|
||||||
|
def request_position():
|
||||||
|
"""
|
||||||
|
Request position from a specific node.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"node_id": "!a1b2c3d4" // Required: target node ID
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with request status.
|
||||||
|
"""
|
||||||
|
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 to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
node_id = data.get('node_id')
|
||||||
|
|
||||||
|
if not node_id:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Node ID is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
success, error = client.request_position(node_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'sent',
|
||||||
|
'node_id': node_id
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error or 'Failed to request position'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/firmware/check')
|
||||||
|
def check_firmware():
|
||||||
|
"""
|
||||||
|
Check current firmware version and compare to latest release.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with current_version, latest_version, update_available, release_url.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
result = client.check_firmware()
|
||||||
|
result['status'] = 'ok'
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/channels/<int:index>/qr')
|
||||||
|
def get_channel_qr(index: int):
|
||||||
|
"""
|
||||||
|
Generate QR code for a channel configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Channel index (0-7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PNG image of QR code.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not 0 <= index <= 7:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Channel index must be 0-7'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
png_data = client.generate_channel_qr(index)
|
||||||
|
|
||||||
|
if png_data:
|
||||||
|
return Response(png_data, mimetype='image/png')
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to generate QR code. Make sure qrcode library is installed.'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/telemetry/history')
|
||||||
|
def get_telemetry_history():
|
||||||
|
"""
|
||||||
|
Get telemetry history for a node.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
node_id: Node ID or number (required)
|
||||||
|
hours: Number of hours of history (default: 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with telemetry data points.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device',
|
||||||
|
'data': []
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
node_id = request.args.get('node_id')
|
||||||
|
hours = request.args.get('hours', 24, type=int)
|
||||||
|
|
||||||
|
if not node_id:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'node_id is required',
|
||||||
|
'data': []
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Parse node ID to number
|
||||||
|
try:
|
||||||
|
if node_id.startswith('!'):
|
||||||
|
node_num = int(node_id[1:], 16)
|
||||||
|
else:
|
||||||
|
node_num = int(node_id)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid node_id: {node_id}',
|
||||||
|
'data': []
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
history = client.get_telemetry_history(node_num, hours=hours)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'node_id': node_id,
|
||||||
|
'hours': hours,
|
||||||
|
'data': [p.to_dict() for p in history],
|
||||||
|
'count': len(history)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/neighbors')
|
||||||
|
def get_neighbors():
|
||||||
|
"""
|
||||||
|
Get neighbor information for mesh topology visualization.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
node_id: Specific node ID (optional, returns all if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with neighbor relationships.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device',
|
||||||
|
'neighbors': {}
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
node_id = request.args.get('node_id')
|
||||||
|
node_num = None
|
||||||
|
|
||||||
|
if node_id:
|
||||||
|
try:
|
||||||
|
if node_id.startswith('!'):
|
||||||
|
node_num = int(node_id[1:], 16)
|
||||||
|
else:
|
||||||
|
node_num = int(node_id)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid node_id: {node_id}',
|
||||||
|
'neighbors': {}
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
neighbors = client.get_neighbors(node_num)
|
||||||
|
|
||||||
|
# Convert to JSON-serializable format
|
||||||
|
result = {}
|
||||||
|
for num, neighbor_list in neighbors.items():
|
||||||
|
node_key = f"!{num:08x}"
|
||||||
|
result[node_key] = [n.to_dict() for n in neighbor_list]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'neighbors': result,
|
||||||
|
'node_count': len(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/pending')
|
||||||
|
def get_pending_messages():
|
||||||
|
"""
|
||||||
|
Get messages waiting for ACK.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with pending messages and their status.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device',
|
||||||
|
'messages': []
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
pending = client.get_pending_messages()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'messages': [m.to_dict() for m in pending.values()],
|
||||||
|
'count': len(pending)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/range-test/start', methods=['POST'])
|
||||||
|
def start_range_test():
|
||||||
|
"""
|
||||||
|
Start a range test.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"count": 10, // Number of packets to send (default 10)
|
||||||
|
"interval": 5 // Seconds between packets (default 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
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 to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
count = data.get('count', 10)
|
||||||
|
interval = data.get('interval', 5)
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if not isinstance(count, int) or count < 1 or count > 100:
|
||||||
|
count = 10
|
||||||
|
if not isinstance(interval, int) or interval < 1 or interval > 60:
|
||||||
|
interval = 5
|
||||||
|
|
||||||
|
success, error = client.start_range_test(count=count, interval=interval)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'count': count,
|
||||||
|
'interval': interval
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error or 'Failed to start range test'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/range-test/stop', methods=['POST'])
|
||||||
|
def stop_range_test():
|
||||||
|
"""
|
||||||
|
Stop an ongoing range test.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if client:
|
||||||
|
client.stop_range_test()
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/range-test/status')
|
||||||
|
def get_range_test_status():
|
||||||
|
"""
|
||||||
|
Get range test status and results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with running status and results.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device',
|
||||||
|
'running': False,
|
||||||
|
'results': []
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
status = client.get_range_test_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
**status
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/store-forward/status')
|
||||||
|
def get_store_forward_status():
|
||||||
|
"""
|
||||||
|
Check if Store & Forward router is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with availability status and router info.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device',
|
||||||
|
'available': False
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
sf_status = client.check_store_forward_available()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
**sf_status
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/store-forward/request', methods=['POST'])
|
||||||
|
def request_store_forward():
|
||||||
|
"""
|
||||||
|
Request missed messages from Store & Forward router.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"window_minutes": 60 // Minutes of history to request (default 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with request status.
|
||||||
|
"""
|
||||||
|
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 to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
window_minutes = data.get('window_minutes', 60)
|
||||||
|
|
||||||
|
if not isinstance(window_minutes, int) or window_minutes < 1 or window_minutes > 1440:
|
||||||
|
window_minutes = 60
|
||||||
|
|
||||||
|
success, error = client.request_store_forward(window_minutes=window_minutes)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'sent',
|
||||||
|
'window_minutes': window_minutes
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error or 'Failed to request S&F history'
|
||||||
|
}), 500
|
||||||
|
|||||||
+40
-4
@@ -29,6 +29,9 @@ from utils.dependencies import get_tool_path
|
|||||||
|
|
||||||
pager_bp = Blueprint('pager', __name__)
|
pager_bp = Blueprint('pager', __name__)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
pager_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||||
"""Parse multimon-ng output line."""
|
"""Parse multimon-ng output line."""
|
||||||
@@ -155,6 +158,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
|
|
||||||
@pager_bp.route('/start', methods=['POST'])
|
@pager_bp.route('/start', methods=['POST'])
|
||||||
def start_decoding() -> Response:
|
def start_decoding() -> Response:
|
||||||
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||||
@@ -178,10 +183,29 @@ def start_decoding() -> Response:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||||
|
|
||||||
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Claim local device if not using remote rtl_tcp
|
||||||
|
if not rtl_tcp_host:
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
pager_active_device = device_int
|
||||||
|
|
||||||
# Validate protocols
|
# Validate protocols
|
||||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||||
protocols = data.get('protocols', valid_protocols)
|
protocols = data.get('protocols', valid_protocols)
|
||||||
if not isinstance(protocols, list):
|
if not isinstance(protocols, list):
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
@@ -213,10 +237,6 @@ def start_decoding() -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -302,13 +322,23 @@ def start_decoding() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
# Release device on failure
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
# Kill rtl_fm process first
|
# Kill rtl_fm process first
|
||||||
@@ -337,6 +367,12 @@ def stop_decoding() -> Response:
|
|||||||
app_module.current_process.kill()
|
app_module.current_process.kill()
|
||||||
|
|
||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
return jsonify({'status': 'not_running'})
|
return jsonify({'status': 'not_running'})
|
||||||
|
|||||||
+33
-7
@@ -26,6 +26,9 @@ rtlamr_bp = Blueprint('rtlamr', __name__)
|
|||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
rtl_tcp_lock = threading.Lock()
|
rtl_tcp_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
rtlamr_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtlamr JSON output to queue."""
|
"""Stream rtlamr JSON output to queue."""
|
||||||
@@ -66,7 +69,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
|
|
||||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||||
def start_rtlamr() -> Response:
|
def start_rtlamr() -> Response:
|
||||||
global rtl_tcp_process
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
@@ -83,6 +86,18 @@ def start_rtlamr() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
rtlamr_active_device = device_int
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.rtlamr_queue.empty():
|
while not app_module.rtlamr_queue.empty():
|
||||||
try:
|
try:
|
||||||
@@ -182,27 +197,33 @@ def start_rtlamr() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# If rtlamr fails, clean up rtl_tcp
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if rtl_tcp_process:
|
if rtl_tcp_process:
|
||||||
rtl_tcp_process.terminate()
|
rtl_tcp_process.terminate()
|
||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If rtlamr fails, clean up rtl_tcp
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if rtl_tcp_process:
|
if rtl_tcp_process:
|
||||||
rtl_tcp_process.terminate()
|
rtl_tcp_process.terminate()
|
||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||||
def stop_rtlamr() -> Response:
|
def stop_rtlamr() -> Response:
|
||||||
global rtl_tcp_process
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
app_module.rtlamr_process.terminate()
|
app_module.rtlamr_process.terminate()
|
||||||
@@ -211,7 +232,7 @@ def stop_rtlamr() -> Response:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
app_module.rtlamr_process.kill()
|
app_module.rtlamr_process.kill()
|
||||||
app_module.rtlamr_process = None
|
app_module.rtlamr_process = None
|
||||||
|
|
||||||
# Also stop rtl_tcp
|
# Also stop rtl_tcp
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if rtl_tcp_process:
|
if rtl_tcp_process:
|
||||||
@@ -222,7 +243,12 @@ def stop_rtlamr() -> Response:
|
|||||||
rtl_tcp_process.kill()
|
rtl_tcp_process.kill()
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
logger.info("rtl_tcp stopped")
|
logger.info("rtl_tcp stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+178
-40
@@ -3,12 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, render_template, Response
|
import requests
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, render_template, Response
|
||||||
|
|
||||||
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
from utils.logging import satellite_logger as logger
|
from utils.logging import satellite_logger as logger
|
||||||
@@ -26,10 +31,101 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Fetch real-time ISS position from external APIs.
|
||||||
|
|
||||||
|
Returns position data dict or None if all APIs fail.
|
||||||
|
"""
|
||||||
|
iss_lat = None
|
||||||
|
iss_lon = None
|
||||||
|
iss_alt = 420 # Default altitude in km
|
||||||
|
source = None
|
||||||
|
|
||||||
|
# Try primary API: Open Notify
|
||||||
|
try:
|
||||||
|
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('message') == 'success':
|
||||||
|
iss_lat = float(data['iss_position']['latitude'])
|
||||||
|
iss_lon = float(data['iss_position']['longitude'])
|
||||||
|
source = 'open-notify'
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Open Notify API failed: {e}")
|
||||||
|
|
||||||
|
# Try fallback API: Where The ISS At
|
||||||
|
if iss_lat is None:
|
||||||
|
try:
|
||||||
|
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
iss_lat = float(data['latitude'])
|
||||||
|
iss_lon = float(data['longitude'])
|
||||||
|
iss_alt = float(data.get('altitude', 420))
|
||||||
|
source = 'wheretheiss'
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Where The ISS At API failed: {e}")
|
||||||
|
|
||||||
|
if iss_lat is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'satellite': 'ISS',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': iss_alt,
|
||||||
|
'source': source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
# Earth radius in km
|
||||||
|
earth_radius = 6371
|
||||||
|
|
||||||
|
# Convert to radians
|
||||||
|
lat1 = math.radians(observer_lat)
|
||||||
|
lat2 = math.radians(iss_lat)
|
||||||
|
lon1 = math.radians(observer_lon)
|
||||||
|
lon2 = math.radians(iss_lon)
|
||||||
|
|
||||||
|
# Haversine for ground distance
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||||
|
c = 2 * math.asin(math.sqrt(a))
|
||||||
|
ground_distance = earth_radius * c
|
||||||
|
|
||||||
|
# Calculate slant range
|
||||||
|
slant_range = math.sqrt(ground_distance**2 + iss_alt**2)
|
||||||
|
|
||||||
|
# Calculate elevation angle (simplified)
|
||||||
|
if ground_distance > 0:
|
||||||
|
elevation = math.degrees(math.atan2(iss_alt - (ground_distance**2 / (2 * earth_radius)), ground_distance))
|
||||||
|
else:
|
||||||
|
elevation = 90.0
|
||||||
|
|
||||||
|
# Calculate azimuth
|
||||||
|
y = math.sin(dlon) * math.cos(lat2)
|
||||||
|
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
||||||
|
azimuth = math.degrees(math.atan2(y, x))
|
||||||
|
azimuth = (azimuth + 360) % 360
|
||||||
|
|
||||||
|
result['elevation'] = round(elevation, 1)
|
||||||
|
result['azimuth'] = round(azimuth, 1)
|
||||||
|
result['distance'] = round(slant_range, 1)
|
||||||
|
result['visible'] = elevation > 0
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/dashboard')
|
@satellite_bp.route('/dashboard')
|
||||||
def satellite_dashboard():
|
def satellite_dashboard():
|
||||||
"""Popout satellite tracking dashboard."""
|
"""Popout satellite tracking dashboard."""
|
||||||
return render_template('satellite_dashboard.html')
|
return render_template(
|
||||||
|
'satellite_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/predict', methods=['POST'])
|
@satellite_bp.route('/predict', methods=['POST'])
|
||||||
@@ -239,6 +335,35 @@ def get_satellite_position():
|
|||||||
positions = []
|
positions = []
|
||||||
|
|
||||||
for sat_name in satellites:
|
for sat_name in satellites:
|
||||||
|
# Special handling for ISS - use real-time API for accurate position
|
||||||
|
if sat_name == 'ISS':
|
||||||
|
iss_data = _fetch_iss_realtime(lat, lon)
|
||||||
|
if iss_data:
|
||||||
|
# Add orbit track if requested (using TLE for track prediction)
|
||||||
|
if include_track and 'ISS' in _tle_cache:
|
||||||
|
try:
|
||||||
|
tle_data = _tle_cache['ISS']
|
||||||
|
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||||
|
orbit_track = []
|
||||||
|
for minutes_offset in range(-45, 46, 1):
|
||||||
|
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
|
||||||
|
try:
|
||||||
|
geo = satellite.at(t_point)
|
||||||
|
sp = wgs84.subpoint(geo)
|
||||||
|
orbit_track.append({
|
||||||
|
'lat': float(sp.latitude.degrees),
|
||||||
|
'lon': float(sp.longitude.degrees),
|
||||||
|
'past': minutes_offset < 0
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
iss_data['track'] = orbit_track
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
positions.append(iss_data)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Other satellites - use TLE data
|
||||||
if sat_name not in _tle_cache:
|
if sat_name not in _tle_cache:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -292,56 +417,69 @@ def get_satellite_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/update-tle', methods=['POST'])
|
def refresh_tle_data() -> list:
|
||||||
def update_tle():
|
"""
|
||||||
"""Update TLE data from CelesTrak."""
|
Refresh TLE data from CelesTrak.
|
||||||
|
|
||||||
|
This can be called at startup or periodically to keep TLE data fresh.
|
||||||
|
Returns list of satellite names that were updated.
|
||||||
|
"""
|
||||||
global _tle_cache
|
global _tle_cache
|
||||||
|
|
||||||
try:
|
name_mappings = {
|
||||||
name_mappings = {
|
'ISS (ZARYA)': 'ISS',
|
||||||
'ISS (ZARYA)': 'ISS',
|
'NOAA 15': 'NOAA-15',
|
||||||
'NOAA 15': 'NOAA-15',
|
'NOAA 18': 'NOAA-18',
|
||||||
'NOAA 18': 'NOAA-18',
|
'NOAA 19': 'NOAA-19',
|
||||||
'NOAA 19': 'NOAA-19',
|
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
||||||
'METEOR-M 2': 'METEOR-M2',
|
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
||||||
'METEOR-M2 3': 'METEOR-M2-3'
|
'METEOR-M 2': 'METEOR-M2',
|
||||||
}
|
'METEOR-M2 3': 'METEOR-M2-3'
|
||||||
|
}
|
||||||
|
|
||||||
updated = []
|
updated = []
|
||||||
|
|
||||||
for group in ['stations', 'weather']:
|
for group in ['stations', 'weather', 'noaa']:
|
||||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=10) as response:
|
with urllib.request.urlopen(url, timeout=15) as response:
|
||||||
content = response.read().decode('utf-8')
|
content = response.read().decode('utf-8')
|
||||||
lines = content.strip().split('\n')
|
lines = content.strip().split('\n')
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while i + 2 < len(lines):
|
while i + 2 < len(lines):
|
||||||
name = lines[i].strip()
|
name = lines[i].strip()
|
||||||
line1 = lines[i + 1].strip()
|
line1 = lines[i + 1].strip()
|
||||||
line2 = lines[i + 2].strip()
|
line2 = lines[i + 2].strip()
|
||||||
|
|
||||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
internal_name = name_mappings.get(name, name)
|
internal_name = name_mappings.get(name, name)
|
||||||
|
|
||||||
if internal_name in _tle_cache:
|
if internal_name in _tle_cache:
|
||||||
_tle_cache[internal_name] = (name, line1, line2)
|
_tle_cache[internal_name] = (name, line1, line2)
|
||||||
|
if internal_name not in updated:
|
||||||
updated.append(internal_name)
|
updated.append(internal_name)
|
||||||
|
|
||||||
i += 3
|
i += 3
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching {group}: {e}")
|
logger.warning(f"Error fetching TLE group {group}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/update-tle', methods=['POST'])
|
||||||
|
def update_tle():
|
||||||
|
"""Update TLE data from CelesTrak (API endpoint)."""
|
||||||
|
try:
|
||||||
|
updated = refresh_tle_data()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'updated': updated
|
'updated': updated
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|||||||
+37
-4
@@ -24,6 +24,9 @@ from utils.sdr import SDRFactory, SDRType
|
|||||||
|
|
||||||
sensor_bp = Blueprint('sensor', __name__)
|
sensor_bp = Blueprint('sensor', __name__)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
sensor_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
@@ -64,6 +67,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||||
@@ -79,6 +84,22 @@ def start_sensor() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Claim local device if not using remote rtl_tcp
|
||||||
|
if not rtl_tcp_host:
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
sensor_active_device = device_int
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.sensor_queue.empty():
|
while not app_module.sensor_queue.empty():
|
||||||
try:
|
try:
|
||||||
@@ -93,10 +114,6 @@ def start_sensor() -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -155,13 +172,23 @@ def start_sensor() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
# Release device on failure
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
def stop_sensor() -> Response:
|
def stop_sensor() -> Response:
|
||||||
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
app_module.sensor_process.terminate()
|
app_module.sensor_process.terminate()
|
||||||
@@ -170,6 +197,12 @@ def stop_sensor() -> Response:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
app_module.sensor_process.kill()
|
app_module.sensor_process.kill()
|
||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
return jsonify({'status': 'not_running'})
|
return jsonify({'status': 'not_running'})
|
||||||
|
|||||||
+626
@@ -0,0 +1,626 @@
|
|||||||
|
"""ISS SSTV (Slow-Scan Television) decoder routes.
|
||||||
|
|
||||||
|
Provides endpoints for decoding SSTV images from the International Space Station.
|
||||||
|
ISS SSTV events occur during special commemorations and typically transmit on 145.800 MHz FM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.sstv import (
|
||||||
|
get_sstv_decoder,
|
||||||
|
is_sstv_available,
|
||||||
|
ISS_SSTV_FREQ,
|
||||||
|
DecodeProgress,
|
||||||
|
DopplerInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.sstv')
|
||||||
|
|
||||||
|
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||||
|
|
||||||
|
# Queue for SSE progress streaming
|
||||||
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_callback(progress: DecodeProgress) -> None:
|
||||||
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_sstv_queue.get_nowait()
|
||||||
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
"""
|
||||||
|
Get SSTV decoder status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with decoder availability and current status.
|
||||||
|
"""
|
||||||
|
available = is_sstv_available()
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'available': available,
|
||||||
|
'decoder': decoder.decoder_available,
|
||||||
|
'running': decoder.is_running,
|
||||||
|
'iss_frequency': ISS_SSTV_FREQ,
|
||||||
|
'image_count': len(decoder.get_images()),
|
||||||
|
'doppler_enabled': decoder.doppler_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include Doppler info if available
|
||||||
|
doppler_info = decoder.last_doppler_info
|
||||||
|
if doppler_info:
|
||||||
|
result['doppler'] = doppler_info.to_dict()
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/start', methods=['POST'])
|
||||||
|
def start_decoder():
|
||||||
|
"""
|
||||||
|
Start SSTV decoder.
|
||||||
|
|
||||||
|
JSON body (optional):
|
||||||
|
{
|
||||||
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||||
|
"device": 0, // RTL-SDR device index
|
||||||
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||||
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||||
|
}
|
||||||
|
|
||||||
|
If latitude and longitude are provided, real-time Doppler shift compensation
|
||||||
|
will be enabled, which improves reception by tracking the ISS frequency shift
|
||||||
|
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
if not is_sstv_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'frequency': ISS_SSTV_FREQ,
|
||||||
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not _sstv_queue.empty():
|
||||||
|
try:
|
||||||
|
_sstv_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get parameters
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||||
|
device_index = data.get('device', 0)
|
||||||
|
latitude = data.get('latitude')
|
||||||
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
|
# Validate frequency
|
||||||
|
try:
|
||||||
|
frequency = float(frequency)
|
||||||
|
if not (100 <= frequency <= 500): # VHF range
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Frequency must be between 100-500 MHz'
|
||||||
|
}), 400
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid frequency'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate location if provided
|
||||||
|
if latitude is not None and longitude is not None:
|
||||||
|
try:
|
||||||
|
latitude = float(latitude)
|
||||||
|
longitude = float(longitude)
|
||||||
|
if not (-90 <= latitude <= 90):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Latitude must be between -90 and 90'
|
||||||
|
}), 400
|
||||||
|
if not (-180 <= longitude <= 180):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Longitude must be between -180 and 180'
|
||||||
|
}), 400
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid latitude or longitude'
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
latitude = None
|
||||||
|
longitude = None
|
||||||
|
|
||||||
|
# Set callback and start
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
success = decoder.start(
|
||||||
|
frequency=frequency,
|
||||||
|
device_index=device_index,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
result = {
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'device': device_index,
|
||||||
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include initial Doppler info if available
|
||||||
|
if decoder.doppler_enabled and decoder.last_doppler_info:
|
||||||
|
result['doppler'] = decoder.last_doppler_info.to_dict()
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start decoder'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_decoder():
|
||||||
|
"""
|
||||||
|
Stop SSTV decoder.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
decoder.stop()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/doppler')
|
||||||
|
def get_doppler():
|
||||||
|
"""
|
||||||
|
Get current Doppler shift information.
|
||||||
|
|
||||||
|
Returns real-time Doppler shift data if tracking is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with Doppler shift information.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
if not decoder.doppler_enabled:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'disabled',
|
||||||
|
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
|
||||||
|
})
|
||||||
|
|
||||||
|
doppler_info = decoder.last_doppler_info
|
||||||
|
if not doppler_info:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': 'Doppler data not yet available'
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'doppler': doppler_info.to_dict(),
|
||||||
|
'nominal_frequency_mhz': ISS_SSTV_FREQ,
|
||||||
|
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/images')
|
||||||
|
def list_images():
|
||||||
|
"""
|
||||||
|
Get list of decoded SSTV images.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
limit: Maximum number of images to return (default: all)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of decoded images.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
images = decoder.get_images()
|
||||||
|
|
||||||
|
limit = request.args.get('limit', type=int)
|
||||||
|
if limit and limit > 0:
|
||||||
|
images = images[-limit:]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'images': [img.to_dict() for img in images],
|
||||||
|
'count': len(images)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/images/<filename>')
|
||||||
|
def get_image(filename: str):
|
||||||
|
"""
|
||||||
|
Get a decoded SSTV image file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image file or 404.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not filename.endswith('.png'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
|
# Find image in decoder's output directory
|
||||||
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/stream')
|
||||||
|
def stream_progress():
|
||||||
|
"""
|
||||||
|
SSE stream of SSTV decode progress.
|
||||||
|
|
||||||
|
Provides real-time Server-Sent Events stream of decode progress.
|
||||||
|
|
||||||
|
Event format:
|
||||||
|
data: {"type": "sstv_progress", "status": "decoding", "mode": "PD120", ...}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE stream (text/event-stream)
|
||||||
|
"""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
progress = _sstv_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(progress)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/iss-schedule')
|
||||||
|
def iss_schedule():
|
||||||
|
"""
|
||||||
|
Get ISS pass schedule for SSTV reception.
|
||||||
|
|
||||||
|
Calculates ISS passes directly using skyfield.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
latitude: Observer latitude (required)
|
||||||
|
longitude: Observer longitude (required)
|
||||||
|
hours: Hours to look ahead (default: 48)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with ISS pass schedule.
|
||||||
|
"""
|
||||||
|
lat = request.args.get('latitude', type=float)
|
||||||
|
lon = request.args.get('longitude', type=float)
|
||||||
|
hours = request.args.get('hours', 48, type=int)
|
||||||
|
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'latitude and longitude parameters required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from skyfield.api import load, wgs84, EarthSatellite
|
||||||
|
from skyfield.almanac import find_discrete
|
||||||
|
from datetime import timedelta
|
||||||
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
|
# Get ISS TLE
|
||||||
|
iss_tle = TLE_SATELLITES.get('ISS')
|
||||||
|
if not iss_tle:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'ISS TLE data not available'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
ts = load.timescale()
|
||||||
|
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||||
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
|
||||||
|
t0 = ts.now()
|
||||||
|
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||||
|
|
||||||
|
def above_horizon(t):
|
||||||
|
diff = satellite - observer
|
||||||
|
topocentric = diff.at(t)
|
||||||
|
alt, _, _ = topocentric.altaz()
|
||||||
|
return alt.degrees > 0
|
||||||
|
|
||||||
|
above_horizon.step_days = 1/720
|
||||||
|
|
||||||
|
times, events = find_discrete(t0, t1, above_horizon)
|
||||||
|
|
||||||
|
passes = []
|
||||||
|
i = 0
|
||||||
|
while i < len(times):
|
||||||
|
if i < len(events) and events[i]: # Rising
|
||||||
|
rise_time = times[i]
|
||||||
|
set_time = None
|
||||||
|
|
||||||
|
for j in range(i + 1, len(times)):
|
||||||
|
if not events[j]: # Setting
|
||||||
|
set_time = times[j]
|
||||||
|
i = j
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if set_time is None:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate max elevation
|
||||||
|
max_el = 0
|
||||||
|
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
|
||||||
|
duration_minutes = int(duration_seconds / 60)
|
||||||
|
|
||||||
|
for k in range(30):
|
||||||
|
frac = k / 29
|
||||||
|
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||||
|
diff = satellite - observer
|
||||||
|
topocentric = diff.at(t_point)
|
||||||
|
alt, _, _ = topocentric.altaz()
|
||||||
|
if alt.degrees > max_el:
|
||||||
|
max_el = alt.degrees
|
||||||
|
|
||||||
|
if max_el >= 10: # Min elevation filter
|
||||||
|
passes.append({
|
||||||
|
'satellite': 'ISS',
|
||||||
|
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||||
|
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||||
|
'maxEl': round(max_el, 1),
|
||||||
|
'duration': duration_minutes,
|
||||||
|
'color': '#00ffff'
|
||||||
|
})
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'passes': passes,
|
||||||
|
'count': len(passes),
|
||||||
|
'sstv_frequency': ISS_SSTV_FREQ,
|
||||||
|
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
||||||
|
})
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'skyfield library not installed'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting ISS schedule: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/iss-position')
|
||||||
|
def iss_position():
|
||||||
|
"""
|
||||||
|
Get current ISS position from real-time API.
|
||||||
|
|
||||||
|
Uses the Open Notify API for accurate real-time position,
|
||||||
|
with fallback to "Where The ISS At" API.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
latitude: Observer latitude (optional, for elevation calc)
|
||||||
|
longitude: Observer longitude (optional, for elevation calc)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with ISS current position.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
observer_lat = request.args.get('latitude', type=float)
|
||||||
|
observer_lon = request.args.get('longitude', type=float)
|
||||||
|
|
||||||
|
# Try primary API: Open Notify
|
||||||
|
try:
|
||||||
|
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('message') == 'success':
|
||||||
|
iss_lat = float(data['iss_position']['latitude'])
|
||||||
|
iss_lon = float(data['iss_position']['longitude'])
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'status': 'ok',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': 420, # Approximate ISS altitude in km
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'source': 'open-notify'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Open Notify API failed: {e}")
|
||||||
|
|
||||||
|
# Try fallback API: Where The ISS At
|
||||||
|
try:
|
||||||
|
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
iss_lat = float(data['latitude'])
|
||||||
|
iss_lon = float(data['longitude'])
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'status': 'ok',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': float(data.get('altitude', 420)),
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'source': 'wheretheiss'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Where The ISS At API failed: {e}")
|
||||||
|
|
||||||
|
# Both APIs failed
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
||||||
|
"""Calculate elevation, azimuth, and distance from observer to ISS."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
# ISS altitude in km
|
||||||
|
iss_alt_km = 420
|
||||||
|
|
||||||
|
# Earth radius in km
|
||||||
|
earth_radius = 6371
|
||||||
|
|
||||||
|
# Convert to radians
|
||||||
|
lat1 = math.radians(obs_lat)
|
||||||
|
lat2 = math.radians(iss_lat)
|
||||||
|
lon1 = math.radians(obs_lon)
|
||||||
|
lon2 = math.radians(iss_lon)
|
||||||
|
|
||||||
|
# Haversine for ground distance
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||||
|
c = 2 * math.asin(math.sqrt(a))
|
||||||
|
ground_distance = earth_radius * c
|
||||||
|
|
||||||
|
# Calculate elevation angle (simplified)
|
||||||
|
# Using spherical geometry approximation
|
||||||
|
iss_height = iss_alt_km
|
||||||
|
slant_range = math.sqrt(ground_distance**2 + iss_height**2)
|
||||||
|
|
||||||
|
if ground_distance > 0:
|
||||||
|
elevation = math.degrees(math.atan2(iss_height - (ground_distance**2 / (2 * earth_radius)), ground_distance))
|
||||||
|
else:
|
||||||
|
elevation = 90.0
|
||||||
|
|
||||||
|
# Calculate azimuth
|
||||||
|
y = math.sin(dlon) * math.cos(lat2)
|
||||||
|
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
||||||
|
azimuth = math.degrees(math.atan2(y, x))
|
||||||
|
azimuth = (azimuth + 360) % 360
|
||||||
|
|
||||||
|
return {
|
||||||
|
'elevation': round(elevation, 1),
|
||||||
|
'azimuth': round(azimuth, 1),
|
||||||
|
'distance': round(slant_range, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/decode-file', methods=['POST'])
|
||||||
|
def decode_file():
|
||||||
|
"""
|
||||||
|
Decode SSTV from an uploaded audio file.
|
||||||
|
|
||||||
|
Expects multipart/form-data with 'audio' file field.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with decoded images.
|
||||||
|
"""
|
||||||
|
if 'audio' not in request.files:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No audio file provided'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
audio_file = request.files['audio']
|
||||||
|
|
||||||
|
if not audio_file.filename:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No file selected'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Save to temp file
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||||
|
audio_file.save(tmp.name)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
images = decoder.decode_file(tmp_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'images': [img.to_dict() for img in images],
|
||||||
|
'count': len(images)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error decoding file: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
try:
|
||||||
|
Path(tmp_path).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Updater routes - GitHub update checking and application updates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.updater import (
|
||||||
|
check_for_updates,
|
||||||
|
dismiss_update,
|
||||||
|
get_update_status,
|
||||||
|
perform_update,
|
||||||
|
restart_application,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.routes.updater')
|
||||||
|
|
||||||
|
updater_bp = Blueprint('updater', __name__, url_prefix='/updater')
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/check', methods=['GET'])
|
||||||
|
def check_updates() -> Response:
|
||||||
|
"""
|
||||||
|
Check for updates from GitHub.
|
||||||
|
|
||||||
|
Uses caching to avoid excessive API calls. Will only hit GitHub
|
||||||
|
if the cache is stale (default: 6 hours).
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
force: Set to 'true' to bypass cache and check GitHub directly
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with update status information
|
||||||
|
"""
|
||||||
|
force = request.args.get('force', '').lower() == 'true'
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = check_for_updates(force=force)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking for updates: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/status', methods=['GET'])
|
||||||
|
def update_status() -> Response:
|
||||||
|
"""
|
||||||
|
Get current update status from cache.
|
||||||
|
|
||||||
|
This endpoint does NOT trigger a GitHub check - it only returns
|
||||||
|
cached data. Use /check to trigger a fresh check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with cached update status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = get_update_status()
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting update status: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/update', methods=['POST'])
|
||||||
|
def do_update() -> Response:
|
||||||
|
"""
|
||||||
|
Perform a git pull to update the application.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
stash_changes: If true, stash local changes before pulling
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with update result information
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
stash_changes = data.get('stash_changes', False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = perform_update(stash_changes=stash_changes)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
return jsonify(result)
|
||||||
|
else:
|
||||||
|
# Return appropriate status code based on error type
|
||||||
|
error = result.get('error', '')
|
||||||
|
if error == 'local_changes':
|
||||||
|
return jsonify(result), 409 # Conflict
|
||||||
|
elif error == 'merge_conflict':
|
||||||
|
return jsonify(result), 409
|
||||||
|
elif result.get('manual_update'):
|
||||||
|
return jsonify(result), 400
|
||||||
|
else:
|
||||||
|
return jsonify(result), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error performing update: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/dismiss', methods=['POST'])
|
||||||
|
def dismiss_notification() -> Response:
|
||||||
|
"""
|
||||||
|
Dismiss update notification for a specific version.
|
||||||
|
|
||||||
|
The notification will not be shown again until a newer version
|
||||||
|
is available.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
version: The version to dismiss notifications for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with success status
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
version = data.get('version')
|
||||||
|
|
||||||
|
if not version:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Version is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = dismiss_update(version)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error dismissing update: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route('/restart', methods=['POST'])
|
||||||
|
def restart_app() -> Response:
|
||||||
|
"""
|
||||||
|
Restart the application.
|
||||||
|
|
||||||
|
This endpoint triggers a graceful restart of the application:
|
||||||
|
1. Stops all running decoder processes
|
||||||
|
2. Cleans up global state
|
||||||
|
3. Replaces the current process with a fresh instance
|
||||||
|
|
||||||
|
The response may not be received by the client since the process
|
||||||
|
is replaced immediately. Clients should poll /health until the
|
||||||
|
server responds again.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with restart status (may not be delivered)
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
logger.info("Restart requested via API")
|
||||||
|
|
||||||
|
# Send response before restarting
|
||||||
|
# Use a short delay to allow the response to be sent
|
||||||
|
def delayed_restart():
|
||||||
|
import time
|
||||||
|
time.sleep(0.5) # Allow response to be sent
|
||||||
|
restart_application()
|
||||||
|
|
||||||
|
# Start restart in a background thread so we can return a response
|
||||||
|
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
|
||||||
|
restart_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Application is restarting. Please wait...',
|
||||||
|
'action': 'restart'
|
||||||
|
})
|
||||||
+140
@@ -1413,3 +1413,143 @@ def v2_clear_data():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error clearing data")
|
logger.exception("Error clearing data")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# V2 Deauth Detection Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/status')
|
||||||
|
def v2_deauth_status():
|
||||||
|
"""
|
||||||
|
Get deauth detection status and recent alerts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- is_running: Whether deauth detector is active
|
||||||
|
- interface: Monitor interface being used
|
||||||
|
- stats: Detection statistics
|
||||||
|
- recent_alerts: Recent deauth alerts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
detector = scanner.deauth_detector
|
||||||
|
|
||||||
|
if detector:
|
||||||
|
stats = detector.stats
|
||||||
|
alerts = detector.get_alerts(limit=50)
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
'is_running': False,
|
||||||
|
'interface': None,
|
||||||
|
'packets_captured': 0,
|
||||||
|
'alerts_generated': 0,
|
||||||
|
}
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'is_running': stats.get('is_running', False),
|
||||||
|
'interface': stats.get('interface'),
|
||||||
|
'started_at': stats.get('started_at'),
|
||||||
|
'stats': {
|
||||||
|
'packets_captured': stats.get('packets_captured', 0),
|
||||||
|
'alerts_generated': stats.get('alerts_generated', 0),
|
||||||
|
'active_trackers': stats.get('active_trackers', 0),
|
||||||
|
},
|
||||||
|
'recent_alerts': alerts,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting deauth status")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/stream')
|
||||||
|
def v2_deauth_stream():
|
||||||
|
"""
|
||||||
|
SSE stream for real-time deauth alerts.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- deauth_alert: A deauth attack was detected
|
||||||
|
- deauth_detector_started: Detector started
|
||||||
|
- deauth_detector_stopped: Detector stopped
|
||||||
|
- deauth_error: An error occurred
|
||||||
|
- keepalive: Periodic keepalive
|
||||||
|
"""
|
||||||
|
def generate():
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Try to get from the dedicated deauth queue
|
||||||
|
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/alerts')
|
||||||
|
def v2_deauth_alerts():
|
||||||
|
"""
|
||||||
|
Get historical deauth alerts.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- limit: Maximum number of alerts to return (default 100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
alerts = scanner.get_deauth_alerts(limit=limit)
|
||||||
|
|
||||||
|
# Also include alerts from DataStore that might have been persisted
|
||||||
|
try:
|
||||||
|
stored_alerts = list(app_module.deauth_alerts.values())
|
||||||
|
# Merge and deduplicate by ID
|
||||||
|
alert_ids = {a.get('id') for a in alerts}
|
||||||
|
for alert in stored_alerts:
|
||||||
|
if alert.get('id') not in alert_ids:
|
||||||
|
alerts.append(alert)
|
||||||
|
# Sort by timestamp descending
|
||||||
|
alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True)
|
||||||
|
alerts = alerts[:limit]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'alerts': alerts,
|
||||||
|
'count': len(alerts),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting deauth alerts")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
|
||||||
|
def v2_deauth_clear():
|
||||||
|
"""Clear deauth alert history."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.clear_deauth_alerts()
|
||||||
|
|
||||||
|
# Clear the queue
|
||||||
|
while not app_module.deauth_detector_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.deauth_detector_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error clearing deauth alerts")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ check_tools() {
|
|||||||
check_required "dump1090" "ADS-B decoder" dump1090
|
check_required "dump1090" "ADS-B decoder" dump1090
|
||||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
|
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
|
||||||
|
|
||||||
echo
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
@@ -303,6 +304,10 @@ install_python_deps() {
|
|||||||
else
|
else
|
||||||
ok "Python dependencies installed"
|
ok "Python dependencies installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility)
|
||||||
|
# System apt packages may have older Flask 2.x which is incompatible
|
||||||
|
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +386,49 @@ install_rtlamr_from_source() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_slowrx_from_source_macos() {
|
||||||
|
info "slowrx not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
|
# Ensure build dependencies are installed
|
||||||
|
brew_install cmake
|
||||||
|
brew_install fftw
|
||||||
|
brew_install libsndfile
|
||||||
|
brew_install gtk+3
|
||||||
|
brew_install pkg-config
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning slowrx..."
|
||||||
|
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone slowrx"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/slowrx"
|
||||||
|
info "Compiling slowrx..."
|
||||||
|
mkdir -p build && cd build
|
||||||
|
local cmake_log make_log
|
||||||
|
cmake_log=$(cmake .. 2>&1) || {
|
||||||
|
warn "cmake failed for slowrx:"
|
||||||
|
echo "$cmake_log" | tail -20
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
make_log=$(make 2>&1) || {
|
||||||
|
warn "make failed for slowrx:"
|
||||||
|
echo "$make_log" | tail -20
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install to /usr/local/bin
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
install -m 0755 slowrx /usr/local/bin/slowrx
|
||||||
|
else
|
||||||
|
sudo install -m 0755 slowrx /usr/local/bin/slowrx
|
||||||
|
fi
|
||||||
|
ok "slowrx installed successfully from source"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_multimon_ng_from_source_macos() {
|
install_multimon_ng_from_source_macos() {
|
||||||
info "multimon-ng not available via Homebrew. Building from source..."
|
info "multimon-ng not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
@@ -413,7 +461,7 @@ install_multimon_ng_from_source_macos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=15
|
TOTAL_STEPS=16
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -433,6 +481,13 @@ install_macos_packages() {
|
|||||||
progress "Installing direwolf (APRS decoder)"
|
progress "Installing direwolf (APRS decoder)"
|
||||||
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||||
|
|
||||||
|
progress "Installing slowrx (SSTV decoder)"
|
||||||
|
if ! cmd_exists slowrx; then
|
||||||
|
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
|
||||||
|
else
|
||||||
|
ok "slowrx already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing ffmpeg"
|
progress "Installing ffmpeg"
|
||||||
brew_install ffmpeg
|
brew_install ffmpeg
|
||||||
|
|
||||||
@@ -549,6 +604,8 @@ install_dump1090_from_source_debian() {
|
|||||||
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
|
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
|
# Remove -Werror to prevent build failures on newer GCC versions
|
||||||
|
sed -i 's/-Werror//g' Makefile 2>/dev/null || sed -i '' 's/-Werror//g' Makefile
|
||||||
info "Compiling FlightAware dump1090..."
|
info "Compiling FlightAware dump1090..."
|
||||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
@@ -556,17 +613,17 @@ install_dump1090_from_source_debian() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
warn "FlightAware build failed. Falling back to antirez/dump1090..."
|
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
||||||
rm -rf "$tmp_dir/dump1090"
|
rm -rf "$tmp_dir/dump1090"
|
||||||
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||||
|| { fail "Failed to clone antirez dump1090"; exit 1; }
|
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
info "Compiling antirez dump1090..."
|
info "Compiling readsb..."
|
||||||
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
|
make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; }
|
||||||
|
|
||||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
||||||
ok "dump1090 installed successfully (antirez)."
|
ok "dump1090 installed successfully (via readsb)."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +683,37 @@ install_aiscatcher_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_slowrx_from_source_debian() {
|
||||||
|
info "slowrx not available via APT. Building from source..."
|
||||||
|
|
||||||
|
# slowrx uses a simple Makefile, not CMake
|
||||||
|
apt_install build-essential git pkg-config \
|
||||||
|
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning slowrx..."
|
||||||
|
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone slowrx"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/slowrx"
|
||||||
|
|
||||||
|
info "Compiling slowrx..."
|
||||||
|
local make_log
|
||||||
|
make_log=$(make 2>&1) || {
|
||||||
|
warn "make failed for slowrx:"
|
||||||
|
echo "$make_log" | tail -20
|
||||||
|
warn "ISS SSTV decoding will not be available."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
|
||||||
|
ok "slowrx installed successfully."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_ubertooth_from_source_debian() {
|
install_ubertooth_from_source_debian() {
|
||||||
info "Building Ubertooth from source..."
|
info "Building Ubertooth from source..."
|
||||||
|
|
||||||
@@ -761,7 +849,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=20
|
TOTAL_STEPS=21
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -805,19 +893,9 @@ install_debian_packages() {
|
|||||||
|
|
||||||
progress "RTL-SDR Blog drivers"
|
progress "RTL-SDR Blog drivers"
|
||||||
if cmd_exists rtl_test; then
|
if cmd_exists rtl_test; then
|
||||||
info "RTL-SDR tools already installed."
|
ok "RTL-SDR drivers already installed"
|
||||||
if $IS_DRAGONOS; then
|
|
||||||
info "Skipping RTL-SDR Blog driver installation (DragonOS has working drivers)."
|
|
||||||
else
|
|
||||||
echo "RTL-SDR Blog drivers provide improved support for V4 dongles."
|
|
||||||
echo "Installing these will REPLACE your current RTL-SDR drivers."
|
|
||||||
if ask_yes_no "Install RTL-SDR Blog drivers?"; then
|
|
||||||
install_rtlsdr_blog_drivers_debian
|
|
||||||
else
|
|
||||||
ok "Keeping existing RTL-SDR drivers."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
|
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
||||||
install_rtlsdr_blog_drivers_debian
|
install_rtlsdr_blog_drivers_debian
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -827,6 +905,9 @@ install_debian_packages() {
|
|||||||
progress "Installing direwolf (APRS decoder)"
|
progress "Installing direwolf (APRS decoder)"
|
||||||
apt_install direwolf || true
|
apt_install direwolf || true
|
||||||
|
|
||||||
|
progress "Installing slowrx (SSTV decoder)"
|
||||||
|
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian
|
||||||
|
|
||||||
progress "Installing ffmpeg"
|
progress "Installing ffmpeg"
|
||||||
apt_install ffmpeg
|
apt_install ffmpeg
|
||||||
|
|
||||||
@@ -916,11 +997,12 @@ install_debian_packages() {
|
|||||||
setup_udev_rules_debian
|
setup_udev_rules_debian
|
||||||
|
|
||||||
progress "Kernel driver configuration"
|
progress "Kernel driver configuration"
|
||||||
echo
|
|
||||||
if $IS_DRAGONOS; then
|
if $IS_DRAGONOS; then
|
||||||
info "DragonOS already has RTL-SDR drivers configured correctly."
|
info "DragonOS already has RTL-SDR drivers configured correctly."
|
||||||
info "Skipping kernel driver blacklist (not needed)."
|
elif [[ -f /etc/modprobe.d/blacklist-rtlsdr.conf ]]; then
|
||||||
|
ok "DVB kernel drivers already blacklisted"
|
||||||
else
|
else
|
||||||
|
echo
|
||||||
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
|
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
|
||||||
echo "Blacklisting them allows rtl_sdr tools to access the device."
|
echo "Blacklisting them allows rtl_sdr tools to access the device."
|
||||||
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
|
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
|
||||||
@@ -1010,3 +1092,7 @@ main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|
||||||
|
# Clear traps before exiting to prevent spurious errors during cleanup
|
||||||
|
trap - ERR EXIT
|
||||||
|
exit 0
|
||||||
|
|||||||
+134
-37
@@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #151a23;
|
--bg-card: #151a23;
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -71,7 +73,7 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header - Mobile first */
|
/* Header */
|
||||||
.header {
|
.header {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -81,20 +83,19 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -126,14 +127,52 @@ body {
|
|||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .agent-select-sm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .agent-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .agent-status-dot.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
box-shadow: 0 0 6px var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .show-all-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -172,15 +211,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid - Mobile first */
|
/* Main dashboard grid - Mobile first */
|
||||||
/* Header ~55px + Stats strip ~36px = ~91px, using 95px for safety */
|
/* 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 - 95px);
|
height: calc(100dvh - 160px);
|
||||||
height: calc(100vh - 95px); /* Fallback */
|
height: calc(100vh - 160px); /* Fallback */
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +255,7 @@ body {
|
|||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.acars-sidebar {
|
.acars-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: calc(100dvh - 95px);
|
max-height: calc(100dvh - 160px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +663,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-value {
|
.telemetry-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -680,7 +719,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-icao {
|
.aircraft-icao {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
@@ -700,7 +739,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-detail-value {
|
.aircraft-detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -790,7 +829,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,7 +840,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,6 +853,24 @@ body {
|
|||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bias-T toggle styling */
|
||||||
|
.bias-t-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: linear-gradient(90deg, rgba(255, 100, 0, 0.15), rgba(255, 100, 0, 0.05));
|
||||||
|
border: 1px solid var(--accent-orange, #ff6400);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent-orange, #ff6400);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bias-t-label input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent-orange, #ff6400);
|
||||||
|
}
|
||||||
|
|
||||||
.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);
|
||||||
@@ -861,7 +918,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -893,7 +950,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -903,10 +960,7 @@ body {
|
|||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-tile-pane,
|
/* Using actual dark tiles now - no filter needed */
|
||||||
.leaflet-container .leaflet-tile-pane {
|
|
||||||
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-control-zoom a {
|
.leaflet-control-zoom a {
|
||||||
background: var(--bg-panel) !important;
|
background: var(--bg-panel) !important;
|
||||||
@@ -1008,7 +1062,7 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -1042,7 +1096,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.airband-status {
|
.airband-status {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1146,6 +1200,55 @@ body {
|
|||||||
50% { opacity: 0.5; transform: scale(0.8); }
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRACKED AIRCRAFT PULSATING RING
|
||||||
|
============================================ */
|
||||||
|
.aircraft-marker.selected {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-ring {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tracking-pulse 1.5s ease-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-ring-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tracking-pulse 1.5s ease-out infinite 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tracking-pulse {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0.8);
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(74, 158, 255, 1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.8);
|
||||||
|
opacity: 0;
|
||||||
|
border-color: rgba(74, 158, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============== MOBILE/TABLET FIXES ============== */
|
/* ============== MOBILE/TABLET FIXES ============== */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
/* Dashboard - allow scrolling */
|
/* Dashboard - allow scrolling */
|
||||||
@@ -1153,7 +1256,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 - 95px);
|
min-height: calc(100dvh - 160px);
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1222,12 +1325,6 @@ body {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status bar - compact on mobile */
|
|
||||||
.status-bar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Strip time smaller on mobile */
|
/* Strip time smaller on mobile */
|
||||||
.strip-time {
|
.strip-time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -1343,7 +1440,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-value {
|
.strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1481,7 +1578,7 @@ body {
|
|||||||
|
|
||||||
.report-grid span:nth-child(even) {
|
.report-grid span:nth-child(even) {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-highlights {
|
.report-highlights {
|
||||||
@@ -1720,7 +1817,7 @@ body {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1874,7 +1971,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.squawk-code {
|
.squawk-code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #141a24;
|
--bg-card: #141a24;
|
||||||
@@ -20,14 +22,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-bg {
|
.radar-bg {
|
||||||
@@ -91,7 +93,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +270,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -306,7 +308,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-meta {
|
.panel-meta {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -347,7 +349,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-row td,
|
.empty-row td,
|
||||||
|
|||||||
+331
-343
@@ -1,343 +1,331 @@
|
|||||||
/*
|
/*
|
||||||
* Agents Management CSS
|
* Agents Management CSS
|
||||||
* Styles for the remote agent management interface
|
* Styles for the remote agent management interface
|
||||||
*/
|
* Inherits CSS variables from core/variables.css
|
||||||
|
*/
|
||||||
/* CSS Variables (inherited from main theme) */
|
|
||||||
:root {
|
/* Agent indicator in navigation */
|
||||||
--bg-primary: #0a0a0f;
|
.agent-indicator {
|
||||||
--bg-secondary: #12121a;
|
display: flex;
|
||||||
--text-primary: #e0e0e0;
|
align-items: center;
|
||||||
--text-secondary: #888;
|
gap: 8px;
|
||||||
--border-color: #1a1a2e;
|
padding: 6px 12px;
|
||||||
--accent-cyan: #00d4ff;
|
background: rgba(0, 212, 255, 0.1);
|
||||||
--accent-green: #00ff88;
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
--accent-red: #ff3366;
|
border-radius: 20px;
|
||||||
--accent-orange: #ff9f1c;
|
cursor: pointer;
|
||||||
}
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
/* Agent indicator in navigation */
|
|
||||||
.agent-indicator {
|
.agent-indicator:hover {
|
||||||
display: flex;
|
background: rgba(0, 212, 255, 0.2);
|
||||||
align-items: center;
|
border-color: var(--accent-cyan);
|
||||||
gap: 8px;
|
}
|
||||||
padding: 6px 12px;
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
.agent-indicator-dot {
|
||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
width: 8px;
|
||||||
border-radius: 20px;
|
height: 8px;
|
||||||
cursor: pointer;
|
border-radius: 50%;
|
||||||
transition: all 0.2s;
|
background: var(--accent-green);
|
||||||
}
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
.agent-indicator:hover {
|
|
||||||
background: rgba(0, 212, 255, 0.2);
|
.agent-indicator-dot.remote {
|
||||||
border-color: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
}
|
box-shadow: 0 0 6px var(--accent-cyan);
|
||||||
|
}
|
||||||
.agent-indicator-dot {
|
|
||||||
width: 8px;
|
.agent-indicator-dot.multiple {
|
||||||
height: 8px;
|
background: var(--accent-orange);
|
||||||
border-radius: 50%;
|
box-shadow: 0 0 6px var(--accent-orange);
|
||||||
background: var(--accent-green);
|
}
|
||||||
box-shadow: 0 0 6px var(--accent-green);
|
|
||||||
}
|
.agent-indicator-label {
|
||||||
|
font-size: 11px;
|
||||||
.agent-indicator-dot.remote {
|
color: var(--text-primary);
|
||||||
background: var(--accent-cyan);
|
font-family: var(--font-mono);
|
||||||
box-shadow: 0 0 6px var(--accent-cyan);
|
}
|
||||||
}
|
|
||||||
|
.agent-indicator-count {
|
||||||
.agent-indicator-dot.multiple {
|
font-size: 10px;
|
||||||
background: var(--accent-orange);
|
padding: 2px 6px;
|
||||||
box-shadow: 0 0 6px var(--accent-orange);
|
background: rgba(0, 212, 255, 0.2);
|
||||||
}
|
border-radius: 10px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
.agent-indicator-label {
|
}
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-primary);
|
/* Agent selector dropdown */
|
||||||
font-family: 'JetBrains Mono', monospace;
|
.agent-selector {
|
||||||
}
|
position: relative;
|
||||||
|
}
|
||||||
.agent-indicator-count {
|
|
||||||
font-size: 10px;
|
.agent-selector-dropdown {
|
||||||
padding: 2px 6px;
|
position: absolute;
|
||||||
background: rgba(0, 212, 255, 0.2);
|
top: 100%;
|
||||||
border-radius: 10px;
|
right: 0;
|
||||||
color: var(--accent-cyan);
|
margin-top: 8px;
|
||||||
}
|
min-width: 280px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
/* Agent selector dropdown */
|
border: 1px solid var(--border-color);
|
||||||
.agent-selector {
|
border-radius: 8px;
|
||||||
position: relative;
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
}
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
.agent-selector-dropdown {
|
}
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
.agent-selector-dropdown.show {
|
||||||
right: 0;
|
display: block;
|
||||||
margin-top: 8px;
|
}
|
||||||
min-width: 280px;
|
|
||||||
background: var(--bg-secondary);
|
.agent-selector-header {
|
||||||
border: 1px solid var(--border-color);
|
display: flex;
|
||||||
border-radius: 8px;
|
justify-content: space-between;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
align-items: center;
|
||||||
z-index: 1000;
|
padding: 12px 15px;
|
||||||
display: none;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-selector-dropdown.show {
|
.agent-selector-header h4 {
|
||||||
display: block;
|
margin: 0;
|
||||||
}
|
font-size: 12px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
.agent-selector-header {
|
text-transform: uppercase;
|
||||||
display: flex;
|
letter-spacing: 1px;
|
||||||
justify-content: space-between;
|
}
|
||||||
align-items: center;
|
|
||||||
padding: 12px 15px;
|
.agent-selector-manage {
|
||||||
border-bottom: 1px solid var(--border-color);
|
font-size: 11px;
|
||||||
}
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
.agent-selector-header h4 {
|
}
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
.agent-selector-manage:hover {
|
||||||
color: var(--accent-cyan);
|
text-decoration: underline;
|
||||||
text-transform: uppercase;
|
}
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
.agent-selector-list {
|
||||||
|
max-height: 300px;
|
||||||
.agent-selector-manage {
|
overflow-y: auto;
|
||||||
font-size: 11px;
|
}
|
||||||
color: var(--accent-cyan);
|
|
||||||
text-decoration: none;
|
.agent-selector-item {
|
||||||
}
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
.agent-selector-manage:hover {
|
gap: 10px;
|
||||||
text-decoration: underline;
|
padding: 10px 15px;
|
||||||
}
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
.agent-selector-list {
|
border-bottom: 1px solid var(--border-color);
|
||||||
max-height: 300px;
|
}
|
||||||
overflow-y: auto;
|
|
||||||
}
|
.agent-selector-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
.agent-selector-item {
|
}
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
.agent-selector-item:hover {
|
||||||
gap: 10px;
|
background: rgba(0, 212, 255, 0.1);
|
||||||
padding: 10px 15px;
|
}
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
.agent-selector-item.selected {
|
||||||
border-bottom: 1px solid var(--border-color);
|
background: rgba(0, 212, 255, 0.15);
|
||||||
}
|
border-left: 3px solid var(--accent-cyan);
|
||||||
|
}
|
||||||
.agent-selector-item:last-child {
|
|
||||||
border-bottom: none;
|
.agent-selector-item.local {
|
||||||
}
|
border-left: 3px solid var(--accent-green);
|
||||||
|
}
|
||||||
.agent-selector-item:hover {
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
.agent-selector-item-status {
|
||||||
}
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
.agent-selector-item.selected {
|
border-radius: 50%;
|
||||||
background: rgba(0, 212, 255, 0.15);
|
flex-shrink: 0;
|
||||||
border-left: 3px solid var(--accent-cyan);
|
}
|
||||||
}
|
|
||||||
|
.agent-selector-item-status.online {
|
||||||
.agent-selector-item.local {
|
background: var(--accent-green);
|
||||||
border-left: 3px solid var(--accent-green);
|
}
|
||||||
}
|
|
||||||
|
.agent-selector-item-status.offline {
|
||||||
.agent-selector-item-status {
|
background: var(--accent-red);
|
||||||
width: 8px;
|
}
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
.agent-selector-item-info {
|
||||||
flex-shrink: 0;
|
flex: 1;
|
||||||
}
|
min-width: 0;
|
||||||
|
}
|
||||||
.agent-selector-item-status.online {
|
|
||||||
background: var(--accent-green);
|
.agent-selector-item-name {
|
||||||
}
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
.agent-selector-item-status.offline {
|
white-space: nowrap;
|
||||||
background: var(--accent-red);
|
overflow: hidden;
|
||||||
}
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.agent-selector-item-info {
|
|
||||||
flex: 1;
|
.agent-selector-item-url {
|
||||||
min-width: 0;
|
font-size: 10px;
|
||||||
}
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
.agent-selector-item-name {
|
white-space: nowrap;
|
||||||
font-size: 13px;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
}
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
.agent-selector-item-check {
|
||||||
}
|
color: var(--accent-green);
|
||||||
|
opacity: 0;
|
||||||
.agent-selector-item-url {
|
}
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary);
|
.agent-selector-item.selected .agent-selector-item-check {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
opacity: 1;
|
||||||
white-space: nowrap;
|
}
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
/* Agent badge in data displays */
|
||||||
}
|
.agent-badge {
|
||||||
|
display: inline-flex;
|
||||||
.agent-selector-item-check {
|
align-items: center;
|
||||||
color: var(--accent-green);
|
gap: 4px;
|
||||||
opacity: 0;
|
padding: 2px 8px;
|
||||||
}
|
font-size: 10px;
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
.agent-selector-item.selected .agent-selector-item-check {
|
color: var(--accent-cyan);
|
||||||
opacity: 1;
|
border-radius: 10px;
|
||||||
}
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
/* Agent badge in data displays */
|
|
||||||
.agent-badge {
|
.agent-badge.local,
|
||||||
display: inline-flex;
|
.agent-badge.agent-local {
|
||||||
align-items: center;
|
background: rgba(0, 255, 136, 0.1);
|
||||||
gap: 4px;
|
color: var(--accent-green);
|
||||||
padding: 2px 8px;
|
}
|
||||||
font-size: 10px;
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
.agent-badge.agent-remote {
|
||||||
color: var(--accent-cyan);
|
background: rgba(0, 212, 255, 0.1);
|
||||||
border-radius: 10px;
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
}
|
||||||
}
|
|
||||||
|
/* WiFi table agent column */
|
||||||
.agent-badge.local,
|
.wifi-networks-table .col-agent {
|
||||||
.agent-badge.agent-local {
|
width: 100px;
|
||||||
background: rgba(0, 255, 136, 0.1);
|
text-align: center;
|
||||||
color: var(--accent-green);
|
}
|
||||||
}
|
|
||||||
|
.wifi-networks-table th.col-agent {
|
||||||
.agent-badge.agent-remote {
|
font-size: 10px;
|
||||||
background: rgba(0, 212, 255, 0.1);
|
}
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
/* Bluetooth table agent column */
|
||||||
|
.bt-devices-table .col-agent {
|
||||||
/* WiFi table agent column */
|
width: 100px;
|
||||||
.wifi-networks-table .col-agent {
|
text-align: center;
|
||||||
width: 100px;
|
}
|
||||||
text-align: center;
|
|
||||||
}
|
.agent-badge-dot {
|
||||||
|
width: 6px;
|
||||||
.wifi-networks-table th.col-agent {
|
height: 6px;
|
||||||
font-size: 10px;
|
border-radius: 50%;
|
||||||
}
|
background: currentColor;
|
||||||
|
}
|
||||||
/* Bluetooth table agent column */
|
|
||||||
.bt-devices-table .col-agent {
|
/* Agent column in data tables */
|
||||||
width: 100px;
|
.data-table .agent-col {
|
||||||
text-align: center;
|
width: 120px;
|
||||||
}
|
max-width: 120px;
|
||||||
|
}
|
||||||
.agent-badge-dot {
|
|
||||||
width: 6px;
|
/* Multi-agent stream indicator */
|
||||||
height: 6px;
|
.multi-agent-indicator {
|
||||||
border-radius: 50%;
|
position: fixed;
|
||||||
background: currentColor;
|
bottom: 20px;
|
||||||
}
|
left: 20px;
|
||||||
|
display: flex;
|
||||||
/* Agent column in data tables */
|
align-items: center;
|
||||||
.data-table .agent-col {
|
gap: 8px;
|
||||||
width: 120px;
|
padding: 8px 12px;
|
||||||
max-width: 120px;
|
background: var(--bg-secondary);
|
||||||
}
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
/* Multi-agent stream indicator */
|
font-size: 11px;
|
||||||
.multi-agent-indicator {
|
color: var(--text-secondary);
|
||||||
position: fixed;
|
z-index: 100;
|
||||||
bottom: 20px;
|
}
|
||||||
left: 20px;
|
|
||||||
display: flex;
|
.multi-agent-indicator.active {
|
||||||
align-items: center;
|
border-color: var(--accent-cyan);
|
||||||
gap: 8px;
|
color: var(--accent-cyan);
|
||||||
padding: 8px 12px;
|
}
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
.multi-agent-indicator-pulse {
|
||||||
border-radius: 20px;
|
width: 8px;
|
||||||
font-size: 11px;
|
height: 8px;
|
||||||
color: var(--text-secondary);
|
border-radius: 50%;
|
||||||
z-index: 100;
|
background: var(--accent-cyan);
|
||||||
}
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
.multi-agent-indicator.active {
|
|
||||||
border-color: var(--accent-cyan);
|
@keyframes pulse {
|
||||||
color: var(--accent-cyan);
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
}
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
|
}
|
||||||
.multi-agent-indicator-pulse {
|
|
||||||
width: 8px;
|
/* Agent connection status toast */
|
||||||
height: 8px;
|
.agent-toast {
|
||||||
border-radius: 50%;
|
position: fixed;
|
||||||
background: var(--accent-cyan);
|
top: 80px;
|
||||||
animation: pulse 2s infinite;
|
right: 20px;
|
||||||
}
|
padding: 10px 15px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
@keyframes pulse {
|
border: 1px solid var(--border-color);
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
border-radius: 6px;
|
||||||
50% { opacity: 0.5; transform: scale(0.8); }
|
font-size: 12px;
|
||||||
}
|
z-index: 1001;
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
/* Agent connection status toast */
|
}
|
||||||
.agent-toast {
|
|
||||||
position: fixed;
|
.agent-toast.connected {
|
||||||
top: 80px;
|
border-color: var(--accent-green);
|
||||||
right: 20px;
|
color: var(--accent-green);
|
||||||
padding: 10px 15px;
|
}
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
.agent-toast.disconnected {
|
||||||
border-radius: 6px;
|
border-color: var(--accent-red);
|
||||||
font-size: 12px;
|
color: var(--accent-red);
|
||||||
z-index: 1001;
|
}
|
||||||
animation: slideInRight 0.3s ease;
|
|
||||||
}
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
.agent-toast.connected {
|
transform: translateX(100%);
|
||||||
border-color: var(--accent-green);
|
opacity: 0;
|
||||||
color: var(--accent-green);
|
}
|
||||||
}
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
.agent-toast.disconnected {
|
opacity: 1;
|
||||||
border-color: var(--accent-red);
|
}
|
||||||
color: var(--accent-red);
|
}
|
||||||
}
|
|
||||||
|
/* Responsive adjustments */
|
||||||
@keyframes slideInRight {
|
@media (max-width: 768px) {
|
||||||
from {
|
.agent-indicator {
|
||||||
transform: translateX(100%);
|
padding: 4px 8px;
|
||||||
opacity: 0;
|
}
|
||||||
}
|
|
||||||
to {
|
.agent-indicator-label {
|
||||||
transform: translateX(0);
|
display: none;
|
||||||
opacity: 1;
|
}
|
||||||
}
|
|
||||||
}
|
.agent-selector-dropdown {
|
||||||
|
position: fixed;
|
||||||
/* Responsive adjustments */
|
top: auto;
|
||||||
@media (max-width: 768px) {
|
bottom: 0;
|
||||||
.agent-indicator {
|
left: 0;
|
||||||
padding: 4px 8px;
|
right: 0;
|
||||||
}
|
margin: 0;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
.agent-indicator-label {
|
max-height: 60vh;
|
||||||
display: none;
|
}
|
||||||
}
|
|
||||||
|
.agents-grid {
|
||||||
.agent-selector-dropdown {
|
grid-template-columns: 1fr;
|
||||||
position: fixed;
|
}
|
||||||
top: auto;
|
}
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 16px 16px 0 0;
|
|
||||||
max-height: 60vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+151
-33
@@ -8,6 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #151a23;
|
--bg-card: #151a23;
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -97,7 +99,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -132,10 +134,49 @@ body {
|
|||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .agent-select-sm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .agent-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .agent-status-dot.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
box-shadow: 0 0 6px var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-compact .show-all-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
@@ -183,7 +224,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-value {
|
.strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -287,7 +328,7 @@ body {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -320,14 +361,15 @@ 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 - 95px);
|
height: calc(100dvh - 160px);
|
||||||
height: calc(100vh - 95px);
|
height: calc(100vh - 160px);
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,13 +409,10 @@ body {
|
|||||||
/* Leaflet overrides - Dark map styling */
|
/* Leaflet overrides - Dark map styling */
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-tile-pane,
|
/* Using actual dark tiles now - no filter needed */
|
||||||
.leaflet-container .leaflet-tile-pane {
|
|
||||||
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-control-zoom a {
|
.leaflet-control-zoom a {
|
||||||
background: var(--bg-panel) !important;
|
background: var(--bg-panel) !important;
|
||||||
@@ -441,7 +480,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', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -513,7 +552,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-name {
|
.vessel-name {
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -521,7 +560,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-mmsi {
|
.vessel-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
@@ -551,7 +590,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -607,20 +646,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-name {
|
.vessel-item-name {
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-type {
|
.vessel-item-type {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-speed {
|
.vessel-item-speed {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -690,7 +729,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +740,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +759,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -759,6 +798,55 @@ body {
|
|||||||
filter: drop-shadow(0 0 8px rgba(255,255,255,0.8)) !important;
|
filter: drop-shadow(0 0 8px rgba(255,255,255,0.8)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRACKED VESSEL PULSATING RING
|
||||||
|
============================================ */
|
||||||
|
.vessel-marker.selected {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-ring {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tracking-pulse 1.5s ease-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-ring-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tracking-pulse 1.5s ease-out infinite 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tracking-pulse {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0.8);
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(74, 158, 255, 1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.8);
|
||||||
|
opacity: 0;
|
||||||
|
border-color: rgba(74, 158, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Range rings */
|
/* Range rings */
|
||||||
.range-ring {
|
.range-ring {
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -788,7 +876,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 - 95px);
|
min-height: calc(100dvh - 160px);
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -958,7 +1046,7 @@ body {
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,7 +1121,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-category {
|
.dsc-message-category {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1050,13 +1138,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-time {
|
.dsc-message-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-mmsi {
|
.dsc-message-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1162,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-pos {
|
.dsc-message-pos {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -1102,7 +1190,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-header {
|
.dsc-distress-alert .dsc-alert-header {
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
@@ -1111,7 +1199,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-mmsi {
|
.dsc-distress-alert .dsc-alert-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1131,7 +1219,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-position {
|
.dsc-distress-alert .dsc-alert-position {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -1142,7 +1230,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1201,3 +1289,33 @@ body {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GPS Indicator */
|
||||||
|
.gps-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid #22c55e;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #22c55e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-indicator .gps-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: gps-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gps-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
|||||||
|
/* Function Strip (Action Bar) - Shared across modes
|
||||||
|
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.function-strip {
|
||||||
|
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow: visible;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.function-strip .strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-stat:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-label {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal stat coloring */
|
||||||
|
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||||
|
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||||
|
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.function-strip .strip-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-select {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-select:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input:hover,
|
||||||
|
.function-strip .strip-input:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wider input for frequency values */
|
||||||
|
.function-strip .strip-input.wide {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Status Indicators */
|
||||||
|
.function-strip .strip-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-tool {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 59, 48, 0.2);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-tool.ok {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border-color: rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-tool.warn {
|
||||||
|
background: rgba(255, 193, 7, 0.2);
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
border-color: rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.function-strip .strip-btn {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
border-color: rgba(74, 158, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.stop {
|
||||||
|
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .strip-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator */
|
||||||
|
.function-strip .strip-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.inactive {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.active,
|
||||||
|
.function-strip .status-dot.scanning,
|
||||||
|
.function-strip .status-dot.decoding {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.listening,
|
||||||
|
.function-strip .status-dot.tracking,
|
||||||
|
.function-strip .status-dot.receiving {
|
||||||
|
background: var(--accent-green);
|
||||||
|
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.sweeping {
|
||||||
|
background: var(--accent-orange);
|
||||||
|
animation: strip-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip .status-dot.error {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes strip-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||||
|
50% { opacity: 0.6; box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time display */
|
||||||
|
.function-strip .strip-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode-specific accent colors */
|
||||||
|
.function-strip.pager-strip .strip-stat {
|
||||||
|
background: rgba(255, 193, 7, 0.05);
|
||||||
|
border-color: rgba(255, 193, 7, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.pager-strip .strip-stat:hover {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border-color: rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.pager-strip .strip-value {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.sensor-strip .strip-stat {
|
||||||
|
background: rgba(0, 255, 136, 0.05);
|
||||||
|
border-color: rgba(0, 255, 136, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.sensor-strip .strip-stat:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
border-color: rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.sensor-strip .strip-value {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.bt-strip .strip-stat {
|
||||||
|
background: rgba(0, 122, 255, 0.05);
|
||||||
|
border-color: rgba(0, 122, 255, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.bt-strip .strip-stat:hover {
|
||||||
|
background: rgba(0, 122, 255, 0.1);
|
||||||
|
border-color: rgba(0, 122, 255, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.bt-strip .strip-value {
|
||||||
|
color: #0a84ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.wifi-strip .strip-stat {
|
||||||
|
background: rgba(255, 149, 0, 0.05);
|
||||||
|
border-color: rgba(255, 149, 0, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.wifi-strip .strip-stat:hover {
|
||||||
|
background: rgba(255, 149, 0, 0.1);
|
||||||
|
border-color: rgba(255, 149, 0, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.wifi-strip .strip-value {
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.tscm-strip {
|
||||||
|
margin-top: 4px; /* Extra clearance to prevent top clipping */
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.tscm-strip .strip-stat {
|
||||||
|
background: rgba(255, 59, 48, 0.15);
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.4);
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-stat:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.25);
|
||||||
|
border-color: rgba(255, 59, 48, 0.6);
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-value {
|
||||||
|
color: #ef4444; /* Explicit red color */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-label {
|
||||||
|
color: #9ca3af; /* Explicit light gray */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-select {
|
||||||
|
color: #e8eaed; /* Explicit white for selects */
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-btn {
|
||||||
|
color: #e8eaed; /* Explicit white for buttons */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-tool {
|
||||||
|
color: #e8eaed; /* Explicit white for tool indicators */
|
||||||
|
}
|
||||||
|
.function-strip.tscm-strip .strip-time,
|
||||||
|
.function-strip.tscm-strip .strip-status span {
|
||||||
|
color: #9ca3af; /* Explicit gray for status/time */
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.rtlamr-strip .strip-stat {
|
||||||
|
background: rgba(175, 82, 222, 0.05);
|
||||||
|
border-color: rgba(175, 82, 222, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.rtlamr-strip .strip-stat:hover {
|
||||||
|
background: rgba(175, 82, 222, 0.1);
|
||||||
|
border-color: rgba(175, 82, 222, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.rtlamr-strip .strip-value {
|
||||||
|
color: #af52de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-strip.listening-strip .strip-stat {
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
border-color: rgba(74, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
.function-strip.listening-strip .strip-stat:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
.function-strip.listening-strip .strip-value {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Threat-colored stats for TSCM */
|
||||||
|
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
|
||||||
|
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
|
||||||
|
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
|
||||||
@@ -14,10 +14,18 @@
|
|||||||
|
|
||||||
.radar-device {
|
.radar-device {
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
transform-origin: center center;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-device:hover {
|
.radar-device:hover {
|
||||||
transform: scale(1.3);
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invisible larger hit area to prevent hover flicker */
|
||||||
|
.radar-device-hitarea {
|
||||||
|
fill: transparent;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-dot-pulse circle:first-child {
|
.radar-dot-pulse circle:first-child {
|
||||||
|
|||||||
+1934
-1785
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,626 @@
|
|||||||
|
/**
|
||||||
|
* Toast Notification System
|
||||||
|
* Reusable toast notifications for update alerts and other messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOAST CONTAINER
|
||||||
|
============================================ */
|
||||||
|
#toastContainer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 10001;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toastContainer > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UPDATE TOAST
|
||||||
|
============================================ */
|
||||||
|
.update-toast {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-card, #121620);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
max-width: 340px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-indicator {
|
||||||
|
width: 4px;
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-icon {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: -4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-close:hover {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-body strong {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-primary {
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-primary:hover {
|
||||||
|
background: #34d673;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-secondary {
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast-btn-secondary:hover {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
border-color: var(--border-light, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UPDATE MODAL
|
||||||
|
============================================ */
|
||||||
|
.update-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10002;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-overlay.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal {
|
||||||
|
background: var(--bg-card, #121620);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-overlay.show .update-modal {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-icon {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-icon svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-close:hover {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Info */
|
||||||
|
.update-version-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-current,
|
||||||
|
.update-version-latest {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-new {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-arrow {
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-arrow svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.update-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes h2,
|
||||||
|
.update-release-notes h3,
|
||||||
|
.update-release-notes h4 {
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes h2:first-child,
|
||||||
|
.update-release-notes h3:first-child,
|
||||||
|
.update-release-notes h4:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes ul {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes li {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-release-notes p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning */
|
||||||
|
.update-warning {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-icon {
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-text strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-warning-text p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options */
|
||||||
|
.update-options {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-option input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.update-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-progress-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color, #1f2937);
|
||||||
|
border-top-color: var(--accent-cyan, #4a9eff);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: updateSpin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes updateSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
.update-result {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-text code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-success {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-success .update-result-icon {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-success .update-result-text {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error .update-result-icon {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error .update-result-text {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-error .update-result-text strong {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning .update-result-icon {
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning .update-result-text {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-warning .update-result-text strong {
|
||||||
|
color: var(--accent-orange, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-info {
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-info .update-result-icon {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-result-info .update-result-text {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.update-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border-color, #1f2937);
|
||||||
|
background: var(--bg-secondary, #0f1218);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-link:hover {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-primary {
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-primary:hover:not(:disabled) {
|
||||||
|
background: #34d673;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-secondary {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-elevated, #1a202c);
|
||||||
|
border-color: var(--border-light, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#toastContainer {
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal {
|
||||||
|
width: 95%;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-link {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Base Styles
|
||||||
|
* Reset, typography, and foundational element styles
|
||||||
|
* Requires: variables.css to be imported first
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CSS RESET
|
||||||
|
============================================ */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================ */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--text-4xl); }
|
||||||
|
h2 { font-size: var(--text-3xl); }
|
||||||
|
h3 { font-size: var(--text-2xl); }
|
||||||
|
h4 { font-size: var(--text-xl); }
|
||||||
|
h5 { font-size: var(--text-lg); }
|
||||||
|
h6 { font-size: var(--text-base); }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-cyan-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong, b {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, pre, samp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM ELEMENTS
|
||||||
|
============================================ */
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-cyan-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TABLES
|
||||||
|
============================================ */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LISTS
|
||||||
|
============================================ */
|
||||||
|
ul, ol {
|
||||||
|
padding-left: var(--space-6);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UTILITY CLASSES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
.text-primary { color: var(--text-primary); }
|
||||||
|
.text-secondary { color: var(--text-secondary); }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-cyan { color: var(--accent-cyan); }
|
||||||
|
.text-green { color: var(--accent-green); }
|
||||||
|
.text-red { color: var(--accent-red); }
|
||||||
|
.text-orange { color: var(--accent-orange); }
|
||||||
|
.text-amber { color: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Font utilities */
|
||||||
|
.font-mono { font-family: var(--font-mono); }
|
||||||
|
.font-medium { font-weight: var(--font-medium); }
|
||||||
|
.font-semibold { font-weight: var(--font-semibold); }
|
||||||
|
.font-bold { font-weight: var(--font-bold); }
|
||||||
|
|
||||||
|
/* Text sizes */
|
||||||
|
.text-xs { font-size: var(--text-xs); }
|
||||||
|
.text-sm { font-size: var(--text-sm); }
|
||||||
|
.text-base { font-size: var(--text-base); }
|
||||||
|
.text-lg { font-size: var(--text-lg); }
|
||||||
|
.text-xl { font-size: var(--text-xl); }
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.inline-flex { display: inline-flex; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
|
||||||
|
/* Flexbox */
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.gap-1 { gap: var(--space-1); }
|
||||||
|
.gap-2 { gap: var(--space-2); }
|
||||||
|
.gap-3 { gap: var(--space-3); }
|
||||||
|
.gap-4 { gap: var(--space-4); }
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
.m-0 { margin: 0; }
|
||||||
|
.mt-2 { margin-top: var(--space-2); }
|
||||||
|
.mt-4 { margin-top: var(--space-4); }
|
||||||
|
.mb-2 { margin-bottom: var(--space-2); }
|
||||||
|
.mb-4 { margin-bottom: var(--space-4); }
|
||||||
|
.p-2 { padding: var(--space-2); }
|
||||||
|
.p-3 { padding: var(--space-3); }
|
||||||
|
.p-4 { padding: var(--space-4); }
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
.rounded { border-radius: var(--radius-md); }
|
||||||
|
.rounded-lg { border-radius: var(--radius-lg); }
|
||||||
|
.border { border: 1px solid var(--border-color); }
|
||||||
|
|
||||||
|
/* Truncate text */
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SCROLLBAR STYLING
|
||||||
|
============================================ */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-light);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-light) var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SELECTION
|
||||||
|
============================================ */
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UX POLISH - TRANSITIONS & INTERACTIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Smooth page transitions */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better focus ring for all interactive elements */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove focus ring for mouse users */
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state feedback */
|
||||||
|
button:active:not(:disabled),
|
||||||
|
a:active,
|
||||||
|
[role="button"]:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for all interactive elements */
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
[role="button"] {
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast),
|
||||||
|
opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle hover lift effect for cards and panels */
|
||||||
|
.card:hover,
|
||||||
|
.panel:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link underline on hover */
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip link for accessibility */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
z-index: 9999;
|
||||||
|
transition: top var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
:root {
|
||||||
|
--border-color: #4b5563;
|
||||||
|
--text-secondary: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT UI Components
|
||||||
|
* Reusable component styles for buttons, cards, badges, etc.
|
||||||
|
* Requires: variables.css and base.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BUTTONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Base button */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-cyan-hover);
|
||||||
|
border-color: var(--accent-cyan-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--accent-red);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background: #16a34a;
|
||||||
|
border-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button sizes */
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button */
|
||||||
|
.btn-icon {
|
||||||
|
padding: var(--space-2);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.btn-sm {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CARDS / PANELS
|
||||||
|
============================================ */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel variant (used in dashboards) */
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-indicator.active {
|
||||||
|
background: var(--status-online);
|
||||||
|
box-shadow: 0 0 8px var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BADGES
|
||||||
|
============================================ */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: var(--accent-green-dim);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATUS INDICATORS
|
||||||
|
============================================ */
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--status-offline);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online,
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--status-online);
|
||||||
|
box-shadow: 0 0 6px var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.warning {
|
||||||
|
background: var(--status-warning);
|
||||||
|
box-shadow: 0 0 6px var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error,
|
||||||
|
.status-dot.offline {
|
||||||
|
background: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.inactive {
|
||||||
|
background: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for active status */
|
||||||
|
.status-dot.pulse {
|
||||||
|
animation: statusPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes statusPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EMPTY STATE
|
||||||
|
============================================ */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-description {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-action {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LOADING STATES
|
||||||
|
============================================ */
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-lg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loader */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--bg-tertiary) 25%,
|
||||||
|
var(--bg-elevated) 50%,
|
||||||
|
var(--bg-tertiary) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS STRIP
|
||||||
|
============================================ */
|
||||||
|
.stats-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
height: var(--stats-strip-height);
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM GROUPS
|
||||||
|
============================================ */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline checkbox/radio */
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ALERTS / TOASTS
|
||||||
|
============================================ */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: var(--accent-green-dim);
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
border-color: var(--accent-orange);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOOLTIPS
|
||||||
|
============================================ */
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||||
|
z-index: var(--z-tooltip);
|
||||||
|
pointer-events: none;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ICONS
|
||||||
|
============================================ */
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--lg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SECTION HEADERS
|
||||||
|
============================================ */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DIVIDERS
|
||||||
|
============================================ */
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-vertical {
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UX POLISH - ENHANCED INTERACTIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Button hover lift */
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card/Panel hover effects */
|
||||||
|
.card,
|
||||||
|
.panel {
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-base),
|
||||||
|
border-color var(--transition-base),
|
||||||
|
transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.panel:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats strip value highlight on hover */
|
||||||
|
.strip-stat {
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-stat:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot pulse animation */
|
||||||
|
.status-dot.online,
|
||||||
|
.status-dot.active {
|
||||||
|
animation: statusGlow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes statusGlow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 6px var(--status-online);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge hover effect */
|
||||||
|
.badge {
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert entrance animation */
|
||||||
|
.alert {
|
||||||
|
animation: alertSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes alertSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner smooth appearance */
|
||||||
|
.spinner {
|
||||||
|
animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input focus glow */
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav item active indicator */
|
||||||
|
.mode-nav-btn.active::after,
|
||||||
|
.mobile-nav-btn.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60%;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth tooltip appearance */
|
||||||
|
[data-tooltip]::after {
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-fast),
|
||||||
|
visibility var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
transform: translateX(-50%) translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state with better visual feedback */
|
||||||
|
:disabled,
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(30%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Layout Styles
|
||||||
|
* Global layout structure: header, navigation, sidebar, main content
|
||||||
|
* Requires: variables.css, base.css, components.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
APP SHELL
|
||||||
|
============================================ */
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLOBAL HEADER
|
||||||
|
============================================ */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.app-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-tagline {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page title in header */
|
||||||
|
.app-header-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-subtitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header utilities */
|
||||||
|
.header-utilities {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-clock-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLOBAL NAVIGATION
|
||||||
|
============================================ */
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
height: var(--nav-height);
|
||||||
|
gap: var(--space-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav groups */
|
||||||
|
.nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown trigger */
|
||||||
|
.nav-dropdown-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-arrow {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group.open .nav-dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown menu */
|
||||||
|
.nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--space-1);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group.open .nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav items */
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav divider */
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav utilities (right side) */
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOBILE NAVIGATION
|
||||||
|
============================================ */
|
||||||
|
.mobile-nav {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-btn:hover,
|
||||||
|
.mobile-nav-btn.active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger button */
|
||||||
|
.hamburger-btn {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(4px, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.open span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(4px, -4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CONTENT LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Main content with optional sidebar */
|
||||||
|
.content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.app-sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content-full {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DASHBOARD LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Full-screen dashboard (maps, etc.) */
|
||||||
|
.dashboard-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-logo {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-logo span {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-normal);
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PAGE LAYOUTS
|
||||||
|
============================================ */
|
||||||
|
.page-container {
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE BREAKPOINTS
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.app-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-utilities {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
OVERLAY (for mobile drawers)
|
||||||
|
============================================ */
|
||||||
|
.drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: calc(var(--z-fixed) - 1);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BACK LINK
|
||||||
|
============================================ */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODE NAVIGATION (from index.css)
|
||||||
|
Used by nav.html partial across all pages
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Mode Navigation Bar */
|
||||||
|
.mode-nav {
|
||||||
|
display: none;
|
||||||
|
background: #151a23 !important; /* Explicit color - forced to ensure consistency */
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mode-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active .nav-icon {
|
||||||
|
filter: brightness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn:hover {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Navigation */
|
||||||
|
.mode-nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||||
|
font-size: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
|
filter: brightness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Bar Utilities (clock, theme, tools) */
|
||||||
|
.nav-utilities {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-time {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle icon states in nav bar */
|
||||||
|
.nav-tool-btn .icon-sun,
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects toggle icon states */
|
||||||
|
.nav-tool-btn .icon-effects-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* INTERCEPT Design Tokens
|
||||||
|
* Single source of truth for colors, spacing, typography, and effects
|
||||||
|
* Import this file FIRST in any stylesheet that needs design tokens
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ============================================
|
||||||
|
COLOR PALETTE - Dark Theme (Default)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Backgrounds - layered depth system */
|
||||||
|
--bg-primary: #0a0c10;
|
||||||
|
--bg-secondary: #0f1218;
|
||||||
|
--bg-tertiary: #151a23;
|
||||||
|
--bg-card: #121620;
|
||||||
|
--bg-elevated: #1a202c;
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
/* Background aliases for components */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
|
/* Accent colors */
|
||||||
|
--accent-cyan: #4a9eff;
|
||||||
|
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
|
||||||
|
--accent-cyan-hover: #6bb3ff;
|
||||||
|
--accent-green: #22c55e;
|
||||||
|
--accent-green-dim: rgba(34, 197, 94, 0.15);
|
||||||
|
--accent-red: #ef4444;
|
||||||
|
--accent-red-dim: rgba(239, 68, 68, 0.15);
|
||||||
|
--accent-orange: #f59e0b;
|
||||||
|
--accent-orange-dim: rgba(245, 158, 11, 0.15);
|
||||||
|
--accent-amber: #d4a853;
|
||||||
|
--accent-amber-dim: rgba(212, 168, 83, 0.15);
|
||||||
|
--accent-yellow: #eab308;
|
||||||
|
--accent-purple: #a855f7;
|
||||||
|
|
||||||
|
/* Text hierarchy */
|
||||||
|
--text-primary: #e8eaed;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-dim: #4b5563;
|
||||||
|
--text-muted: #374151;
|
||||||
|
--text-inverse: #0a0c10;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-color: #1f2937;
|
||||||
|
--border-light: #374151;
|
||||||
|
--border-glow: rgba(74, 158, 255, 0.2);
|
||||||
|
--border-focus: var(--accent-cyan);
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
--status-online: #22c55e;
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-error: #ef4444;
|
||||||
|
--status-offline: #6b7280;
|
||||||
|
--status-info: #3b82f6;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SPACING SCALE
|
||||||
|
============================================ */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================ */
|
||||||
|
--font-sans: 'Space Mono', ui-monospace, 'SF Mono', monospace;
|
||||||
|
--font-mono: 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', monospace;
|
||||||
|
|
||||||
|
/* Font sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
--text-4xl: 30px;
|
||||||
|
|
||||||
|
/* Font weights */
|
||||||
|
--font-normal: 400;
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
--font-bold: 700;
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BORDERS & RADIUS
|
||||||
|
============================================ */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SHADOWS
|
||||||
|
============================================ */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15);
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRANSITIONS
|
||||||
|
============================================ */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
--transition-slow: 300ms ease;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Z-INDEX SCALE
|
||||||
|
============================================ */
|
||||||
|
--z-base: 0;
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-fixed: 300;
|
||||||
|
--z-modal-backdrop: 400;
|
||||||
|
--z-modal: 500;
|
||||||
|
--z-toast: 600;
|
||||||
|
--z-tooltip: 700;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LAYOUT
|
||||||
|
============================================ */
|
||||||
|
--header-height: 60px;
|
||||||
|
--nav-height: 44px;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--stats-strip-height: 36px;
|
||||||
|
--content-max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIGHT THEME OVERRIDES
|
||||||
|
============================================ */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #f8fafc;
|
||||||
|
--bg-secondary: #f1f5f9;
|
||||||
|
--bg-tertiary: #e2e8f0;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-elevated: #f8fafc;
|
||||||
|
--bg-overlay: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
/* Background aliases for components */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
|
--accent-cyan: #2563eb;
|
||||||
|
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
|
||||||
|
--accent-cyan-hover: #1d4ed8;
|
||||||
|
--accent-green: #16a34a;
|
||||||
|
--accent-green-dim: rgba(22, 163, 74, 0.1);
|
||||||
|
--accent-red: #dc2626;
|
||||||
|
--accent-red-dim: rgba(220, 38, 38, 0.1);
|
||||||
|
--accent-orange: #d97706;
|
||||||
|
--accent-orange-dim: rgba(217, 119, 6, 0.1);
|
||||||
|
--accent-amber: #b45309;
|
||||||
|
--accent-amber-dim: rgba(180, 83, 9, 0.1);
|
||||||
|
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--text-muted: #cbd5e1;
|
||||||
|
--text-inverse: #f8fafc;
|
||||||
|
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--border-light: #cbd5e1;
|
||||||
|
--border-glow: rgba(37, 99, 235, 0.15);
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||||
|
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
REDUCED MOTION
|
||||||
|
============================================ */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--transition-fast: 0ms;
|
||||||
|
--transition-base: 0ms;
|
||||||
|
--transition-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-67
@@ -1,67 +1,18 @@
|
|||||||
/* Local font declarations for offline mode */
|
/* Local font declarations for offline mode */
|
||||||
|
|
||||||
/* Inter - Primary UI font */
|
/* Space Mono - Console font */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Space Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
|
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Space Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
|
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JetBrains Mono - Monospace/code font */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
/* ============================================
|
||||||
|
Global Navigation Styles
|
||||||
|
Shared across all pages using nav.html
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Icon base (kept lightweight for nav usage) */
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Navigation Bar */
|
||||||
|
.mode-nav {
|
||||||
|
display: none;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
|
||||||
|
border-bottom: 1px solid var(--border-color, #202833);
|
||||||
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mode-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color, #202833);
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.8);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn.active .nav-icon {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(24, 31, 44, 0.85);
|
||||||
|
border: 1px solid var(--border-light, #2b3645);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.95);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Navigation */
|
||||||
|
.mode-nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.8);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: rgba(16, 22, 32, 0.98);
|
||||||
|
border: 1px solid var(--border-color, #202833);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
|
background: rgba(27, 36, 51, 0.95);
|
||||||
|
color: var(--text-primary, #e7ebf2);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Bar Utilities */
|
||||||
|
.nav-utilities {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.nav-utilities {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #8a97a8);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-clock .utc-time {
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color, #202833);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(20, 33, 53, 0.6);
|
||||||
|
border: 1px solid rgba(77, 125, 191, 0.12);
|
||||||
|
color: var(--text-secondary, #b7c1cf);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.9);
|
||||||
|
border-color: var(--accent-cyan, #4d7dbf);
|
||||||
|
color: var(--accent-cyan, #4d7dbf);
|
||||||
|
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position relative needed for absolute positioned icon children */
|
||||||
|
.nav-tool-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-btn:focus-visible,
|
||||||
|
.mode-nav-dropdown-btn:focus-visible,
|
||||||
|
.nav-action-btn:focus-visible,
|
||||||
|
.nav-tool-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-cyan, #4d7dbf);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav tool button SVG sizing and styling */
|
||||||
|
.nav-tool-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle icon states */
|
||||||
|
.nav-tool-btn .icon-sun,
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-moon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects/animations toggle icon states */
|
||||||
|
.nav-tool-btn .icon-effects-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Dashboard Button in Nav */
|
||||||
|
a.nav-dashboard-btn,
|
||||||
|
a.nav-dashboard-btn:link,
|
||||||
|
a.nav-dashboard-btn:visited {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(20, 33, 53, 0.6) !important;
|
||||||
|
border: 1px solid rgba(77, 125, 191, 0.12) !important;
|
||||||
|
color: #b7c1cf !important;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.nav-dashboard-btn:hover {
|
||||||
|
background: rgba(27, 36, 51, 0.9) !important;
|
||||||
|
border-color: #4d7dbf !important;
|
||||||
|
color: #4d7dbf !important;
|
||||||
|
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dashboard-btn .icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dashboard-btn .icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dashboard-btn .nav-label {
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Help Modal Styles
|
||||||
|
* Shared across all pages that include the help modal partial
|
||||||
|
*/
|
||||||
|
|
||||||
|
.help-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 10000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content h2 {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content h3 {
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
margin: 25px 0 15px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-close:hover {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary, #0a0c10);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .desc {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
position: relative;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li::before {
|
||||||
|
content: '\203A';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary, #0a0c10);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab:not(:last-child) {
|
||||||
|
border-right: 1px solid var(--border-color, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab:hover {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab.active {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure code tags are styled */
|
||||||
|
.help-modal code {
|
||||||
|
background: var(--bg-tertiary, #151a23);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
+222
-88
@@ -1,5 +1,3 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -7,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
/* Tactical dark palette */
|
/* Tactical dark palette */
|
||||||
--bg-primary: #0a0c10;
|
--bg-primary: #0a0c10;
|
||||||
--bg-secondary: #0f1218;
|
--bg-secondary: #0f1218;
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -259,7 +259,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-title {
|
.welcome-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -269,7 +269,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-tagline {
|
.welcome-tagline {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
@@ -278,7 +278,7 @@ body {
|
|||||||
|
|
||||||
.welcome-version {
|
.welcome-version {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
@@ -297,7 +297,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-content h2 {
|
.welcome-content h2 {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -313,7 +313,7 @@ body {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-height: 320px;
|
max-height: calc(100vh - 300px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,14 +333,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.changelog-version {
|
.changelog-version {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.changelog-date {
|
.changelog-date {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.changelog-list li {
|
.changelog-list li {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
@@ -364,7 +364,7 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: -15px;
|
left: -15px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mode Selection Grid */
|
/* Mode Selection Grid */
|
||||||
@@ -435,7 +435,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-card .mode-name {
|
.mode-card .mode-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -444,7 +444,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-card .mode-desc {
|
.mode-card .mode-desc {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -463,7 +463,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -517,7 +517,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-footer p {
|
.welcome-footer p {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
@@ -731,7 +731,7 @@ header h1 {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -778,7 +778,7 @@ header h1 {
|
|||||||
border: 1px solid var(--accent-cyan);
|
border: 1px solid var(--accent-cyan);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -814,7 +814,7 @@ header h1 {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -922,7 +922,7 @@ header h1 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -978,6 +978,18 @@ header h1 {
|
|||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Donate button - warm amber accent */
|
||||||
|
.nav-tool-btn--donate {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn--donate:hover {
|
||||||
|
color: var(--accent-orange);
|
||||||
|
border-color: var(--accent-amber);
|
||||||
|
background: rgba(212, 168, 83, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme toggle icon states in nav bar */
|
/* Theme toggle icon states in nav bar */
|
||||||
.nav-tool-btn .icon-sun,
|
.nav-tool-btn .icon-sun,
|
||||||
.nav-tool-btn .icon-moon {
|
.nav-tool-btn .icon-moon {
|
||||||
@@ -1018,7 +1030,7 @@ header h1 {
|
|||||||
.version-badge {
|
.version-badge {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
@@ -1077,7 +1089,7 @@ header h1 .tagline {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1566,7 +1578,7 @@ header h1 .tagline {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -1578,6 +1590,11 @@ header h1 .tagline {
|
|||||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure device select is wide enough for device name + serial */
|
||||||
|
#deviceSelect {
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-group {
|
.checkbox-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1620,7 +1637,7 @@ header h1 .tagline {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -1640,7 +1657,7 @@ header h1 .tagline {
|
|||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1677,7 +1694,7 @@ header h1 .tagline {
|
|||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1740,7 +1757,7 @@ header h1 .tagline {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats>div {
|
.stats>div {
|
||||||
@@ -1766,11 +1783,10 @@ header h1 .tagline {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
min-height: 400px;
|
min-height: 0; /* Allow shrinking in flex context */
|
||||||
max-height: 600px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-content::-webkit-scrollbar {
|
.output-content::-webkit-scrollbar {
|
||||||
@@ -1839,7 +1855,7 @@ header h1 .tagline {
|
|||||||
|
|
||||||
.message .address {
|
.message .address {
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -1852,7 +1868,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message .content.numeric {
|
.message .content.numeric {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -2073,7 +2089,7 @@ header h1 .tagline {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn:hover {
|
.control-btn:hover {
|
||||||
@@ -2341,13 +2357,10 @@ header h1 .tagline {
|
|||||||
/* Dark theme for Leaflet */
|
/* Dark theme for Leaflet */
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-tile-pane,
|
/* Using actual dark tiles now - no filter needed */
|
||||||
.leaflet-container .leaflet-tile-pane {
|
|
||||||
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-control-zoom {
|
.leaflet-control-zoom {
|
||||||
margin-top: 45px !important;
|
margin-top: 45px !important;
|
||||||
@@ -2381,7 +2394,7 @@ header h1 .tagline {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 5px var(--accent-cyan);
|
text-shadow: 0 0 5px var(--accent-cyan);
|
||||||
@@ -2398,7 +2411,7 @@ header h1 .tagline {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 5px var(--accent-cyan);
|
text-shadow: 0 0 5px var(--accent-cyan);
|
||||||
@@ -2419,7 +2432,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-popup {
|
.aircraft-popup {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2463,7 +2476,7 @@ header h1 .tagline {
|
|||||||
background: rgba(0, 0, 0, 0.8) !important;
|
background: rgba(0, 0, 0, 0.8) !important;
|
||||||
border: 1px solid var(--accent-cyan) !important;
|
border: 1px solid var(--accent-cyan) !important;
|
||||||
color: var(--accent-cyan) !important;
|
color: var(--accent-cyan) !important;
|
||||||
font-family: 'JetBrains Mono', monospace !important;
|
font-family: var(--font-mono) !important;
|
||||||
font-size: 10px !important;
|
font-size: 10px !important;
|
||||||
padding: 2px 6px !important;
|
padding: 2px 6px !important;
|
||||||
border-radius: 2px !important;
|
border-radius: 2px !important;
|
||||||
@@ -2491,7 +2504,7 @@ header h1 .tagline {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@@ -2519,9 +2532,8 @@ header h1 .tagline {
|
|||||||
/* Satellite Dashboard Embed */
|
/* Satellite Dashboard Embed */
|
||||||
.satellite-dashboard-embed {
|
.satellite-dashboard-embed {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100vh - 200px);
|
flex: 1;
|
||||||
min-height: 700px;
|
min-height: 400px;
|
||||||
max-height: 900px;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2708,7 +2720,7 @@ header h1 .tagline {
|
|||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
text-shadow: 0 0 15px var(--accent-cyan-dim);
|
text-shadow: 0 0 15px var(--accent-cyan-dim);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
@@ -3102,7 +3114,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sensor-card .data-value {
|
.sensor-card .data-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -3152,7 +3164,7 @@ header h1 .tagline {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recon-stats span {
|
.recon-stats span {
|
||||||
@@ -3202,14 +3214,14 @@ header h1 .tagline {
|
|||||||
|
|
||||||
.device-id {
|
.device-id {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-meta {
|
.device-meta {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-meta.encrypted {
|
.device-meta.encrypted {
|
||||||
@@ -3285,7 +3297,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hex-dump {
|
.hex-dump {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -3325,8 +3337,8 @@ header h1 .tagline {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
margin: 0 15px 10px 15px;
|
margin: 0 15px 10px 15px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
height: calc(100vh - 200px);
|
flex: 1;
|
||||||
min-height: 400px;
|
min-height: 0; /* Allow shrinking in flex context */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -3376,7 +3388,7 @@ header h1 .tagline {
|
|||||||
/* WiFi Main Content - 3 columns */
|
/* WiFi Main Content - 3 columns */
|
||||||
.wifi-main-content {
|
.wifi-main-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr minmax(240px, 280px) minmax(240px, 280px);
|
grid-template-columns: minmax(300px, 1fr) minmax(240px, 280px) minmax(240px, 280px);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -3391,6 +3403,7 @@ header h1 .tagline {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0; /* Prevent content from forcing panel wider */
|
||||||
}
|
}
|
||||||
|
|
||||||
.wifi-networks-header {
|
.wifi-networks-header {
|
||||||
@@ -3558,6 +3571,8 @@ header h1 .tagline {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
min-width: 0; /* Prevent content from forcing panel wider */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wifi-radar-panel h5 {
|
.wifi-radar-panel h5 {
|
||||||
@@ -3793,17 +3808,97 @@ header h1 .tagline {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wifi-client-count-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-mac {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-vendor {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-probes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-probe-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-signal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-rssi {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-client-lastseen {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* WiFi Responsive */
|
/* WiFi Responsive */
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.wifi-main-content {
|
.wifi-main-content {
|
||||||
grid-template-columns: 1fr 240px 240px;
|
grid-template-columns: minmax(280px, 1fr) 240px 240px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.wifi-layout-container {
|
.wifi-layout-container {
|
||||||
height: auto;
|
flex: 1;
|
||||||
max-height: calc(100vh - 200px);
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wifi-main-content {
|
.wifi-main-content {
|
||||||
@@ -3839,8 +3934,8 @@ header h1 .tagline {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
margin: 0 15px 10px 15px;
|
margin: 0 15px 10px 15px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
height: calc(100vh - 200px);
|
flex: 1;
|
||||||
min-height: 400px;
|
min-height: 0; /* Allow shrinking in flex context */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-visuals-column {
|
.bt-visuals-column {
|
||||||
@@ -3954,7 +4049,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-address {
|
.bt-detail-address {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #00d4ff;
|
color: #00d4ff;
|
||||||
}
|
}
|
||||||
@@ -3968,7 +4063,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-rssi-value {
|
.bt-detail-rssi-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -4063,7 +4158,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-services-list {
|
.bt-detail-services-list {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -4097,10 +4192,37 @@ header h1 .tagline {
|
|||||||
|
|
||||||
.bt-device-list {
|
.bt-device-list {
|
||||||
border-left-color: var(--accent-purple) !important;
|
border-left-color: var(--accent-purple) !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 320px;
|
||||||
|
max-height: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-device-list .wifi-device-list-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-device-list .wifi-device-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-device-list .wifi-device-list-header h5 {
|
.bt-device-list .wifi-device-list-header h5 {
|
||||||
color: var(--accent-purple);
|
color: var(--accent-purple);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bluetooth Device Filters */
|
/* Bluetooth Device Filters */
|
||||||
@@ -4110,6 +4232,7 @@ header h1 .tagline {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-filter-btn {
|
.bt-filter-btn {
|
||||||
@@ -4282,7 +4405,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bt-rssi-value {
|
.bt-rssi-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
@@ -4521,8 +4644,8 @@ header h1 .tagline {
|
|||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.bt-layout-container {
|
.bt-layout-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: auto;
|
flex: 1;
|
||||||
max-height: calc(100vh - 200px);
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-layout-container .wifi-visuals {
|
.bt-layout-container .wifi-visuals {
|
||||||
@@ -4641,7 +4764,7 @@ header h1 .tagline {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-legend-item {
|
.security-legend-item {
|
||||||
@@ -4688,7 +4811,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-value {
|
.signal-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 10px var(--accent-cyan-dim);
|
text-shadow: 0 0 10px var(--accent-cyan-dim);
|
||||||
@@ -4841,7 +4964,7 @@ body::before {
|
|||||||
color: #000;
|
color: #000;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 40px;
|
padding: 12px 40px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -5204,7 +5327,7 @@ body::before {
|
|||||||
|
|
||||||
.meter-value {
|
.meter-value {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
width: 50px;
|
width: 50px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -5361,7 +5484,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.freq-digits {
|
.freq-digits {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 56px;
|
font-size: 56px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -5382,7 +5505,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.freq-unit {
|
.freq-unit {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
@@ -5526,7 +5649,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.knob-value {
|
.knob-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -5651,7 +5774,7 @@ body::before {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -5713,13 +5836,13 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-arc-label {
|
.signal-arc-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
fill: var(--text-muted);
|
fill: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-arc-value {
|
.signal-arc-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
fill: var(--accent-cyan);
|
fill: var(--accent-cyan);
|
||||||
@@ -5751,7 +5874,7 @@ body::before {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -5887,7 +6010,7 @@ body::before {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5976,7 +6099,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.module-header {
|
.module-header {
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -6001,7 +6124,7 @@ body::before {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -6040,16 +6163,27 @@ body::before {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-action-btn.scan {
|
.radio-action-btn.scan,
|
||||||
|
.radio-action-btn.listen {
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
border-color: var(--accent-green);
|
border-color: var(--accent-green);
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-action-btn.scan:hover:not(:disabled) {
|
.radio-action-btn.scan:hover:not(:disabled),
|
||||||
|
.radio-action-btn.listen:hover:not(:disabled) {
|
||||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
|
box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radio-action-btn.listen.active {
|
||||||
|
background: var(--accent-red);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-action-btn.listen.active:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 0 20px var(--accent-red-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* Statistics Box */
|
/* Statistics Box */
|
||||||
.stat-box {
|
.stat-box {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
@@ -6059,7 +6193,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -6107,7 +6241,7 @@ body::before {
|
|||||||
.tune-btn {
|
.tune-btn {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -6137,13 +6271,13 @@ body::before {
|
|||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Listening Mode Selector Buttons */
|
/* Listening Mode Selector Buttons */
|
||||||
.radio-mode-btn {
|
.radio-mode-btn {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -6184,7 +6318,7 @@ body::before {
|
|||||||
/* Frequency Preset Buttons */
|
/* Frequency Preset Buttons */
|
||||||
.preset-freq-btn {
|
.preset-freq-btn {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -6248,4 +6382,4 @@ body::before {
|
|||||||
[data-animations="off"] .logo-dot,
|
[data-animations="off"] .logo-dot,
|
||||||
[data-animations="off"] .welcome-logo {
|
[data-animations="off"] .welcome-logo {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
.landing-title {
|
.landing-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.4em;
|
letter-spacing: 0.4em;
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.landing-tagline {
|
.landing-tagline {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
/* Hacker Style Error */
|
/* Hacker Style Error */
|
||||||
.flash-error {
|
.flash-error {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box; /* Crucial for visibility */
|
box-sizing: border-box; /* Crucial for visibility */
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
border: 2px solid var(--accent-cyan);
|
border: 2px solid var(--accent-cyan);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
.landing-version {
|
.landing-version {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
|||||||
+328
-328
@@ -1,328 +1,328 @@
|
|||||||
/* APRS Function Bar (Stats Strip) Styles */
|
/* APRS Function Bar (Stats Strip) Styles */
|
||||||
.aprs-strip {
|
.aprs-strip {
|
||||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.aprs-strip-inner {
|
.aprs-strip-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-stat {
|
.aprs-strip .strip-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
background: rgba(74, 158, 255, 0.05);
|
background: rgba(74, 158, 255, 0.05);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
min-width: 55px;
|
min-width: 55px;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-stat:hover {
|
.aprs-strip .strip-stat:hover {
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
border-color: rgba(74, 158, 255, 0.3);
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-value {
|
.aprs-strip .strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-label {
|
.aprs-strip .strip-label {
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-divider {
|
.aprs-strip .strip-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
/* Signal stat coloring */
|
/* Signal stat coloring */
|
||||||
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||||
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||||
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
.aprs-strip .strip-control {
|
.aprs-strip .strip-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-select {
|
.aprs-strip .strip-select {
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-select:hover {
|
.aprs-strip .strip-select:hover {
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-input-label {
|
.aprs-strip .strip-input-label {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-input {
|
.aprs-strip .strip-input {
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-input:hover,
|
.aprs-strip .strip-input:hover,
|
||||||
.aprs-strip .strip-input:focus {
|
.aprs-strip .strip-input:focus {
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool Status Indicators */
|
/* Tool Status Indicators */
|
||||||
.aprs-strip .strip-tools {
|
.aprs-strip .strip-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-tool {
|
.aprs-strip .strip-tool {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(255, 59, 48, 0.2);
|
background: rgba(255, 59, 48, 0.2);
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-tool.ok {
|
.aprs-strip .strip-tool.ok {
|
||||||
background: rgba(0, 255, 136, 0.1);
|
background: rgba(0, 255, 136, 0.1);
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
border-color: rgba(0, 255, 136, 0.3);
|
border-color: rgba(0, 255, 136, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.aprs-strip .strip-btn {
|
.aprs-strip .strip-btn {
|
||||||
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);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn:hover:not(:disabled) {
|
.aprs-strip .strip-btn:hover:not(:disabled) {
|
||||||
background: rgba(74, 158, 255, 0.2);
|
background: rgba(74, 158, 255, 0.2);
|
||||||
border-color: rgba(74, 158, 255, 0.4);
|
border-color: rgba(74, 158, 255, 0.4);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn.primary {
|
.aprs-strip .strip-btn.primary {
|
||||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn.stop {
|
.aprs-strip .strip-btn.stop {
|
||||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn:disabled {
|
.aprs-strip .strip-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status indicator */
|
/* Status indicator */
|
||||||
.aprs-strip .strip-status {
|
.aprs-strip .strip-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.aprs-strip .status-dot {
|
.aprs-strip .status-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
.aprs-strip .status-dot.listening {
|
.aprs-strip .status-dot.listening {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.aprs-strip .status-dot.tracking {
|
.aprs-strip .status-dot.tracking {
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.aprs-strip .status-dot.error {
|
.aprs-strip .status-dot.error {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
}
|
}
|
||||||
@keyframes aprs-strip-pulse {
|
@keyframes aprs-strip-pulse {
|
||||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||||
50% { opacity: 0.6; box-shadow: none; }
|
50% { opacity: 0.6; box-shadow: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Time display */
|
/* Time display */
|
||||||
.aprs-strip .strip-time {
|
.aprs-strip .strip-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* APRS Status Bar Styles (Sidebar - legacy) */
|
/* APRS Status Bar Styles (Sidebar - legacy) */
|
||||||
.aprs-status-bar {
|
.aprs-status-bar {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.aprs-status-indicator {
|
.aprs-status-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.aprs-status-dot {
|
.aprs-status-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
.aprs-status-dot.standby { background: var(--text-muted); }
|
.aprs-status-dot.standby { background: var(--text-muted); }
|
||||||
.aprs-status-dot.listening {
|
.aprs-status-dot.listening {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
animation: aprs-pulse 1.5s ease-in-out infinite;
|
animation: aprs-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.aprs-status-dot.tracking { background: var(--accent-green); }
|
.aprs-status-dot.tracking { background: var(--accent-green); }
|
||||||
.aprs-status-dot.error { background: var(--accent-red); }
|
.aprs-status-dot.error { background: var(--accent-red); }
|
||||||
@keyframes aprs-pulse {
|
@keyframes aprs-pulse {
|
||||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||||
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
||||||
}
|
}
|
||||||
.aprs-status-text {
|
.aprs-status-text {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
.aprs-status-stats {
|
.aprs-status-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
.aprs-stat {
|
.aprs-stat {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.aprs-stat-label {
|
.aprs-stat-label {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Signal Meter Styles */
|
/* Signal Meter Styles */
|
||||||
.aprs-signal-meter {
|
.aprs-signal-meter {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.aprs-meter-header {
|
.aprs-meter-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.aprs-meter-label {
|
.aprs-meter-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.aprs-meter-value {
|
.aprs-meter-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
}
|
}
|
||||||
.aprs-meter-burst {
|
.aprs-meter-burst {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--accent-yellow);
|
color: var(--accent-yellow);
|
||||||
background: rgba(255, 193, 7, 0.2);
|
background: rgba(255, 193, 7, 0.2);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
animation: burst-flash 0.3s ease-out;
|
animation: burst-flash 0.3s ease-out;
|
||||||
}
|
}
|
||||||
@keyframes burst-flash {
|
@keyframes burst-flash {
|
||||||
0% { opacity: 1; transform: scale(1.1); }
|
0% { opacity: 1; transform: scale(1.1); }
|
||||||
100% { opacity: 1; transform: scale(1); }
|
100% { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
.aprs-meter-bar-container {
|
.aprs-meter-bar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
background: rgba(0,0,0,0.4);
|
background: rgba(0,0,0,0.4);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.aprs-meter-bar {
|
.aprs-meter-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 0%;
|
width: 0%;
|
||||||
background: linear-gradient(90deg,
|
background: linear-gradient(90deg,
|
||||||
var(--accent-green) 0%,
|
var(--accent-green) 0%,
|
||||||
var(--accent-cyan) 50%,
|
var(--accent-cyan) 50%,
|
||||||
var(--accent-yellow) 75%,
|
var(--accent-yellow) 75%,
|
||||||
var(--accent-red) 100%
|
var(--accent-red) 100%
|
||||||
);
|
);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.1s ease-out;
|
transition: width 0.1s ease-out;
|
||||||
}
|
}
|
||||||
.aprs-meter-bar.no-signal {
|
.aprs-meter-bar.no-signal {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
.aprs-meter-ticks {
|
.aprs-meter-ticks {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
.aprs-meter-status {
|
.aprs-meter-status {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.aprs-meter-status.active {
|
.aprs-meter-status.active {
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
.aprs-meter-status.no-signal {
|
.aprs-meter-status.no-signal {
|
||||||
color: var(--accent-yellow);
|
color: var(--accent-yellow);
|
||||||
}
|
}
|
||||||
|
|||||||
+1610
-1182
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-stations-title {
|
.spy-stations-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-station-name {
|
.spy-station-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
|
|
||||||
/* Type Badge */
|
/* Type Badge */
|
||||||
.spy-station-badge {
|
.spy-station-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-meta-mode {
|
.spy-meta-mode {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-list {
|
.spy-freq-list {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-item {
|
.spy-freq-item {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-select {
|
.spy-freq-select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -0,0 +1,876 @@
|
|||||||
|
/**
|
||||||
|
* SSTV Mode Styles
|
||||||
|
* ISS Slow-Scan Television decoder interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODE VISIBILITY
|
||||||
|
============================================ */
|
||||||
|
#sstvMode.active {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VISUALS CONTAINER
|
||||||
|
============================================ */
|
||||||
|
.sstv-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MAIN ROW (Live Decode + Gallery)
|
||||||
|
============================================ */
|
||||||
|
.sstv-main-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS STRIP
|
||||||
|
============================================ */
|
||||||
|
.sstv-stats-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot.idle {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot.listening {
|
||||||
|
background: var(--accent-yellow);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-dot.decoding {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 6px var(--accent-cyan);
|
||||||
|
animation: pulse 0.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-status-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.start {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.start:hover {
|
||||||
|
background: var(--accent-cyan-bright, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.stop {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.stop:hover {
|
||||||
|
background: #ff1a53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-value.accent-cyan {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location inputs in strip */
|
||||||
|
.sstv-strip-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-loc-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-loc-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.gps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.gps:hover {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.update-tle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-btn.update-tle:hover {
|
||||||
|
background: var(--accent-orange);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIVE DECODE SECTION
|
||||||
|
============================================ */
|
||||||
|
.sstv-live-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-title svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-canvas-container {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sstvCanvas {
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-decode-info {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-mode-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-progress-bar .progress {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-status-message {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Idle state */
|
||||||
|
.sstv-idle-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-idle-state svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-idle-state h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-idle-state p {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GALLERY SECTION
|
||||||
|
============================================ */
|
||||||
|
.sstv-gallery-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1.5;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-card:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #000;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-info {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-mode {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-timestamp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty gallery state */
|
||||||
|
.sstv-gallery-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-empty svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOP ROW (Map + Countdown)
|
||||||
|
============================================ */
|
||||||
|
.sstv-top-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
height: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ISS MAP ROW
|
||||||
|
============================================ */
|
||||||
|
.sstv-map-row {
|
||||||
|
flex: 1.5;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-container {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0a1628;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffcc00;
|
||||||
|
background: rgba(255, 204, 0, 0.2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-coords {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-alt {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-pass-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-pass-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-pass-value {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ISS MAP MARKER
|
||||||
|
============================================ */
|
||||||
|
.sstv-iss-marker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-dot {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #ffcc00;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
|
||||||
|
animation: iss-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffcc00;
|
||||||
|
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes iss-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 25px rgba(255, 204, 0, 1), 0 0 50px rgba(255, 204, 0, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Leaflet default marker styles */
|
||||||
|
.leaflet-marker-icon.sstv-iss-marker {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
COUNTDOWN PANEL
|
||||||
|
============================================ */
|
||||||
|
.sstv-countdown-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-header svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-timer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value.imminent {
|
||||||
|
color: var(--accent-green);
|
||||||
|
text-shadow: 0 0 20px rgba(0, 255, 136, 0.4);
|
||||||
|
animation: countdown-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value.active {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
text-shadow: 0 0 20px rgba(255, 204, 0, 0.4);
|
||||||
|
animation: countdown-pulse 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes countdown-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 6px 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-detail-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-detail-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status .sstv-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status.has-pass .sstv-status-dot {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status.imminent .sstv-status-dot {
|
||||||
|
background: var(--accent-green);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-status.active .sstv-status-dot {
|
||||||
|
background: var(--accent-yellow);
|
||||||
|
box-shadow: 0 0 8px var(--accent-yellow);
|
||||||
|
animation: pulse 0.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
IMAGE MODAL
|
||||||
|
============================================ */
|
||||||
|
.sstv-image-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-modal.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-modal img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.sstv-main-row {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-live-section {
|
||||||
|
max-width: none;
|
||||||
|
min-height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-section {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.sstv-top-row {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-row {
|
||||||
|
flex: none;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-panel {
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-countdown-value {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-map {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-overlay {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sstv-stats-strip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-strip-location {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-loc-input {
|
||||||
|
width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-iss-map {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-info {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-map-overlay {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
+1463
-1463
File diff suppressed because it is too large
Load Diff
+660
-660
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--bg-dark: #0a0c10;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0f1218;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #151a23;
|
--bg-card: #151a23;
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -93,7 +95,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
@@ -142,7 +144,7 @@ body {
|
|||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,10 +164,45 @@ body {
|
|||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-status-dot.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
box-shadow: 0 0 6px var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item {
|
.status-item {
|
||||||
@@ -211,6 +248,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid */
|
/* Main dashboard grid */
|
||||||
|
/* Header ~52px + Nav 44px = ~96px, using 100px for safety */
|
||||||
.dashboard {
|
.dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -218,7 +256,7 @@ body {
|
|||||||
grid-template-columns: 1fr 1fr 340px;
|
grid-template-columns: 1fr 1fr 340px;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100vh - 60px);
|
height: calc(100vh - 100px);
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +495,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-value {
|
.telemetry-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -543,7 +581,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pass-time {
|
.pass-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom controls bar */
|
/* Bottom controls bar */
|
||||||
@@ -579,7 +617,7 @@ body {
|
|||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,10 +664,7 @@ body {
|
|||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-tile-pane,
|
/* Using actual dark tiles now - no filter needed */
|
||||||
.leaflet-container .leaflet-tile-pane {
|
|
||||||
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-control-zoom a {
|
.leaflet-control-zoom a {
|
||||||
background: var(--bg-panel) !important;
|
background: var(--bg-panel) !important;
|
||||||
@@ -699,7 +734,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.polar-container,
|
.polar-container,
|
||||||
@@ -751,4 +786,4 @@ body.embedded .panel {
|
|||||||
|
|
||||||
body.embedded .controls-bar {
|
body.embedded .controls-bar {
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
+444
-399
@@ -1,399 +1,444 @@
|
|||||||
/* Settings Modal Styles */
|
/* Settings Modal Styles */
|
||||||
|
|
||||||
.settings-modal {
|
.settings-modal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.85);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-modal.active {
|
.settings-modal.active {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-content {
|
.settings-content {
|
||||||
background: var(--bg-dark, #0a0a0f);
|
background: var(--bg-dark, #0a0a0f);
|
||||||
border: 1px solid var(--border-color, #1a1a2e);
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header h2 {
|
.settings-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header h2 .icon {
|
.settings-header h2 .icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-close {
|
.settings-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-close:hover {
|
.settings-close:hover {
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Tabs */
|
/* Settings Tabs */
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab:hover {
|
.settings-tab:hover {
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab.active {
|
.settings-tab.active {
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab.active::after {
|
.settings-tab.active::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--accent-cyan, #00d4ff);
|
background: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Sections */
|
/* Settings Sections */
|
||||||
.settings-section {
|
.settings-section {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section.active {
|
.settings-section.active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group {
|
.settings-group {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group:last-child {
|
.settings-group:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group-title {
|
.settings-group-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Row */
|
/* Settings Row */
|
||||||
.settings-row {
|
.settings-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-row:last-child {
|
.settings-row:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-label {
|
.settings-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-label-text {
|
.settings-label-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-label-desc {
|
.settings-label-desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle Switch */
|
/* Toggle Switch */
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input {
|
.toggle-switch input {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-slider {
|
.toggle-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--bg-tertiary, #1a1a2e);
|
background-color: var(--bg-tertiary, #1a1a2e);
|
||||||
border: 1px solid var(--border-color, #2a2a3e);
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
transition: 0.3s;
|
transition: 0.3s;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-slider:before {
|
.toggle-slider:before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
content: "";
|
content: "";
|
||||||
height: 18px;
|
height: 18px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
background-color: var(--text-muted, #666);
|
background-color: var(--text-muted, #666);
|
||||||
transition: 0.3s;
|
transition: 0.3s;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider {
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
background-color: var(--accent-cyan, #00d4ff);
|
background-color: var(--accent-cyan, #00d4ff);
|
||||||
border-color: var(--accent-cyan, #00d4ff);
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider:before {
|
.toggle-switch input:checked + .toggle-slider:before {
|
||||||
transform: translateX(20px);
|
transform: translateX(20px);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:focus + .toggle-slider {
|
.toggle-switch input:focus + .toggle-slider {
|
||||||
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
|
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Select Dropdown */
|
/* Select Dropdown */
|
||||||
.settings-select {
|
.settings-select {
|
||||||
background: var(--bg-tertiary, #1a1a2e);
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
border: 1px solid var(--border-color, #2a2a3e);
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: right 8px center;
|
background-position: right 8px center;
|
||||||
padding-right: 32px;
|
padding-right: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-select:focus {
|
.settings-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-cyan, #00d4ff);
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text Input */
|
/* Text Input */
|
||||||
.settings-input {
|
.settings-input {
|
||||||
background: var(--bg-tertiary, #1a1a2e);
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
border: 1px solid var(--border-color, #2a2a3e);
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-input:focus {
|
.settings-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-cyan, #00d4ff);
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-input::placeholder {
|
.settings-input::placeholder {
|
||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Asset Status */
|
/* Asset Status */
|
||||||
.asset-status {
|
.asset-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--bg-secondary, #0f0f1a);
|
background: var(--bg-secondary, #0f0f1a);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-status-row {
|
.asset-status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-name {
|
.asset-name {
|
||||||
color: var(--text-muted, #888);
|
color: var(--text-muted, #888);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-badge {
|
.asset-badge {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-badge.available {
|
.asset-badge.available {
|
||||||
background: rgba(0, 255, 136, 0.15);
|
background: rgba(0, 255, 136, 0.15);
|
||||||
color: var(--accent-green, #00ff88);
|
color: var(--accent-green, #00ff88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-badge.missing {
|
.asset-badge.missing {
|
||||||
background: rgba(255, 68, 68, 0.15);
|
background: rgba(255, 68, 68, 0.15);
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-badge.checking {
|
.asset-badge.checking {
|
||||||
background: rgba(255, 170, 0, 0.15);
|
background: rgba(255, 170, 0, 0.15);
|
||||||
color: var(--accent-orange, #ffaa00);
|
color: var(--accent-orange, #ffaa00);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Check Assets Button */
|
/* Check Assets Button */
|
||||||
.check-assets-btn {
|
.check-assets-btn {
|
||||||
background: var(--bg-tertiary, #1a1a2e);
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
border: 1px solid var(--border-color, #2a2a3e);
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-assets-btn:hover {
|
.check-assets-btn:hover {
|
||||||
border-color: var(--accent-cyan, #00d4ff);
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-assets-btn:disabled {
|
.check-assets-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* About Section */
|
/* GPS Detection Spinner */
|
||||||
.about-info {
|
.detecting-spinner {
|
||||||
font-size: 13px;
|
display: inline-block;
|
||||||
color: var(--text-muted, #888);
|
width: 12px;
|
||||||
line-height: 1.6;
|
height: 12px;
|
||||||
}
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
.about-info p {
|
border-radius: 50%;
|
||||||
margin: 0 0 12px 0;
|
animation: detecting-spin 0.8s linear infinite;
|
||||||
}
|
vertical-align: middle;
|
||||||
|
margin-right: 6px;
|
||||||
.about-info a {
|
}
|
||||||
color: var(--accent-cyan, #00d4ff);
|
|
||||||
text-decoration: none;
|
@keyframes detecting-spin {
|
||||||
}
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
.about-info a:hover {
|
|
||||||
text-decoration: underline;
|
/* About Section */
|
||||||
}
|
.about-info {
|
||||||
|
font-size: 13px;
|
||||||
.about-version {
|
color: var(--text-muted, #888);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
line-height: 1.6;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
}
|
||||||
}
|
|
||||||
|
.about-info p {
|
||||||
/* Tile Provider Custom URL */
|
margin: 0 0 12px 0;
|
||||||
.custom-url-row {
|
}
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
.about-info a {
|
||||||
}
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
text-decoration: none;
|
||||||
.custom-url-row .settings-input {
|
}
|
||||||
width: 100%;
|
|
||||||
}
|
.about-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
/* Info Callout */
|
}
|
||||||
.settings-info {
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
.about-version {
|
||||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
font-family: var(--font-mono);
|
||||||
border-radius: 6px;
|
color: var(--accent-cyan, #00d4ff);
|
||||||
padding: 12px;
|
}
|
||||||
margin-top: 16px;
|
|
||||||
font-size: 12px;
|
/* Donate Button */
|
||||||
color: var(--text-muted, #888);
|
.donate-btn {
|
||||||
}
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
.settings-info strong {
|
justify-content: center;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
padding: 10px 20px;
|
||||||
}
|
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
|
||||||
|
border: none;
|
||||||
/* Responsive */
|
border-radius: 6px;
|
||||||
@media (max-width: 640px) {
|
color: #000;
|
||||||
.settings-modal.active {
|
font-size: 13px;
|
||||||
padding: 20px 10px;
|
font-weight: 600;
|
||||||
}
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
.settings-content {
|
transition: all 0.2s ease;
|
||||||
max-width: 100%;
|
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-row {
|
.donate-btn:hover {
|
||||||
flex-direction: column;
|
transform: translateY(-1px);
|
||||||
align-items: flex-start;
|
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
|
||||||
gap: 8px;
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-select,
|
.donate-btn:active {
|
||||||
.settings-input {
|
transform: translateY(0);
|
||||||
width: 100%;
|
}
|
||||||
}
|
|
||||||
}
|
/* Tile Provider Custom URL */
|
||||||
|
.custom-url-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-url-row .settings-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Callout */
|
||||||
|
.settings-info {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info strong {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-modal.active {
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select,
|
||||||
|
.settings-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 585 KiB After Width: | Height: | Size: 694 KiB |
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Consumption Sparkline Component
|
||||||
|
* SVG-based visualization for meter consumption deltas
|
||||||
|
* Adapted from RSSISparkline pattern
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ConsumptionSparkline = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Default configuration
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
width: 100,
|
||||||
|
height: 28,
|
||||||
|
maxSamples: 20,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
showGradient: true,
|
||||||
|
barMode: true // Use bars instead of line for consumption
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color thresholds for consumption deltas
|
||||||
|
// Green = normal/expected, Yellow = elevated, Red = spike
|
||||||
|
const DELTA_COLORS = {
|
||||||
|
normal: '#22c55e', // Green
|
||||||
|
elevated: '#eab308', // Yellow
|
||||||
|
spike: '#ef4444' // Red
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a delta value relative to the average
|
||||||
|
* @param {number} delta - The delta value
|
||||||
|
* @param {number} avgDelta - Average delta for comparison
|
||||||
|
* @returns {string} - 'normal', 'elevated', or 'spike'
|
||||||
|
*/
|
||||||
|
function classifyDelta(delta, avgDelta) {
|
||||||
|
if (avgDelta === 0 || isNaN(avgDelta)) {
|
||||||
|
return delta === 0 ? 'normal' : 'elevated';
|
||||||
|
}
|
||||||
|
const ratio = Math.abs(delta) / Math.abs(avgDelta);
|
||||||
|
if (ratio <= 1.5) return 'normal';
|
||||||
|
if (ratio <= 3) return 'elevated';
|
||||||
|
return 'spike';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for a delta value
|
||||||
|
*/
|
||||||
|
function getDeltaColor(delta, avgDelta) {
|
||||||
|
const classification = classifyDelta(delta, avgDelta);
|
||||||
|
return DELTA_COLORS[classification];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline SVG for consumption deltas
|
||||||
|
* @param {Array<{timestamp, delta}>} deltas - Array of delta objects
|
||||||
|
* @param {Object} config - Configuration options
|
||||||
|
* @returns {string} - SVG HTML string
|
||||||
|
*/
|
||||||
|
function createSparklineSvg(deltas, config = {}) {
|
||||||
|
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
const { width, height, strokeWidth, showGradient, barMode } = cfg;
|
||||||
|
|
||||||
|
if (!deltas || deltas.length < 1) {
|
||||||
|
return createEmptySparkline(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the delta values
|
||||||
|
const values = deltas.map(d => d.delta);
|
||||||
|
|
||||||
|
// Calculate statistics for color classification
|
||||||
|
const avgDelta = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
const maxDelta = Math.max(...values.map(Math.abs), 1);
|
||||||
|
|
||||||
|
if (barMode) {
|
||||||
|
return createBarSparkline(values, avgDelta, maxDelta, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createLineSparkline(values, avgDelta, maxDelta, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create bar-style sparkline (better for discrete readings)
|
||||||
|
*/
|
||||||
|
function createBarSparkline(values, avgDelta, maxDelta, cfg) {
|
||||||
|
const { width, height } = cfg;
|
||||||
|
const barCount = Math.min(values.length, cfg.maxSamples);
|
||||||
|
const displayValues = values.slice(-barCount);
|
||||||
|
|
||||||
|
const barWidth = Math.max(3, (width / barCount) - 1);
|
||||||
|
const barGap = 1;
|
||||||
|
|
||||||
|
let bars = '';
|
||||||
|
displayValues.forEach((val, i) => {
|
||||||
|
const normalizedHeight = (Math.abs(val) / maxDelta) * (height - 4);
|
||||||
|
const barHeight = Math.max(2, normalizedHeight);
|
||||||
|
const x = i * (barWidth + barGap);
|
||||||
|
const y = height - barHeight - 2;
|
||||||
|
const color = getDeltaColor(val, avgDelta);
|
||||||
|
|
||||||
|
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
|
||||||
|
width="${barWidth.toFixed(1)}" height="${barHeight.toFixed(1)}"
|
||||||
|
fill="${color}" rx="1" opacity="0.85"/>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<line x1="0" y1="${height - 2}" x2="${width}" y2="${height - 2}"
|
||||||
|
stroke="#333" stroke-width="1" opacity="0.3"/>
|
||||||
|
${bars}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create line-style sparkline
|
||||||
|
*/
|
||||||
|
function createLineSparkline(values, avgDelta, maxDelta, cfg) {
|
||||||
|
const { width, height, strokeWidth, showGradient } = cfg;
|
||||||
|
const displayValues = values.slice(-cfg.maxSamples);
|
||||||
|
|
||||||
|
if (displayValues.length < 2) {
|
||||||
|
return createEmptySparkline(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize values to 0-1 range
|
||||||
|
const normalized = displayValues.map(v => Math.abs(v) / maxDelta);
|
||||||
|
|
||||||
|
// Calculate path
|
||||||
|
const stepX = width / (normalized.length - 1);
|
||||||
|
let pathD = '';
|
||||||
|
let areaD = '';
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
normalized.forEach((val, i) => {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = height - (val * (height - 4)) - 2;
|
||||||
|
points.push({ x, y, value: displayValues[i] });
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
} else {
|
||||||
|
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
areaD += ` L${width},${height} Z`;
|
||||||
|
|
||||||
|
// Get color based on latest value
|
||||||
|
const latestValue = displayValues[displayValues.length - 1];
|
||||||
|
const strokeColor = getDeltaColor(latestValue, avgDelta);
|
||||||
|
const gradientId = `consumption-gradient-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
let gradientDef = '';
|
||||||
|
if (showGradient) {
|
||||||
|
gradientDef = `
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
|
||||||
|
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
${gradientDef}
|
||||||
|
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
|
||||||
|
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
|
||||||
|
r="2.5" fill="${strokeColor}" class="sparkline-dot" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create empty sparkline placeholder
|
||||||
|
*/
|
||||||
|
function createEmptySparkline(width, height) {
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg consumption-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
|
||||||
|
stroke="#444" stroke-width="1" stroke-dasharray="3,3" />
|
||||||
|
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
|
||||||
|
fill="#555" font-size="9" font-family="monospace">Collecting...</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline with summary stats
|
||||||
|
* @param {Array} deltas - Delta history
|
||||||
|
* @param {Object} options - Display options
|
||||||
|
* @returns {string} - HTML string
|
||||||
|
*/
|
||||||
|
function createSparklineWithStats(deltas, options = {}) {
|
||||||
|
const svg = createSparklineSvg(deltas, options);
|
||||||
|
|
||||||
|
if (!deltas || deltas.length < 2) {
|
||||||
|
return `<div class="consumption-sparkline-wrapper">${svg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trend
|
||||||
|
const recentDeltas = deltas.slice(-5);
|
||||||
|
const avgRecent = recentDeltas.reduce((a, d) => a + d.delta, 0) / recentDeltas.length;
|
||||||
|
const trend = avgRecent > 0 ? 'up' : avgRecent < 0 ? 'down' : 'stable';
|
||||||
|
const trendIcon = trend === 'up' ? '↑' : trend === 'down' ? '↓' : '↔';
|
||||||
|
const trendColor = trend === 'up' ? '#22c55e' : trend === 'down' ? '#ef4444' : '#888';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="consumption-sparkline-wrapper">
|
||||||
|
${svg}
|
||||||
|
<span class="consumption-trend" style="color: ${trendColor}" title="Recent trend">
|
||||||
|
${trendIcon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createSparklineSvg,
|
||||||
|
createEmptySparkline,
|
||||||
|
createSparklineWithStats,
|
||||||
|
classifyDelta,
|
||||||
|
getDeltaColor,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
DELTA_COLORS
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.ConsumptionSparkline = ConsumptionSparkline;
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Meter Aggregator Component
|
||||||
|
* Client-side aggregation for rtlamr meter readings
|
||||||
|
* Groups readings by meter ID and tracks consumption history
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MeterAggregator = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
maxHistoryAge: 60 * 60 * 1000, // 60 minutes
|
||||||
|
maxHistoryLength: 50, // Max readings to keep per meter
|
||||||
|
rateWindowMs: 30 * 60 * 1000 // 30 minutes for rate calculation
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage for aggregated meters
|
||||||
|
// Map<meterId, MeterData>
|
||||||
|
const meters = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MeterData structure:
|
||||||
|
* {
|
||||||
|
* id: string,
|
||||||
|
* type: string,
|
||||||
|
* utility: string,
|
||||||
|
* manufacturer: string,
|
||||||
|
* firstSeen: number (timestamp),
|
||||||
|
* lastSeen: number (timestamp),
|
||||||
|
* readingCount: number,
|
||||||
|
* latestReading: object (full reading data),
|
||||||
|
* history: Array<{timestamp, consumption, raw}>,
|
||||||
|
* delta: number | null (change from previous reading),
|
||||||
|
* rate: number | null (units per hour)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingest a new meter reading
|
||||||
|
* @param {Object} data - The raw meter reading data
|
||||||
|
* @returns {Object} - { meter: MeterData, isNew: boolean }
|
||||||
|
*/
|
||||||
|
function ingest(data) {
|
||||||
|
const msgData = data.Message || {};
|
||||||
|
const meterId = String(msgData.ID || data.id || 'Unknown');
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const consumption = msgData.Consumption !== undefined ? msgData.Consumption : data.consumption;
|
||||||
|
|
||||||
|
// Get meter type info if available
|
||||||
|
const meterInfo = typeof getMeterTypeInfo === 'function'
|
||||||
|
? getMeterTypeInfo(msgData.EndpointType, data.Type)
|
||||||
|
: { utility: 'Unknown', manufacturer: 'Unknown' };
|
||||||
|
|
||||||
|
const existing = meters.get(meterId);
|
||||||
|
const isNew = !existing;
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
// Create new meter entry
|
||||||
|
const meter = {
|
||||||
|
id: meterId,
|
||||||
|
type: data.Type || 'Unknown',
|
||||||
|
utility: meterInfo.utility,
|
||||||
|
manufacturer: meterInfo.manufacturer,
|
||||||
|
firstSeen: timestamp,
|
||||||
|
lastSeen: timestamp,
|
||||||
|
readingCount: 1,
|
||||||
|
latestReading: data,
|
||||||
|
history: [{
|
||||||
|
timestamp: timestamp,
|
||||||
|
consumption: consumption,
|
||||||
|
raw: data
|
||||||
|
}],
|
||||||
|
delta: null,
|
||||||
|
rate: null
|
||||||
|
};
|
||||||
|
meters.set(meterId, meter);
|
||||||
|
return { meter, isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing meter
|
||||||
|
const previousConsumption = existing.history.length > 0
|
||||||
|
? existing.history[existing.history.length - 1].consumption
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
existing.history.push({
|
||||||
|
timestamp: timestamp,
|
||||||
|
consumption: consumption,
|
||||||
|
raw: data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prune old history
|
||||||
|
pruneHistory(existing);
|
||||||
|
|
||||||
|
// Calculate delta (change from previous reading)
|
||||||
|
if (previousConsumption !== null && consumption !== undefined && consumption !== null) {
|
||||||
|
existing.delta = consumption - previousConsumption;
|
||||||
|
} else {
|
||||||
|
existing.delta = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate rate (units per hour)
|
||||||
|
existing.rate = calculateRate(existing);
|
||||||
|
|
||||||
|
// Update meter data
|
||||||
|
existing.lastSeen = timestamp;
|
||||||
|
existing.readingCount++;
|
||||||
|
existing.latestReading = data;
|
||||||
|
existing.type = data.Type || existing.type;
|
||||||
|
if (meterInfo.utility !== 'Unknown') existing.utility = meterInfo.utility;
|
||||||
|
if (meterInfo.manufacturer !== 'Unknown') existing.manufacturer = meterInfo.manufacturer;
|
||||||
|
|
||||||
|
return { meter: existing, isNew: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune history older than maxHistoryAge and beyond maxHistoryLength
|
||||||
|
*/
|
||||||
|
function pruneHistory(meter) {
|
||||||
|
const cutoff = Date.now() - CONFIG.maxHistoryAge;
|
||||||
|
|
||||||
|
// Remove old entries
|
||||||
|
meter.history = meter.history.filter(h => h.timestamp >= cutoff);
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if (meter.history.length > CONFIG.maxHistoryLength) {
|
||||||
|
meter.history = meter.history.slice(-CONFIG.maxHistoryLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate consumption rate over the rate window
|
||||||
|
* @returns {number|null} Units per hour, or null if insufficient data
|
||||||
|
*/
|
||||||
|
function calculateRate(meter) {
|
||||||
|
if (meter.history.length < 2) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - CONFIG.rateWindowMs;
|
||||||
|
|
||||||
|
// Find readings within the rate window
|
||||||
|
const recentHistory = meter.history.filter(h => h.timestamp >= windowStart);
|
||||||
|
if (recentHistory.length < 2) return null;
|
||||||
|
|
||||||
|
const oldest = recentHistory[0];
|
||||||
|
const newest = recentHistory[recentHistory.length - 1];
|
||||||
|
|
||||||
|
// Need both to have valid consumption values
|
||||||
|
if (oldest.consumption === undefined || oldest.consumption === null ||
|
||||||
|
newest.consumption === undefined || newest.consumption === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumptionDiff = newest.consumption - oldest.consumption;
|
||||||
|
const timeDiffHours = (newest.timestamp - oldest.timestamp) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (timeDiffHours <= 0) return null;
|
||||||
|
|
||||||
|
return consumptionDiff / timeDiffHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get consumption deltas for sparkline display
|
||||||
|
* @returns {Array<{timestamp, delta}>}
|
||||||
|
*/
|
||||||
|
function getConsumptionDeltas(meter) {
|
||||||
|
const deltas = [];
|
||||||
|
for (let i = 1; i < meter.history.length; i++) {
|
||||||
|
const prev = meter.history[i - 1];
|
||||||
|
const curr = meter.history[i];
|
||||||
|
if (prev.consumption !== undefined && prev.consumption !== null &&
|
||||||
|
curr.consumption !== undefined && curr.consumption !== null) {
|
||||||
|
deltas.push({
|
||||||
|
timestamp: curr.timestamp,
|
||||||
|
delta: curr.consumption - prev.consumption
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a meter by ID
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
function getMeter(id) {
|
||||||
|
return meters.get(String(id)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all meters
|
||||||
|
* @returns {Array<Object>}
|
||||||
|
*/
|
||||||
|
function getAllMeters() {
|
||||||
|
return Array.from(meters.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get meter count
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getCount() {
|
||||||
|
return meters.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all aggregated data
|
||||||
|
*/
|
||||||
|
function clear() {
|
||||||
|
meters.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time since last reading for a meter
|
||||||
|
* @param {Object} meter
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getTimeSinceLastReading(meter) {
|
||||||
|
const diff = Date.now() - meter.lastSeen;
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return 'Just now';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format rate for display
|
||||||
|
* @param {number|null} rate
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatRate(rate) {
|
||||||
|
if (rate === null || rate === undefined || isNaN(rate)) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
// Format based on magnitude
|
||||||
|
const absRate = Math.abs(rate);
|
||||||
|
if (absRate >= 100) {
|
||||||
|
return rate.toFixed(0) + '/hr';
|
||||||
|
} else if (absRate >= 1) {
|
||||||
|
return rate.toFixed(1) + '/hr';
|
||||||
|
} else {
|
||||||
|
return rate.toFixed(2) + '/hr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format delta for display
|
||||||
|
* @param {number|null} delta
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatDelta(delta) {
|
||||||
|
if (delta === null || delta === undefined || isNaN(delta)) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const sign = delta >= 0 ? '+' : '';
|
||||||
|
return sign + delta.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
ingest,
|
||||||
|
getMeter,
|
||||||
|
getAllMeters,
|
||||||
|
getCount,
|
||||||
|
clear,
|
||||||
|
getConsumptionDeltas,
|
||||||
|
getTimeSinceLastReading,
|
||||||
|
formatRate,
|
||||||
|
formatDelta,
|
||||||
|
CONFIG
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.MeterAggregator = MeterAggregator;
|
||||||
@@ -207,9 +207,14 @@ const ProximityRadar = (function() {
|
|||||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||||
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
||||||
|
|
||||||
|
// Hit area size (prevents hover flicker when scaling)
|
||||||
|
const hitAreaSize = Math.max(dotSize * 2, 15);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||||
transform="translate(${x}, ${y})" style="cursor: pointer;">
|
transform="translate(${x}, ${y})" style="cursor: pointer;">
|
||||||
|
<!-- Invisible hit area to prevent hover flicker -->
|
||||||
|
<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 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"/>
|
||||||
|
|||||||
@@ -995,6 +995,24 @@ const SignalCards = (function() {
|
|||||||
let html = '';
|
let html = '';
|
||||||
const rawMessage = msg.rawMessage || {};
|
const rawMessage = msg.rawMessage || {};
|
||||||
|
|
||||||
|
// Add device intelligence info at the top
|
||||||
|
if (msg.utility && msg.utility !== 'Unknown') {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Utility Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(msg.utility)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (msg.manufacturer && msg.manufacturer !== 'Unknown') {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Manufacturer</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(msg.manufacturer)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Display all fields from the raw rtlamr message
|
// Display all fields from the raw rtlamr message
|
||||||
for (const [key, value] of Object.entries(rawMessage)) {
|
for (const [key, value] of Object.entries(rawMessage)) {
|
||||||
if (value === null || value === undefined) continue;
|
if (value === null || value === undefined) continue;
|
||||||
@@ -1066,19 +1084,24 @@ const SignalCards = (function() {
|
|||||||
const stats = getAddressStats('meter', msg.id);
|
const stats = getAddressStats('meter', msg.id);
|
||||||
const seenCount = stats ? stats.count : 1;
|
const seenCount = stats ? stats.count : 1;
|
||||||
|
|
||||||
// Determine meter type color
|
// Determine meter type color based on utility type
|
||||||
let meterTypeClass = 'electric';
|
let meterTypeClass = 'electric';
|
||||||
|
const utility = (msg.utility || '').toLowerCase();
|
||||||
const meterType = (msg.type || '').toLowerCase();
|
const meterType = (msg.type || '').toLowerCase();
|
||||||
if (meterType.includes('gas')) {
|
if (utility === 'gas' || meterType.includes('gas')) {
|
||||||
meterTypeClass = 'gas';
|
meterTypeClass = 'gas';
|
||||||
} else if (meterType.includes('water')) {
|
} else if (utility === 'water' || meterType.includes('water') || meterType.includes('r900')) {
|
||||||
meterTypeClass = 'water';
|
meterTypeClass = 'water';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format utility display
|
||||||
|
const utilityDisplay = msg.utility && msg.utility !== 'Unknown' ? msg.utility : null;
|
||||||
|
const manufacturerDisplay = msg.manufacturer && msg.manufacturer !== 'Unknown' ? msg.manufacturer : null;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="signal-card-header">
|
<div class="signal-card-header">
|
||||||
<div class="signal-card-badges">
|
<div class="signal-card-badges">
|
||||||
<span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(msg.type || 'Meter')}</span>
|
<span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(utilityDisplay || msg.type || 'Meter')}</span>
|
||||||
<span class="signal-freq-badge">ID: ${escapeHtml(msg.id || 'N/A')}</span>
|
<span class="signal-freq-badge">ID: ${escapeHtml(msg.id || 'N/A')}</span>
|
||||||
</div>
|
</div>
|
||||||
${status !== 'baseline' ? `
|
${status !== 'baseline' ? `
|
||||||
@@ -1090,7 +1113,8 @@ const SignalCards = (function() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="signal-card-body">
|
<div class="signal-card-body">
|
||||||
<div class="signal-meta-row">
|
<div class="signal-meta-row">
|
||||||
${msg.endpoint_type ? `<span class="signal-msg-type">${escapeHtml(msg.endpoint_type)}</span>` : ''}
|
${manufacturerDisplay ? `<span class="signal-msg-type">${escapeHtml(manufacturerDisplay)}</span>` : ''}
|
||||||
|
${msg.type ? `<span class="signal-msg-type" style="opacity: 0.7">${escapeHtml(msg.type)}</span>` : ''}
|
||||||
${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''}
|
${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''}
|
||||||
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
|
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1131,6 +1155,303 @@ const SignalCards = (function() {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an aggregated utility meter card (grouped by meter ID)
|
||||||
|
* Shows consumption history, sparkline, delta, and rate
|
||||||
|
* @param {Object} meter - Aggregated meter data from MeterAggregator
|
||||||
|
* @param {Object} options - Optional configuration
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function createAggregatedMeterCard(meter, options = {}) {
|
||||||
|
const status = meter.readingCount === 1 ? 'new' : 'baseline';
|
||||||
|
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
|
||||||
|
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'signal-card meter-aggregated';
|
||||||
|
card.dataset.status = status;
|
||||||
|
card.dataset.type = 'meter';
|
||||||
|
card.dataset.protocol = meter.type || 'unknown';
|
||||||
|
card.dataset.meterId = meter.id;
|
||||||
|
card.id = 'metercard_' + meter.id;
|
||||||
|
|
||||||
|
// Determine meter type color
|
||||||
|
let meterTypeClass = 'electric';
|
||||||
|
const utility = (meter.utility || '').toLowerCase();
|
||||||
|
const meterType = (meter.type || '').toLowerCase();
|
||||||
|
if (utility === 'gas' || meterType.includes('gas')) {
|
||||||
|
meterTypeClass = 'gas';
|
||||||
|
} else if (utility === 'water' || meterType.includes('water') || meterType.includes('r900')) {
|
||||||
|
meterTypeClass = 'water';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format utility display
|
||||||
|
const utilityDisplay = meter.utility && meter.utility !== 'Unknown' ? meter.utility : null;
|
||||||
|
const manufacturerDisplay = meter.manufacturer && meter.manufacturer !== 'Unknown' ? meter.manufacturer : null;
|
||||||
|
|
||||||
|
// Get consumption deltas for sparkline
|
||||||
|
const deltas = typeof MeterAggregator !== 'undefined'
|
||||||
|
? MeterAggregator.getConsumptionDeltas(meter)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Create sparkline
|
||||||
|
const sparklineHtml = typeof ConsumptionSparkline !== 'undefined'
|
||||||
|
? ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 })
|
||||||
|
: '<span class="meter-sparkline-placeholder">--</span>';
|
||||||
|
|
||||||
|
// Format delta and rate
|
||||||
|
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
|
||||||
|
const rateFormatted = MeterAggregator.formatRate(meter.rate);
|
||||||
|
const deltaClass = meter.delta === null ? '' : (meter.delta >= 0 ? 'positive' : 'negative');
|
||||||
|
|
||||||
|
// Get latest consumption
|
||||||
|
const latestConsumption = meter.history.length > 0
|
||||||
|
? meter.history[meter.history.length - 1].consumption
|
||||||
|
: null;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="signal-card-header">
|
||||||
|
<div class="signal-card-badges">
|
||||||
|
<span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(utilityDisplay || meter.type || 'Meter')}</span>
|
||||||
|
<span class="signal-freq-badge">ID: ${escapeHtml(meter.id || 'N/A')}</span>
|
||||||
|
${meter.readingCount > 1 ? `<span class="signal-seen-count">×${meter.readingCount}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${status === 'new' ? `
|
||||||
|
<span class="signal-status-pill" data-status="new">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-body">
|
||||||
|
<div class="signal-meta-row">
|
||||||
|
${manufacturerDisplay ? `<span class="signal-msg-type">${escapeHtml(manufacturerDisplay)}</span>` : ''}
|
||||||
|
${meter.type ? `<span class="signal-msg-type" style="opacity: 0.7">${escapeHtml(meter.type)}</span>` : ''}
|
||||||
|
<span class="signal-timestamp meter-last-seen" data-timestamp="${meter.lastSeen}">${escapeHtml(relativeTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter-aggregated-grid">
|
||||||
|
<div class="meter-aggregated-col consumption-col">
|
||||||
|
<span class="meter-aggregated-label">Consumption</span>
|
||||||
|
<span class="meter-aggregated-value consumption-value">${latestConsumption !== null ? latestConsumption.toLocaleString() : '--'}</span>
|
||||||
|
<span class="meter-delta ${deltaClass}" title="Change from previous reading">${deltaFormatted}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter-aggregated-col trend-col">
|
||||||
|
<span class="meter-aggregated-label">Trend</span>
|
||||||
|
<div class="meter-sparkline-container">
|
||||||
|
${sparklineHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meter-aggregated-col rate-col">
|
||||||
|
<span class="meter-aggregated-label">Rate</span>
|
||||||
|
<span class="meter-rate-value">${rateFormatted}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-footer">
|
||||||
|
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M6 9l6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
<div class="signal-card-actions">
|
||||||
|
<button class="signal-action-btn" onclick="SignalCards.muteAddress('${escapeHtml(meter.id)}')">Mute</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-panel">
|
||||||
|
<div class="signal-advanced-inner">
|
||||||
|
<div class="signal-advanced-content">
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Meter Details</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
${buildAggregatedMeterDetailsHtml(meter)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing aggregated meter card in place
|
||||||
|
* @param {HTMLElement} card - The card element to update
|
||||||
|
* @param {Object} meter - Updated meter data from MeterAggregator
|
||||||
|
*/
|
||||||
|
function updateAggregatedMeterCard(card, meter) {
|
||||||
|
if (!card || !meter) return;
|
||||||
|
|
||||||
|
// Update timestamp
|
||||||
|
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
|
||||||
|
const timestampEl = card.querySelector('.meter-last-seen');
|
||||||
|
if (timestampEl) {
|
||||||
|
timestampEl.dataset.timestamp = meter.lastSeen;
|
||||||
|
timestampEl.textContent = relativeTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update seen count badge
|
||||||
|
const seenCountEl = card.querySelector('.signal-seen-count');
|
||||||
|
if (seenCountEl) {
|
||||||
|
seenCountEl.innerHTML = `×${meter.readingCount}`;
|
||||||
|
} else if (meter.readingCount > 1) {
|
||||||
|
// Add seen count if it doesn't exist
|
||||||
|
const badges = card.querySelector('.signal-card-badges');
|
||||||
|
if (badges) {
|
||||||
|
const countSpan = document.createElement('span');
|
||||||
|
countSpan.className = 'signal-seen-count';
|
||||||
|
countSpan.innerHTML = `×${meter.readingCount}`;
|
||||||
|
badges.appendChild(countSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove "new" status pill after first update
|
||||||
|
if (meter.readingCount > 1) {
|
||||||
|
card.dataset.status = 'baseline';
|
||||||
|
const statusPill = card.querySelector('.signal-status-pill[data-status="new"]');
|
||||||
|
if (statusPill) {
|
||||||
|
statusPill.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update consumption value
|
||||||
|
const latestConsumption = meter.history.length > 0
|
||||||
|
? meter.history[meter.history.length - 1].consumption
|
||||||
|
: null;
|
||||||
|
const consumptionEl = card.querySelector('.consumption-value');
|
||||||
|
if (consumptionEl) {
|
||||||
|
consumptionEl.textContent = latestConsumption !== null ? latestConsumption.toLocaleString() : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update delta
|
||||||
|
const deltaEl = card.querySelector('.meter-delta');
|
||||||
|
if (deltaEl) {
|
||||||
|
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
|
||||||
|
deltaEl.textContent = deltaFormatted;
|
||||||
|
deltaEl.classList.remove('positive', 'negative');
|
||||||
|
if (meter.delta !== null) {
|
||||||
|
deltaEl.classList.add(meter.delta >= 0 ? 'positive' : 'negative');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sparkline
|
||||||
|
const sparklineContainer = card.querySelector('.meter-sparkline-container');
|
||||||
|
if (sparklineContainer && typeof ConsumptionSparkline !== 'undefined') {
|
||||||
|
const deltas = MeterAggregator.getConsumptionDeltas(meter);
|
||||||
|
sparklineContainer.innerHTML = ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rate
|
||||||
|
const rateEl = card.querySelector('.meter-rate-value');
|
||||||
|
if (rateEl) {
|
||||||
|
rateEl.textContent = MeterAggregator.formatRate(meter.rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update details panel
|
||||||
|
const detailsGrid = card.querySelector('.signal-advanced-grid');
|
||||||
|
if (detailsGrid) {
|
||||||
|
detailsGrid.innerHTML = buildAggregatedMeterDetailsHtml(meter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subtle update animation
|
||||||
|
card.classList.add('meter-updated');
|
||||||
|
setTimeout(() => card.classList.remove('meter-updated'), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTML for aggregated meter detail fields
|
||||||
|
* @param {Object} meter - Aggregated meter data
|
||||||
|
* @returns {string} - HTML string
|
||||||
|
*/
|
||||||
|
function buildAggregatedMeterDetailsHtml(meter) {
|
||||||
|
let html = '';
|
||||||
|
const latestReading = meter.latestReading || {};
|
||||||
|
const rawMessage = latestReading.Message || {};
|
||||||
|
|
||||||
|
// Add device intelligence info at the top
|
||||||
|
if (meter.utility && meter.utility !== 'Unknown') {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Utility Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(meter.utility)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (meter.manufacturer && meter.manufacturer !== 'Unknown') {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Manufacturer</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(meter.manufacturer)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add aggregation stats
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Total Readings</span>
|
||||||
|
<span class="signal-advanced-value">${meter.readingCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">First Seen</span>
|
||||||
|
<span class="signal-advanced-value">${new Date(meter.firstSeen).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add rate info if available
|
||||||
|
if (meter.rate !== null) {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Consumption Rate</span>
|
||||||
|
<span class="signal-advanced-value">${MeterAggregator.formatRate(meter.rate)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display fields from the raw rtlamr message
|
||||||
|
for (const [key, value] of Object.entries(rawMessage)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
|
||||||
|
// Format the label
|
||||||
|
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
|
||||||
|
|
||||||
|
// Format the value
|
||||||
|
let displayValue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 10) {
|
||||||
|
displayValue = `[${value.length} values] ${value.slice(0, 5).join(', ')}...`;
|
||||||
|
} else {
|
||||||
|
displayValue = value.join(', ');
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
displayValue = JSON.stringify(value);
|
||||||
|
} else if (key === 'Consumption') {
|
||||||
|
displayValue = `${value.toLocaleString()} units`;
|
||||||
|
} else {
|
||||||
|
displayValue = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">${escapeHtml(label)}</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(displayValue)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message type if not in raw message
|
||||||
|
if (!rawMessage.Type && meter.type) {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Message Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(meter.type)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle advanced panel on a card
|
* Toggle advanced panel on a card
|
||||||
*/
|
*/
|
||||||
@@ -1922,6 +2243,8 @@ const SignalCards = (function() {
|
|||||||
createSensorCard,
|
createSensorCard,
|
||||||
createAcarsCard,
|
createAcarsCard,
|
||||||
createMeterCard,
|
createMeterCard,
|
||||||
|
createAggregatedMeterCard,
|
||||||
|
updateAggregatedMeterCard,
|
||||||
|
|
||||||
// Signal classification
|
// Signal classification
|
||||||
SignalClassification,
|
SignalClassification,
|
||||||
|
|||||||
+507
-504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
(() => {
|
||||||
|
const dropdowns = Array.from(document.querySelectorAll('.mode-nav-dropdown'));
|
||||||
|
if (!dropdowns.length) return;
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
dropdowns.forEach((dropdown) => dropdown.classList.remove('open'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDropdown = (dropdown) => {
|
||||||
|
if (!dropdown.classList.contains('open')) {
|
||||||
|
closeAll();
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const menuLink = event.target.closest('.mode-nav-dropdown-menu a');
|
||||||
|
if (menuLink) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
window.location.href = menuLink.href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = event.target.closest('.mode-nav-dropdown-btn');
|
||||||
|
if (button) {
|
||||||
|
event.preventDefault();
|
||||||
|
const dropdown = button.closest('.mode-nav-dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
if (dropdown.classList.contains('open')) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
openDropdown(dropdown);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest('.mode-nav-dropdown')) {
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Shared observer location helper for map-based modules.
|
||||||
|
// Default: shared location enabled unless explicitly disabled via config.
|
||||||
|
window.ObserverLocation = (function() {
|
||||||
|
const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
|
||||||
|
const SHARED_KEY = 'observerLocation';
|
||||||
|
const AIS_KEY = 'ais_observerLocation';
|
||||||
|
const LEGACY_LAT_KEY = 'observerLat';
|
||||||
|
const LEGACY_LON_KEY = 'observerLon';
|
||||||
|
|
||||||
|
function isSharedEnabled() {
|
||||||
|
return window.INTERCEPT_SHARED_OBSERVER_LOCATION !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(lat, lon) {
|
||||||
|
const latNum = parseFloat(lat);
|
||||||
|
const lonNum = parseFloat(lon);
|
||||||
|
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null;
|
||||||
|
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null;
|
||||||
|
return { lat: latNum, lon: lonNum };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocation(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && parsed.lat !== undefined && parsed.lon !== undefined) {
|
||||||
|
return normalize(parsed.lat, parsed.lon);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readKey(key) {
|
||||||
|
return parseLocation(localStorage.getItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacyLatLon() {
|
||||||
|
const lat = localStorage.getItem(LEGACY_LAT_KEY);
|
||||||
|
const lon = localStorage.getItem(LEGACY_LON_KEY);
|
||||||
|
if (!lat || !lon) return null;
|
||||||
|
return normalize(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShared() {
|
||||||
|
const current = readKey(SHARED_KEY);
|
||||||
|
if (current) return current;
|
||||||
|
|
||||||
|
const legacy = readKey(AIS_KEY) || readLegacyLatLon();
|
||||||
|
if (legacy) {
|
||||||
|
setShared(legacy);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_LOCATION };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShared(location, options = {}) {
|
||||||
|
if (!location) return;
|
||||||
|
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
|
||||||
|
if (options.updateLegacy !== false) {
|
||||||
|
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||||
|
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForModule(moduleKey, options = {}) {
|
||||||
|
if (isSharedEnabled()) {
|
||||||
|
return getShared();
|
||||||
|
}
|
||||||
|
if (moduleKey) {
|
||||||
|
const moduleLocation = readKey(moduleKey);
|
||||||
|
if (moduleLocation) return moduleLocation;
|
||||||
|
}
|
||||||
|
if (options.fallbackToLatLon) {
|
||||||
|
const legacy = readLegacyLatLon();
|
||||||
|
if (legacy) return legacy;
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_LOCATION };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setForModule(moduleKey, location, options = {}) {
|
||||||
|
if (!location) return;
|
||||||
|
if (isSharedEnabled()) {
|
||||||
|
setShared(location, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (moduleKey) {
|
||||||
|
localStorage.setItem(moduleKey, JSON.stringify(location));
|
||||||
|
} else if (options.fallbackToLatLon) {
|
||||||
|
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||||
|
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSharedEnabled,
|
||||||
|
getShared,
|
||||||
|
setShared,
|
||||||
|
getForModule,
|
||||||
|
setForModule,
|
||||||
|
normalize,
|
||||||
|
DEFAULT_LOCATION: { ...DEFAULT_LOCATION }
|
||||||
|
};
|
||||||
|
})();
|
||||||
+906
-399
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* Updater Module - GitHub update checking and notification system
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Updater = {
|
||||||
|
// State
|
||||||
|
_checkInterval: null,
|
||||||
|
_toastElement: null,
|
||||||
|
_modalElement: null,
|
||||||
|
_updateData: null,
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the updater module
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Create toast container if it doesn't exist
|
||||||
|
this._ensureToastContainer();
|
||||||
|
|
||||||
|
// Check for updates on page load
|
||||||
|
this.checkForUpdates();
|
||||||
|
|
||||||
|
// Set up periodic checks
|
||||||
|
this._checkInterval = setInterval(() => {
|
||||||
|
this.checkForUpdates();
|
||||||
|
}, this.CHECK_INTERVAL_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure toast container exists in DOM
|
||||||
|
*/
|
||||||
|
_ensureToastContainer() {
|
||||||
|
if (!document.getElementById('toastContainer')) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'toastContainer';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for updates from the server
|
||||||
|
* @param {boolean} force - Bypass cache and check GitHub directly
|
||||||
|
*/
|
||||||
|
async checkForUpdates(force = false) {
|
||||||
|
try {
|
||||||
|
const url = force ? '/updater/check?force=true' : '/updater/check';
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.show_notification) {
|
||||||
|
this._updateData = data;
|
||||||
|
this.showUpdateToast(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check for updates:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached update status without triggering a check
|
||||||
|
*/
|
||||||
|
async getStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/updater/status');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get update status:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show update toast notification
|
||||||
|
* @param {Object} data - Update data from server
|
||||||
|
*/
|
||||||
|
showUpdateToast(data) {
|
||||||
|
// Remove existing toast if present
|
||||||
|
this.hideToast();
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'update-toast';
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="update-toast-indicator"></div>
|
||||||
|
<div class="update-toast-content">
|
||||||
|
<div class="update-toast-header">
|
||||||
|
<span class="update-toast-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="update-toast-title">Update Available</span>
|
||||||
|
<button class="update-toast-close" onclick="Updater.dismissUpdate()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="update-toast-body">
|
||||||
|
Version <strong>${data.latest_version}</strong> is ready
|
||||||
|
</div>
|
||||||
|
<div class="update-toast-actions">
|
||||||
|
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
<button class="update-toast-btn update-toast-btn-secondary" onclick="Updater.hideToast()">
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
if (container) {
|
||||||
|
container.appendChild(toast);
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._toastElement = toast;
|
||||||
|
|
||||||
|
// Trigger animation
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toast.classList.add('show');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the update toast
|
||||||
|
*/
|
||||||
|
hideToast() {
|
||||||
|
if (this._toastElement) {
|
||||||
|
this._toastElement.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._toastElement && this._toastElement.parentNode) {
|
||||||
|
this._toastElement.parentNode.removeChild(this._toastElement);
|
||||||
|
}
|
||||||
|
this._toastElement = null;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss update notification for this version
|
||||||
|
*/
|
||||||
|
async dismissUpdate() {
|
||||||
|
this.hideToast();
|
||||||
|
|
||||||
|
if (this._updateData && this._updateData.latest_version) {
|
||||||
|
try {
|
||||||
|
await fetch('/updater/dismiss', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ version: this._updateData.latest_version })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to dismiss update:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the full update modal with details
|
||||||
|
*/
|
||||||
|
showUpdateModal() {
|
||||||
|
this.hideToast();
|
||||||
|
|
||||||
|
if (!this._updateData) {
|
||||||
|
console.warn('No update data available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing modal if present
|
||||||
|
this.hideModal();
|
||||||
|
|
||||||
|
const data = this._updateData;
|
||||||
|
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'update-modal-overlay';
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) this.hideModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="update-modal">
|
||||||
|
<div class="update-modal-header">
|
||||||
|
<div class="update-modal-title">
|
||||||
|
<span class="update-modal-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Update Available
|
||||||
|
</div>
|
||||||
|
<button class="update-modal-close" onclick="Updater.hideModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="update-modal-body">
|
||||||
|
<div class="update-version-info">
|
||||||
|
<div class="update-version-current">
|
||||||
|
<span class="update-version-label">Current</span>
|
||||||
|
<span class="update-version-value">v${data.current_version}</span>
|
||||||
|
</div>
|
||||||
|
<div class="update-version-arrow">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
<polyline points="12 5 19 12 12 19"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="update-version-latest">
|
||||||
|
<span class="update-version-label">Latest</span>
|
||||||
|
<span class="update-version-value update-version-new">v${data.latest_version}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="update-section">
|
||||||
|
<div class="update-section-title">Release Notes</div>
|
||||||
|
<div class="update-release-notes">${releaseNotes}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="update-warning" id="updateWarning" style="display: none;">
|
||||||
|
<div class="update-warning-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="update-warning-text">
|
||||||
|
<strong>Local changes detected</strong>
|
||||||
|
<p id="updateWarningText"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="update-options" id="updateOptions" style="display: none;">
|
||||||
|
<label class="update-option">
|
||||||
|
<input type="checkbox" id="stashChanges">
|
||||||
|
<span>Stash local changes before updating</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="update-progress" id="updateProgress" style="display: none;">
|
||||||
|
<div class="update-progress-spinner"></div>
|
||||||
|
<span id="updateProgressText">Updating...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="update-result" id="updateResult" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="update-modal-footer">
|
||||||
|
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
|
||||||
|
View on GitHub
|
||||||
|
<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"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="update-modal-actions">
|
||||||
|
<button class="update-modal-btn update-modal-btn-secondary" onclick="Updater.hideModal()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="update-modal-btn update-modal-btn-primary" id="updateNowBtn" onclick="Updater.performUpdate()">
|
||||||
|
Update Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
this._modalElement = modal;
|
||||||
|
|
||||||
|
// Trigger animation
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
modal.classList.add('show');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the update modal
|
||||||
|
*/
|
||||||
|
hideModal() {
|
||||||
|
if (this._modalElement) {
|
||||||
|
this._modalElement.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._modalElement && this._modalElement.parentNode) {
|
||||||
|
this._modalElement.parentNode.removeChild(this._modalElement);
|
||||||
|
}
|
||||||
|
this._modalElement = null;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the update
|
||||||
|
*/
|
||||||
|
async performUpdate() {
|
||||||
|
const progressEl = document.getElementById('updateProgress');
|
||||||
|
const progressText = document.getElementById('updateProgressText');
|
||||||
|
const resultEl = document.getElementById('updateResult');
|
||||||
|
const updateBtn = document.getElementById('updateNowBtn');
|
||||||
|
const warningEl = document.getElementById('updateWarning');
|
||||||
|
const optionsEl = document.getElementById('updateOptions');
|
||||||
|
const stashCheckbox = document.getElementById('stashChanges');
|
||||||
|
|
||||||
|
// Show progress
|
||||||
|
if (progressEl) progressEl.style.display = 'flex';
|
||||||
|
if (progressText) progressText.textContent = 'Checking repository status...';
|
||||||
|
if (updateBtn) updateBtn.disabled = true;
|
||||||
|
if (resultEl) resultEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stashChanges = stashCheckbox ? stashCheckbox.checked : false;
|
||||||
|
|
||||||
|
if (progressText) progressText.textContent = 'Fetching and applying updates...';
|
||||||
|
|
||||||
|
const response = await fetch('/updater/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stash_changes: stashChanges })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (progressEl) progressEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this._showResult(resultEl, true, data);
|
||||||
|
} else {
|
||||||
|
// Handle specific error cases
|
||||||
|
if (data.error === 'local_changes') {
|
||||||
|
if (warningEl) {
|
||||||
|
warningEl.style.display = 'flex';
|
||||||
|
const warningText = document.getElementById('updateWarningText');
|
||||||
|
if (warningText) {
|
||||||
|
warningText.textContent = data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (optionsEl) optionsEl.style.display = 'block';
|
||||||
|
if (updateBtn) updateBtn.disabled = false;
|
||||||
|
} else if (data.manual_update) {
|
||||||
|
this._showResult(resultEl, false, data, true);
|
||||||
|
} else {
|
||||||
|
this._showResult(resultEl, false, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (progressEl) progressEl.style.display = 'none';
|
||||||
|
this._showResult(resultEl, false, { error: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show update result
|
||||||
|
*/
|
||||||
|
_showResult(resultEl, success, data, isManual = false) {
|
||||||
|
if (!resultEl) return;
|
||||||
|
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
if (data.updated) {
|
||||||
|
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
|
||||||
|
|
||||||
|
if (data.requirements_changed) {
|
||||||
|
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
|
||||||
|
}
|
||||||
|
|
||||||
|
resultEl.className = 'update-result update-result-success';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="update-result-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="update-result-text">${message}</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
resultEl.className = 'update-result update-result-info';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="update-result-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isManual) {
|
||||||
|
resultEl.className = 'update-result update-result-warning';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="update-result-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="update-result-text">
|
||||||
|
<strong>Manual update required</strong><br>
|
||||||
|
${data.message || 'Please download the latest release from GitHub.'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
resultEl.className = 'update-result update-result-error';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="update-result-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="update-result-text">
|
||||||
|
<strong>Update failed</strong><br>
|
||||||
|
${data.message || data.error || 'An error occurred during the update.'}
|
||||||
|
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format release notes (basic markdown to HTML)
|
||||||
|
*/
|
||||||
|
_formatReleaseNotes(notes) {
|
||||||
|
if (!notes) return '<p>No release notes available.</p>';
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
let html = notes
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Convert markdown-style formatting
|
||||||
|
html = html
|
||||||
|
// Headers
|
||||||
|
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
// Bold
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
// Italic
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
// Code
|
||||||
|
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||||
|
// Lists
|
||||||
|
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||||
|
.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>')
|
||||||
|
// Paragraphs
|
||||||
|
.replace(/\n\n/g, '</p><p>')
|
||||||
|
// Line breaks
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
// Wrap list items
|
||||||
|
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
|
||||||
|
|
||||||
|
return '<p>' + html + '</p>';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual trigger for settings panel
|
||||||
|
*/
|
||||||
|
async checkNow() {
|
||||||
|
return await this.checkForUpdates(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up on page unload
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
if (this._checkInterval) {
|
||||||
|
clearInterval(this._checkInterval);
|
||||||
|
this._checkInterval = null;
|
||||||
|
}
|
||||||
|
this.hideToast();
|
||||||
|
this.hideModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
Updater.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
Updater.destroy();
|
||||||
|
});
|
||||||
+2951
-2513
File diff suppressed because it is too large
Load Diff
+1140
-23
File diff suppressed because it is too large
Load Diff
@@ -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: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span>
|
<span style="font-family: 'Space Mono', monospace; font-size: 10px;">${m}</span>
|
||||||
</label>
|
</label>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,977 @@
|
|||||||
|
/**
|
||||||
|
* SSTV Mode
|
||||||
|
* ISS Slow-Scan Television decoder interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SSTV = (function() {
|
||||||
|
// State
|
||||||
|
let isRunning = false;
|
||||||
|
let eventSource = null;
|
||||||
|
let images = [];
|
||||||
|
let currentMode = null;
|
||||||
|
let progress = 0;
|
||||||
|
let issMap = null;
|
||||||
|
let issMarker = null;
|
||||||
|
let issTrackLine = null;
|
||||||
|
let issPosition = null;
|
||||||
|
let issUpdateInterval = null;
|
||||||
|
let countdownInterval = null;
|
||||||
|
let nextPassData = null;
|
||||||
|
|
||||||
|
// ISS frequency
|
||||||
|
const ISS_FREQ = 145.800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the SSTV mode
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
checkStatus();
|
||||||
|
loadImages();
|
||||||
|
loadLocationInputs();
|
||||||
|
loadIssSchedule();
|
||||||
|
initMap();
|
||||||
|
startIssTracking();
|
||||||
|
startCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load location into input fields
|
||||||
|
*/
|
||||||
|
function loadLocationInputs() {
|
||||||
|
const latInput = document.getElementById('sstvObsLat');
|
||||||
|
const lonInput = document.getElementById('sstvObsLon');
|
||||||
|
|
||||||
|
let storedLat = localStorage.getItem('observerLat');
|
||||||
|
let storedLon = localStorage.getItem('observerLon');
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
storedLat = shared.lat.toString();
|
||||||
|
storedLon = shared.lon.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latInput && storedLat) latInput.value = storedLat;
|
||||||
|
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||||
|
|
||||||
|
// Add change handlers to save and refresh
|
||||||
|
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||||
|
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save location from input fields
|
||||||
|
*/
|
||||||
|
function saveLocationFromInputs() {
|
||||||
|
const latInput = document.getElementById('sstvObsLat');
|
||||||
|
const lonInput = document.getElementById('sstvObsLon');
|
||||||
|
|
||||||
|
const lat = parseFloat(latInput?.value);
|
||||||
|
const lon = parseFloat(lonInput?.value);
|
||||||
|
|
||||||
|
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
|
||||||
|
!isNaN(lon) && lon >= -180 && lon <= 180) {
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
ObserverLocation.setShared({ lat, lon });
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('observerLat', lat.toString());
|
||||||
|
localStorage.setItem('observerLon', lon.toString());
|
||||||
|
}
|
||||||
|
loadIssSchedule(); // Refresh pass predictions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use GPS to get location
|
||||||
|
*/
|
||||||
|
function useGPS(btn) {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
showNotification('SSTV', 'GPS not available in this browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span style="opacity: 0.7;">...</span>';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const latInput = document.getElementById('sstvObsLat');
|
||||||
|
const lonInput = document.getElementById('sstvObsLon');
|
||||||
|
|
||||||
|
const lat = pos.coords.latitude.toFixed(4);
|
||||||
|
const lon = pos.coords.longitude.toFixed(4);
|
||||||
|
|
||||||
|
if (latInput) latInput.value = lat;
|
||||||
|
if (lonInput) lonInput.value = lon;
|
||||||
|
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('observerLat', lat);
|
||||||
|
localStorage.setItem('observerLon', lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
|
||||||
|
showNotification('SSTV', 'Location updated from GPS');
|
||||||
|
loadIssSchedule();
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
|
||||||
|
let msg = 'Failed to get location';
|
||||||
|
if (err.code === 1) msg = 'Location access denied';
|
||||||
|
else if (err.code === 2) msg = 'Location unavailable';
|
||||||
|
showNotification('SSTV', msg);
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update TLE data from CelesTrak
|
||||||
|
*/
|
||||||
|
async function updateTLE(btn) {
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span style="opacity: 0.7;">Updating...</span>';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/satellite/update-tle', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
showNotification('SSTV', `TLE updated: ${data.updated?.length || 0} satellites`);
|
||||||
|
loadIssSchedule(); // Refresh predictions with new TLE
|
||||||
|
} else {
|
||||||
|
showNotification('SSTV', data.message || 'TLE update failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('TLE update error:', err);
|
||||||
|
showNotification('SSTV', 'Failed to update TLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Leaflet map for ISS tracking
|
||||||
|
*/
|
||||||
|
async function initMap() {
|
||||||
|
const mapContainer = document.getElementById('sstvIssMap');
|
||||||
|
if (!mapContainer || issMap) return;
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
issMap = L.map('sstvIssMap', {
|
||||||
|
center: [0, 0],
|
||||||
|
zoom: 1,
|
||||||
|
minZoom: 1,
|
||||||
|
maxZoom: 6,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: false,
|
||||||
|
worldCopyJump: true
|
||||||
|
});
|
||||||
|
window.issMap = issMap;
|
||||||
|
|
||||||
|
// Add tile layer using settings manager if available
|
||||||
|
if (typeof Settings !== 'undefined') {
|
||||||
|
// Wait for settings to load from server before applying tiles
|
||||||
|
await Settings.init();
|
||||||
|
Settings.createTileLayer().addTo(issMap);
|
||||||
|
Settings.registerMap(issMap);
|
||||||
|
} else {
|
||||||
|
// Fallback to dark theme tiles
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(issMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ISS icon
|
||||||
|
const issIcon = L.divIcon({
|
||||||
|
className: 'sstv-iss-marker',
|
||||||
|
html: `<div class="sstv-iss-dot"></div><div class="sstv-iss-label">ISS</div>`,
|
||||||
|
iconSize: [40, 40],
|
||||||
|
iconAnchor: [20, 20]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create ISS marker (will be positioned when we get data)
|
||||||
|
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
||||||
|
|
||||||
|
// Create ground track line
|
||||||
|
issTrackLine = L.polyline([], {
|
||||||
|
color: '#00d4ff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.6,
|
||||||
|
dashArray: '5, 5'
|
||||||
|
}).addTo(issMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start ISS position tracking
|
||||||
|
*/
|
||||||
|
function startIssTracking() {
|
||||||
|
updateIssPosition();
|
||||||
|
// Update every 5 seconds
|
||||||
|
if (issUpdateInterval) clearInterval(issUpdateInterval);
|
||||||
|
issUpdateInterval = setInterval(updateIssPosition, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop ISS tracking
|
||||||
|
*/
|
||||||
|
function stopIssTracking() {
|
||||||
|
if (issUpdateInterval) {
|
||||||
|
clearInterval(issUpdateInterval);
|
||||||
|
issUpdateInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start countdown timer
|
||||||
|
*/
|
||||||
|
function startCountdown() {
|
||||||
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
|
countdownInterval = setInterval(updateCountdown, 1000);
|
||||||
|
updateCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop countdown timer
|
||||||
|
*/
|
||||||
|
function stopCountdown() {
|
||||||
|
if (countdownInterval) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
countdownInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update countdown display
|
||||||
|
*/
|
||||||
|
function updateCountdown() {
|
||||||
|
const valueEl = document.getElementById('sstvCountdownValue');
|
||||||
|
const labelEl = document.getElementById('sstvCountdownLabel');
|
||||||
|
const statusEl = document.getElementById('sstvCountdownStatus');
|
||||||
|
|
||||||
|
if (!nextPassData || !nextPassData.startTimestamp) {
|
||||||
|
if (valueEl) {
|
||||||
|
valueEl.textContent = '--:--:--';
|
||||||
|
valueEl.className = 'sstv-countdown-value';
|
||||||
|
}
|
||||||
|
if (labelEl) {
|
||||||
|
const hasLocation = localStorage.getItem('observerLat') !== null;
|
||||||
|
labelEl.textContent = hasLocation ? 'No passes in 48h' : 'Set location';
|
||||||
|
}
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.className = 'sstv-countdown-status';
|
||||||
|
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>Waiting for pass data...</span>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const startTime = nextPassData.startTimestamp;
|
||||||
|
const endTime = nextPassData.endTimestamp || (startTime + (nextPassData.durationMinutes || 10) * 60 * 1000);
|
||||||
|
const diff = startTime - now;
|
||||||
|
|
||||||
|
if (now >= startTime && now < endTime) {
|
||||||
|
// Pass is currently active
|
||||||
|
const remaining = endTime - now;
|
||||||
|
const mins = Math.floor(remaining / 60000);
|
||||||
|
const secs = Math.floor((remaining % 60000) / 1000);
|
||||||
|
|
||||||
|
if (valueEl) {
|
||||||
|
valueEl.textContent = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
valueEl.className = 'sstv-countdown-value active';
|
||||||
|
}
|
||||||
|
if (labelEl) labelEl.textContent = 'Pass in progress!';
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.className = 'sstv-countdown-status active';
|
||||||
|
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>ISS overhead now!</span>';
|
||||||
|
}
|
||||||
|
} else if (diff > 0) {
|
||||||
|
// Countdown to next pass
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const mins = Math.floor((diff % 3600000) / 60000);
|
||||||
|
const secs = Math.floor((diff % 60000) / 1000);
|
||||||
|
|
||||||
|
if (valueEl) {
|
||||||
|
if (hours > 0) {
|
||||||
|
valueEl.textContent = `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
} else {
|
||||||
|
valueEl.textContent = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight when pass is imminent (< 5 minutes)
|
||||||
|
if (diff < 300000) {
|
||||||
|
valueEl.className = 'sstv-countdown-value imminent';
|
||||||
|
} else {
|
||||||
|
valueEl.className = 'sstv-countdown-value';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelEl) {
|
||||||
|
if (diff < 60000) {
|
||||||
|
labelEl.textContent = 'Starting soon!';
|
||||||
|
} else if (diff < 300000) {
|
||||||
|
labelEl.textContent = 'Get ready!';
|
||||||
|
} else if (diff < 3600000) {
|
||||||
|
labelEl.textContent = 'Until next pass';
|
||||||
|
} else {
|
||||||
|
labelEl.textContent = 'Until next pass';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
if (diff < 300000) {
|
||||||
|
statusEl.className = 'sstv-countdown-status imminent';
|
||||||
|
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>Pass imminent!</span>';
|
||||||
|
} else {
|
||||||
|
statusEl.className = 'sstv-countdown-status has-pass';
|
||||||
|
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>Next pass scheduled</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pass has ended, need to refresh schedule
|
||||||
|
loadIssSchedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update countdown panel details
|
||||||
|
*/
|
||||||
|
function updateCountdownDetails(pass) {
|
||||||
|
const startEl = document.getElementById('sstvPassStart');
|
||||||
|
const maxElEl = document.getElementById('sstvPassMaxEl');
|
||||||
|
const durationEl = document.getElementById('sstvPassDuration');
|
||||||
|
const directionEl = document.getElementById('sstvPassDirection');
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
if (startEl) startEl.textContent = '--:--';
|
||||||
|
if (maxElEl) maxElEl.textContent = '--°';
|
||||||
|
if (durationEl) durationEl.textContent = '-- min';
|
||||||
|
if (directionEl) directionEl.textContent = '--';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startEl) startEl.textContent = pass.startTime || '--:--';
|
||||||
|
if (maxElEl) maxElEl.textContent = (pass.maxEl || '--') + '°';
|
||||||
|
if (durationEl) durationEl.textContent = (pass.duration || '--') + ' min';
|
||||||
|
if (directionEl) directionEl.textContent = pass.direction || (pass.azStart ? getDirection(pass.azStart) : '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get compass direction from azimuth
|
||||||
|
*/
|
||||||
|
function getDirection(azimuth) {
|
||||||
|
if (azimuth === undefined || azimuth === null) return '--';
|
||||||
|
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||||
|
const index = Math.round(azimuth / 22.5) % 16;
|
||||||
|
return directions[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current ISS position
|
||||||
|
*/
|
||||||
|
async function updateIssPosition() {
|
||||||
|
const storedLat = localStorage.getItem('observerLat') || '51.5074';
|
||||||
|
const storedLon = localStorage.getItem('observerLon') || '-0.1278';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/sstv/iss-position?latitude=${storedLat}&longitude=${storedLon}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
issPosition = data;
|
||||||
|
updateIssDisplay();
|
||||||
|
updateMap();
|
||||||
|
console.log('ISS position updated:', data.lat.toFixed(1), data.lon.toFixed(1));
|
||||||
|
} else {
|
||||||
|
console.warn('ISS position error:', data.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get ISS position:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ISS position display
|
||||||
|
*/
|
||||||
|
function updateIssDisplay() {
|
||||||
|
if (!issPosition) return;
|
||||||
|
|
||||||
|
const latEl = document.getElementById('sstvIssLat');
|
||||||
|
const lonEl = document.getElementById('sstvIssLon');
|
||||||
|
const altEl = document.getElementById('sstvIssAlt');
|
||||||
|
|
||||||
|
if (latEl) latEl.textContent = issPosition.lat.toFixed(1) + '°';
|
||||||
|
if (lonEl) lonEl.textContent = issPosition.lon.toFixed(1) + '°';
|
||||||
|
if (altEl) altEl.textContent = Math.round(issPosition.altitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update map with ISS position
|
||||||
|
*/
|
||||||
|
function updateMap() {
|
||||||
|
if (!issMap || !issPosition) return;
|
||||||
|
|
||||||
|
const lat = issPosition.lat;
|
||||||
|
const lon = issPosition.lon;
|
||||||
|
|
||||||
|
// Update marker position
|
||||||
|
if (issMarker) {
|
||||||
|
issMarker.setLatLng([lat, lon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate and draw ground track
|
||||||
|
if (issTrackLine) {
|
||||||
|
const trackPoints = [];
|
||||||
|
const inclination = 51.6; // ISS orbital inclination in degrees
|
||||||
|
|
||||||
|
// Generate orbit track points
|
||||||
|
for (let offset = -180; offset <= 180; offset += 3) {
|
||||||
|
let trackLon = lon + offset;
|
||||||
|
|
||||||
|
// Normalize longitude
|
||||||
|
while (trackLon > 180) trackLon -= 360;
|
||||||
|
while (trackLon < -180) trackLon += 360;
|
||||||
|
|
||||||
|
// Calculate latitude based on orbital inclination
|
||||||
|
const phase = (offset / 360) * 2 * Math.PI;
|
||||||
|
const currentPhase = Math.asin(Math.max(-1, Math.min(1, lat / inclination)));
|
||||||
|
let trackLat = inclination * Math.sin(phase + currentPhase);
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
trackLat = Math.max(-inclination, Math.min(inclination, trackLat));
|
||||||
|
|
||||||
|
trackPoints.push([trackLat, trackLon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split track at antimeridian to avoid line across map
|
||||||
|
const segments = [];
|
||||||
|
let currentSegment = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < trackPoints.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
const prevLon = trackPoints[i - 1][1];
|
||||||
|
const currLon = trackPoints[i][1];
|
||||||
|
if (Math.abs(currLon - prevLon) > 180) {
|
||||||
|
// Crossed antimeridian
|
||||||
|
if (currentSegment.length > 0) {
|
||||||
|
segments.push(currentSegment);
|
||||||
|
}
|
||||||
|
currentSegment = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSegment.push(trackPoints[i]);
|
||||||
|
}
|
||||||
|
if (currentSegment.length > 0) {
|
||||||
|
segments.push(currentSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use only the longest segment or combine if needed
|
||||||
|
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pan map to follow ISS
|
||||||
|
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check current decoder status
|
||||||
|
*/
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/sstv/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.available) {
|
||||||
|
updateStatusUI('unavailable', 'Decoder not installed');
|
||||||
|
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.running) {
|
||||||
|
isRunning = true;
|
||||||
|
updateStatusUI('listening', 'Listening...');
|
||||||
|
startStream();
|
||||||
|
} else {
|
||||||
|
updateStatusUI('idle', 'Idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update image count
|
||||||
|
updateImageCount(data.image_count || 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check SSTV status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start SSTV decoder
|
||||||
|
*/
|
||||||
|
async function start() {
|
||||||
|
const freqInput = document.getElementById('sstvFrequency');
|
||||||
|
// Use the global SDR device selector
|
||||||
|
const deviceSelect = document.getElementById('deviceSelect');
|
||||||
|
|
||||||
|
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
|
||||||
|
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||||
|
|
||||||
|
updateStatusUI('connecting', 'Starting...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/sstv/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ frequency, device })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'started' || data.status === 'already_running') {
|
||||||
|
isRunning = true;
|
||||||
|
updateStatusUI('listening', `${frequency} MHz`);
|
||||||
|
startStream();
|
||||||
|
showNotification('SSTV', `Listening on ${frequency} MHz`);
|
||||||
|
} else {
|
||||||
|
updateStatusUI('idle', 'Start failed');
|
||||||
|
showStatusMessage(data.message || 'Failed to start decoder', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start SSTV:', err);
|
||||||
|
updateStatusUI('idle', 'Error');
|
||||||
|
showStatusMessage('Connection error: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop SSTV decoder
|
||||||
|
*/
|
||||||
|
async function stop() {
|
||||||
|
try {
|
||||||
|
await fetch('/sstv/stop', { method: 'POST' });
|
||||||
|
isRunning = false;
|
||||||
|
stopStream();
|
||||||
|
updateStatusUI('idle', 'Stopped');
|
||||||
|
showNotification('SSTV', 'Decoder stopped');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to stop SSTV:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update status UI elements
|
||||||
|
*/
|
||||||
|
function updateStatusUI(status, text) {
|
||||||
|
const dot = document.getElementById('sstvStripDot');
|
||||||
|
const statusText = document.getElementById('sstvStripStatus');
|
||||||
|
const startBtn = document.getElementById('sstvStartBtn');
|
||||||
|
const stopBtn = document.getElementById('sstvStopBtn');
|
||||||
|
|
||||||
|
if (dot) {
|
||||||
|
dot.className = 'sstv-strip-dot';
|
||||||
|
if (status === 'listening' || status === 'detecting') {
|
||||||
|
dot.classList.add('listening');
|
||||||
|
} else if (status === 'decoding') {
|
||||||
|
dot.classList.add('decoding');
|
||||||
|
} else {
|
||||||
|
dot.classList.add('idle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) {
|
||||||
|
statusText.textContent = text || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startBtn && stopBtn) {
|
||||||
|
if (status === 'listening' || status === 'decoding') {
|
||||||
|
startBtn.style.display = 'none';
|
||||||
|
stopBtn.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
startBtn.style.display = 'inline-block';
|
||||||
|
stopBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update live content area
|
||||||
|
const liveContent = document.getElementById('sstvLiveContent');
|
||||||
|
if (liveContent) {
|
||||||
|
if (status === 'idle' || status === 'unavailable') {
|
||||||
|
liveContent.innerHTML = renderIdleState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render idle state HTML
|
||||||
|
*/
|
||||||
|
function renderIdleState() {
|
||||||
|
return `
|
||||||
|
<div class="sstv-idle-state">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||||||
|
</svg>
|
||||||
|
<h4>ISS SSTV Decoder</h4>
|
||||||
|
<p>Click Start to listen for SSTV transmissions on 145.800 MHz</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start SSE stream
|
||||||
|
*/
|
||||||
|
function startStream() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource = new EventSource('/sstv/stream');
|
||||||
|
|
||||||
|
eventSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'sstv_progress') {
|
||||||
|
handleProgress(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse SSE message:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
console.warn('SSTV SSE error, will reconnect...');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isRunning) startStream();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop SSE stream
|
||||||
|
*/
|
||||||
|
function stopStream() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle progress update
|
||||||
|
*/
|
||||||
|
function handleProgress(data) {
|
||||||
|
currentMode = data.mode || currentMode;
|
||||||
|
progress = data.progress || 0;
|
||||||
|
|
||||||
|
// Update status based on decode state
|
||||||
|
if (data.status === 'decoding') {
|
||||||
|
updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`);
|
||||||
|
renderDecodeProgress(data);
|
||||||
|
} else if (data.status === 'complete' && data.image) {
|
||||||
|
// New image decoded
|
||||||
|
images.unshift(data.image);
|
||||||
|
updateImageCount(images.length);
|
||||||
|
renderGallery();
|
||||||
|
showNotification('SSTV', 'New image decoded!');
|
||||||
|
updateStatusUI('listening', 'Listening...');
|
||||||
|
} else if (data.status === 'detecting') {
|
||||||
|
updateStatusUI('listening', data.message || 'Listening...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render decode progress in live area
|
||||||
|
*/
|
||||||
|
function renderDecodeProgress(data) {
|
||||||
|
const liveContent = document.getElementById('sstvLiveContent');
|
||||||
|
if (!liveContent) return;
|
||||||
|
|
||||||
|
liveContent.innerHTML = `
|
||||||
|
<div class="sstv-canvas-container">
|
||||||
|
<canvas id="sstvCanvas" width="320" height="256"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-decode-info">
|
||||||
|
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
|
||||||
|
<div class="sstv-progress-bar">
|
||||||
|
<div class="progress" style="width: ${data.progress || 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load decoded images
|
||||||
|
*/
|
||||||
|
async function loadImages() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/sstv/images');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
images = data.images || [];
|
||||||
|
updateImageCount(images.length);
|
||||||
|
renderGallery();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load SSTV images:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update image count display
|
||||||
|
*/
|
||||||
|
function updateImageCount(count) {
|
||||||
|
const countEl = document.getElementById('sstvImageCount');
|
||||||
|
const stripCount = document.getElementById('sstvStripImageCount');
|
||||||
|
|
||||||
|
if (countEl) countEl.textContent = count;
|
||||||
|
if (stripCount) stripCount.textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render image gallery
|
||||||
|
*/
|
||||||
|
function renderGallery() {
|
||||||
|
const gallery = document.getElementById('sstvGallery');
|
||||||
|
if (!gallery) return;
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
gallery.innerHTML = `
|
||||||
|
<div class="sstv-gallery-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<p>No images decoded yet</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gallery.innerHTML = images.map(img => `
|
||||||
|
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
|
||||||
|
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
||||||
|
<div class="sstv-image-info">
|
||||||
|
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||||
|
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load ISS pass schedule
|
||||||
|
*/
|
||||||
|
async function loadIssSchedule() {
|
||||||
|
// Try to get user's location from settings
|
||||||
|
const storedLat = localStorage.getItem('observerLat');
|
||||||
|
const storedLon = localStorage.getItem('observerLon');
|
||||||
|
|
||||||
|
// Check if location is actually set
|
||||||
|
const hasLocation = storedLat !== null && storedLon !== null;
|
||||||
|
const lat = storedLat || 51.5074;
|
||||||
|
const lon = storedLon || -0.1278;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/sstv/iss-schedule?latitude=${lat}&longitude=${lon}&hours=48`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok' && data.passes && data.passes.length > 0) {
|
||||||
|
const pass = data.passes[0];
|
||||||
|
// Parse the pass data to get timestamps
|
||||||
|
nextPassData = parsePassData(pass);
|
||||||
|
updateCountdownDetails(pass);
|
||||||
|
updateCountdown();
|
||||||
|
} else {
|
||||||
|
nextPassData = null;
|
||||||
|
updateCountdownDetails(null);
|
||||||
|
updateCountdown();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load ISS schedule:', err);
|
||||||
|
nextPassData = null;
|
||||||
|
updateCountdownDetails(null);
|
||||||
|
updateCountdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse pass data to extract timestamps
|
||||||
|
*/
|
||||||
|
function parsePassData(pass) {
|
||||||
|
if (!pass) return null;
|
||||||
|
|
||||||
|
let startTimestamp = null;
|
||||||
|
let endTimestamp = null;
|
||||||
|
const durationMinutes = parseInt(pass.duration) || 10;
|
||||||
|
|
||||||
|
// Try to parse the startTime
|
||||||
|
if (pass.startTimestamp) {
|
||||||
|
// If timestamp is provided directly
|
||||||
|
startTimestamp = pass.startTimestamp;
|
||||||
|
} else if (pass.startTime) {
|
||||||
|
// Parse time string (format: "HH:MM" or "HH:MM:SS" or with date)
|
||||||
|
startTimestamp = parseTimeString(pass.startTime, pass.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTimestamp) {
|
||||||
|
endTimestamp = startTimestamp + durationMinutes * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTimestamp,
|
||||||
|
endTimestamp,
|
||||||
|
durationMinutes,
|
||||||
|
maxEl: pass.maxEl,
|
||||||
|
azStart: pass.azStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse time string to timestamp
|
||||||
|
*/
|
||||||
|
function parseTimeString(timeStr, dateStr) {
|
||||||
|
if (!timeStr) return null;
|
||||||
|
|
||||||
|
// Try to parse as a full datetime string first (e.g., "2026-01-30 03:01 UTC")
|
||||||
|
// Remove UTC suffix for parsing
|
||||||
|
const cleanedStr = timeStr.replace(' UTC', '').replace('UTC', '');
|
||||||
|
|
||||||
|
// Try full datetime parse
|
||||||
|
let parsed = new Date(cleanedStr);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with T separator (ISO format)
|
||||||
|
parsed = new Date(cleanedStr.replace(' ', 'T'));
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: parse as time only (HH:MM or HH:MM:SS)
|
||||||
|
const now = new Date();
|
||||||
|
let targetDate = new Date();
|
||||||
|
|
||||||
|
// If a date string is provided
|
||||||
|
if (dateStr) {
|
||||||
|
const parsedDate = new Date(dateStr);
|
||||||
|
if (!isNaN(parsedDate)) {
|
||||||
|
targetDate = parsedDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time (HH:MM or HH:MM:SS format)
|
||||||
|
const timeParts = cleanedStr.split(':');
|
||||||
|
if (timeParts.length >= 2) {
|
||||||
|
const hours = parseInt(timeParts[0]);
|
||||||
|
const minutes = parseInt(timeParts[1]);
|
||||||
|
const seconds = timeParts.length > 2 ? parseInt(timeParts[2]) : 0;
|
||||||
|
|
||||||
|
if (!isNaN(hours) && !isNaN(minutes)) {
|
||||||
|
targetDate.setHours(hours, minutes, seconds, 0);
|
||||||
|
|
||||||
|
// If the time is in the past, assume it's tomorrow
|
||||||
|
if (targetDate.getTime() < now.getTime() && !dateStr) {
|
||||||
|
targetDate.setDate(targetDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetDate.getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show full-size image in modal
|
||||||
|
*/
|
||||||
|
function showImage(url) {
|
||||||
|
let modal = document.getElementById('sstvImageModal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'sstvImageModal';
|
||||||
|
modal.className = 'sstv-image-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<button class="sstv-modal-close" onclick="SSTV.closeImage()">×</button>
|
||||||
|
<img src="" alt="SSTV Image">
|
||||||
|
`;
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) closeImage();
|
||||||
|
});
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.querySelector('img').src = url;
|
||||||
|
modal.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close image modal
|
||||||
|
*/
|
||||||
|
function closeImage() {
|
||||||
|
const modal = document.getElementById('sstvImageModal');
|
||||||
|
if (modal) modal.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp for display
|
||||||
|
*/
|
||||||
|
function formatTimestamp(isoString) {
|
||||||
|
if (!isoString) return '--';
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML for safe display
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show status message
|
||||||
|
*/
|
||||||
|
function showStatusMessage(message, type) {
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('SSTV', message);
|
||||||
|
} else {
|
||||||
|
console.log(`[SSTV ${type}] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
loadImages,
|
||||||
|
loadIssSchedule,
|
||||||
|
showImage,
|
||||||
|
closeImage,
|
||||||
|
useGPS,
|
||||||
|
updateTLE,
|
||||||
|
stopIssTracking,
|
||||||
|
stopCountdown
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Initialize when DOM is ready (will be called by selectMode)
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialization happens via selectMode when SSTV mode is activated
|
||||||
|
});
|
||||||
+236
-5
@@ -77,6 +77,7 @@ const WiFiMode = (function() {
|
|||||||
let scanMode = 'quick'; // 'quick' or 'deep'
|
let scanMode = 'quick'; // 'quick' or 'deep'
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
let agentPollTimer = null;
|
||||||
|
|
||||||
// Data stores
|
// Data stores
|
||||||
let networks = new Map(); // bssid -> network
|
let networks = new Map(); // bssid -> network
|
||||||
@@ -505,8 +506,13 @@ const WiFiMode = (function() {
|
|||||||
console.log('[WiFiMode] Agent deep scan started:', scanResult);
|
console.log('[WiFiMode] Agent deep scan started:', scanResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start SSE stream for real-time updates
|
// Start SSE stream for real-time updates (works with push-enabled agents)
|
||||||
startEventStream();
|
startEventStream();
|
||||||
|
|
||||||
|
// Also start polling for agent data (works without push enabled)
|
||||||
|
if (isAgentMode) {
|
||||||
|
startAgentDeepScanPolling();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WiFiMode] Deep scan error:', error);
|
console.error('[WiFiMode] Deep scan error:', error);
|
||||||
showError(error.message);
|
showError(error.message);
|
||||||
@@ -523,6 +529,9 @@ const WiFiMode = (function() {
|
|||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop agent polling
|
||||||
|
stopAgentDeepScanPolling();
|
||||||
|
|
||||||
// Close event stream
|
// Close event stream
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
@@ -584,9 +593,18 @@ const WiFiMode = (function() {
|
|||||||
const status = isAgentMode && data.result ? data.result : data;
|
const status = isAgentMode && data.result ? data.result : data;
|
||||||
|
|
||||||
if (status.is_scanning || status.running) {
|
if (status.is_scanning || status.running) {
|
||||||
setScanning(true, status.scan_mode);
|
// Agent returns scan_type in params, local returns scan_mode
|
||||||
if (status.scan_mode === 'deep') {
|
// Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick'
|
||||||
|
let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep';
|
||||||
|
if (detectedMode === 'deepscan') detectedMode = 'deep';
|
||||||
|
|
||||||
|
setScanning(true, detectedMode);
|
||||||
|
if (detectedMode === 'deep') {
|
||||||
startEventStream();
|
startEventStream();
|
||||||
|
// Also start polling for agent mode (works without push enabled)
|
||||||
|
if (isAgentMode) {
|
||||||
|
startAgentDeepScanPolling();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
startQuickScanPolling();
|
startQuickScanPolling();
|
||||||
}
|
}
|
||||||
@@ -655,6 +673,76 @@ const WiFiMode = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Agent Deep Scan Polling (fallback when push is not enabled)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
function startAgentDeepScanPolling() {
|
||||||
|
if (agentPollTimer) return;
|
||||||
|
|
||||||
|
console.log('[WiFiMode] Starting agent deep scan polling...');
|
||||||
|
|
||||||
|
agentPollTimer = setInterval(async () => {
|
||||||
|
if (!isScanning || scanMode !== 'deep') {
|
||||||
|
clearInterval(agentPollTimer);
|
||||||
|
agentPollTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
if (!isAgentMode) {
|
||||||
|
clearInterval(agentPollTimer);
|
||||||
|
agentPollTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.status !== 'success' || !result.data) return;
|
||||||
|
|
||||||
|
const data = result.data.data || result.data;
|
||||||
|
const agentName = result.agent_name || 'Remote';
|
||||||
|
|
||||||
|
// Process networks
|
||||||
|
if (data.networks && Array.isArray(data.networks)) {
|
||||||
|
data.networks.forEach(net => {
|
||||||
|
net._agent = agentName;
|
||||||
|
handleStreamEvent({
|
||||||
|
type: 'network_update',
|
||||||
|
network: net
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process clients
|
||||||
|
if (data.clients && Array.isArray(data.clients)) {
|
||||||
|
data.clients.forEach(client => {
|
||||||
|
client._agent = agentName;
|
||||||
|
handleStreamEvent({
|
||||||
|
type: 'client_update',
|
||||||
|
client: client
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[WiFiMode] Agent poll error:', error);
|
||||||
|
}
|
||||||
|
}, 2000); // Poll every 2 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAgentDeepScanPolling() {
|
||||||
|
if (agentPollTimer) {
|
||||||
|
clearInterval(agentPollTimer);
|
||||||
|
agentPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// SSE Event Stream
|
// SSE Event Stream
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -799,6 +887,9 @@ const WiFiMode = (function() {
|
|||||||
clients.set(client.mac, client);
|
clients.set(client.mac, client);
|
||||||
updateStats();
|
updateStats();
|
||||||
|
|
||||||
|
// Update client display if this client belongs to the selected network
|
||||||
|
updateClientInList(client);
|
||||||
|
|
||||||
if (onClientUpdate) onClientUpdate(client);
|
if (onClientUpdate) onClientUpdate(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,6 +1138,9 @@ const WiFiMode = (function() {
|
|||||||
|
|
||||||
// Show the drawer
|
// Show the drawer
|
||||||
elements.detailDrawer.classList.add('open');
|
elements.detailDrawer.classList.add('open');
|
||||||
|
|
||||||
|
// Fetch and display clients for this network
|
||||||
|
fetchClientsForNetwork(network.bssid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
@@ -1059,6 +1153,130 @@ const WiFiMode = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Client Display
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
async function fetchClientsForNetwork(bssid) {
|
||||||
|
if (!elements.detailClientList) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (isAgentMode) {
|
||||||
|
// Route through agent proxy
|
||||||
|
response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||||
|
} else {
|
||||||
|
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Hide client list on error
|
||||||
|
elements.detailClientList.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle agent response format (may be nested in 'result')
|
||||||
|
const result = isAgentMode && data.result ? data.result : data;
|
||||||
|
const clientList = result.clients || [];
|
||||||
|
|
||||||
|
if (clientList.length > 0) {
|
||||||
|
renderClientList(clientList, bssid);
|
||||||
|
elements.detailClientList.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
elements.detailClientList.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[WiFiMode] Error fetching clients:', error);
|
||||||
|
elements.detailClientList.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClientList(clientList, bssid) {
|
||||||
|
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
||||||
|
const countBadge = document.getElementById('wifiClientCountBadge');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Update count badge
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.textContent = clientList.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render client cards
|
||||||
|
container.innerHTML = clientList.map(client => {
|
||||||
|
const rssi = client.rssi_current;
|
||||||
|
const signalClass = rssi >= -50 ? 'signal-strong' :
|
||||||
|
rssi >= -70 ? 'signal-medium' :
|
||||||
|
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
||||||
|
|
||||||
|
// Format last seen time
|
||||||
|
const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--';
|
||||||
|
|
||||||
|
// Build probed SSIDs badges
|
||||||
|
let probesHtml = '';
|
||||||
|
if (client.probed_ssids && client.probed_ssids.length > 0) {
|
||||||
|
const probes = client.probed_ssids.slice(0, 5); // Show max 5
|
||||||
|
probesHtml = `
|
||||||
|
<div class="wifi-client-probes">
|
||||||
|
${probes.map(ssid => `<span class="wifi-client-probe-badge">${escapeHtml(ssid)}</span>`).join('')}
|
||||||
|
${client.probed_ssids.length > 5 ? `<span class="wifi-client-probe-badge">+${client.probed_ssids.length - 5}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="wifi-client-card" data-mac="${escapeHtml(client.mac)}">
|
||||||
|
<div class="wifi-client-identity">
|
||||||
|
<span class="wifi-client-mac">${escapeHtml(client.mac)}</span>
|
||||||
|
<span class="wifi-client-vendor">${escapeHtml(client.vendor || 'Unknown vendor')}</span>
|
||||||
|
${probesHtml}
|
||||||
|
</div>
|
||||||
|
<div class="wifi-client-signal">
|
||||||
|
<span class="wifi-client-rssi ${signalClass}">${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'}</span>
|
||||||
|
<span class="wifi-client-lastseen">${lastSeen}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClientInList(client) {
|
||||||
|
// Check if this client belongs to the currently selected network
|
||||||
|
if (!selectedNetwork || client.associated_bssid !== selectedNetwork) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const existingCard = container.querySelector(`[data-mac="${client.mac}"]`);
|
||||||
|
|
||||||
|
if (existingCard) {
|
||||||
|
// Update existing card's RSSI and last seen
|
||||||
|
const rssiEl = existingCard.querySelector('.wifi-client-rssi');
|
||||||
|
const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen');
|
||||||
|
|
||||||
|
if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) {
|
||||||
|
const rssi = client.rssi_current;
|
||||||
|
const signalClass = rssi >= -50 ? 'signal-strong' :
|
||||||
|
rssi >= -70 ? 'signal-medium' :
|
||||||
|
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
||||||
|
rssiEl.textContent = rssi + ' dBm';
|
||||||
|
rssiEl.className = 'wifi-client-rssi ' + signalClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSeenEl && client.last_seen) {
|
||||||
|
lastSeenEl.textContent = formatTime(client.last_seen);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New client for this network - re-fetch the full list
|
||||||
|
fetchClientsForNetwork(selectedNetwork);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Statistics
|
// Statistics
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -1292,9 +1510,19 @@ const WiFiMode = (function() {
|
|||||||
|
|
||||||
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
|
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
|
||||||
|
|
||||||
// Stop any running scan
|
// Stop UI polling only - don't stop the actual scan on the agent
|
||||||
|
// The agent should continue running independently
|
||||||
if (isScanning) {
|
if (isScanning) {
|
||||||
stopScan();
|
stopAgentDeepScanPolling();
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
setScanning(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing data when switching agents (unless "Show All" is enabled)
|
// Clear existing data when switching agents (unless "Show All" is enabled)
|
||||||
@@ -1306,6 +1534,9 @@ const WiFiMode = (function() {
|
|||||||
// Refresh capabilities for new agent
|
// Refresh capabilities for new agent
|
||||||
checkCapabilities();
|
checkCapabilities();
|
||||||
|
|
||||||
|
// Check if new agent already has a scan running
|
||||||
|
checkScanStatus();
|
||||||
|
|
||||||
lastAgentId = currentAgentId;
|
lastAgentId = currentAgentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4916
-4821
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,15 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ADS-B History // INTERCEPT</title>
|
<title>ADS-B History // INTERCEPT</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -22,6 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% set active_mode = 'adsb' %}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<main class="history-shell">
|
<main class="history-shell">
|
||||||
<section class="summary-strip">
|
<section class="summary-strip">
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
@@ -240,6 +250,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Bias-T helper (reads from main dashboard localStorage)
|
||||||
|
function getBiasTEnabled() {
|
||||||
|
return localStorage.getItem('biasTEnabled') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
const historyEnabled = {{ 'true' if history_enabled else 'false' }};
|
const historyEnabled = {{ 'true' if history_enabled else 'false' }};
|
||||||
|
|
||||||
const summaryMessages = document.getElementById('summaryMessages');
|
const summaryMessages = document.getElementById('summaryMessages');
|
||||||
@@ -457,7 +472,7 @@
|
|||||||
|
|
||||||
if (!points.length) {
|
if (!points.length) {
|
||||||
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||||
ctx.font = '12px "JetBrains Mono", monospace';
|
ctx.font = '12px "Space Mono", monospace';
|
||||||
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -465,7 +480,7 @@
|
|||||||
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
|
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
|
||||||
if (!series.length) {
|
if (!series.length) {
|
||||||
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||||
ctx.font = '12px "JetBrains Mono", monospace';
|
ctx.font = '12px "Space Mono", monospace';
|
||||||
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -506,7 +521,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
|
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
|
||||||
ctx.font = '11px "JetBrains Mono", monospace';
|
ctx.font = '11px "Space Mono", monospace';
|
||||||
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
|
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
|
||||||
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
|
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
|
||||||
}
|
}
|
||||||
@@ -553,11 +568,9 @@
|
|||||||
}
|
}
|
||||||
devices.forEach((dev, idx) => {
|
devices.forEach((dev, idx) => {
|
||||||
const index = dev.index !== undefined ? dev.index : idx;
|
const index = dev.index !== undefined ? dev.index : idx;
|
||||||
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
|
|
||||||
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
|
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = index;
|
opt.value = index;
|
||||||
opt.textContent = `${type} #${index}${serial}`;
|
opt.textContent = `SDR ${index}: ${dev.name}`;
|
||||||
sessionDeviceSelect.appendChild(opt);
|
sessionDeviceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
sessionDeviceSelect.disabled = false;
|
sessionDeviceSelect.disabled = false;
|
||||||
@@ -632,7 +645,7 @@
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ device, source: 'adsb_history' })
|
body: JSON.stringify({ device, source: 'adsb_history', bias_t: getBiasTEnabled() })
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
sessionNotice.textContent = 'Start failed';
|
sessionNotice.textContent = 'Start failed';
|
||||||
@@ -758,5 +771,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
{% include 'partials/settings-modal.html' %}
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+568
-571
File diff suppressed because it is too large
Load Diff
+178
-28
@@ -8,7 +8,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
@@ -18,8 +18,17 @@
|
|||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<!-- Core CSS variables -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
|
<script>
|
||||||
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Radar background effects -->
|
<!-- Radar background effects -->
|
||||||
@@ -42,11 +51,12 @@
|
|||||||
<input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All
|
<input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
|
||||||
<a href="/" class="back-link">Main Dashboard</a>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% set active_mode = 'ais' %}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
|
||||||
<div class="stats-strip">
|
<div class="stats-strip">
|
||||||
<div class="stats-strip-inner">
|
<div class="stats-strip-inner">
|
||||||
<div class="strip-stat">
|
<div class="strip-stat">
|
||||||
@@ -84,6 +94,7 @@
|
|||||||
<span id="trackingStatus">STANDBY</span>
|
<span id="trackingStatus">STANDBY</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
||||||
|
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,6 +200,11 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Bias-T helper (reads from main dashboard localStorage)
|
||||||
|
function getBiasTEnabled() {
|
||||||
|
return localStorage.getItem('biasTEnabled') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let vesselMap = null;
|
let vesselMap = null;
|
||||||
let vessels = {};
|
let vessels = {};
|
||||||
@@ -213,10 +229,20 @@
|
|||||||
const MAX_TRAIL_POINTS = 50;
|
const MAX_TRAIL_POINTS = 50;
|
||||||
|
|
||||||
// Observer location
|
// Observer location
|
||||||
let observerLocation = { lat: 51.5074, lon: -0.1278 };
|
let observerLocation = (function() {
|
||||||
|
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||||||
|
return ObserverLocation.getForModule('ais_observerLocation');
|
||||||
|
}
|
||||||
|
return { lat: 51.5074, lon: -0.1278 };
|
||||||
|
})();
|
||||||
let rangeRingsLayer = null;
|
let rangeRingsLayer = null;
|
||||||
let observerMarker = null;
|
let observerMarker = null;
|
||||||
|
|
||||||
|
// GPS state
|
||||||
|
let gpsConnected = false;
|
||||||
|
let gpsEventSource = null;
|
||||||
|
let gpsReconnectTimeout = null;
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
let stats = {
|
let stats = {
|
||||||
totalVesselsSeen: new Set(),
|
totalVesselsSeen: new Set(),
|
||||||
@@ -323,10 +349,12 @@
|
|||||||
const size = 24;
|
const size = 24;
|
||||||
const glowColor = isSelected ? 'rgba(255,255,255,0.8)' : color;
|
const glowColor = isSelected ? 'rgba(255,255,255,0.8)' : color;
|
||||||
const glowSize = isSelected ? '8px' : '4px';
|
const glowSize = isSelected ? '8px' : '4px';
|
||||||
|
const trackingRing = isSelected ?
|
||||||
|
'<div class="tracking-ring"></div><div class="tracking-ring-inner"></div>' : '';
|
||||||
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'vessel-marker' + (isSelected ? ' selected' : ''),
|
className: 'vessel-marker' + (isSelected ? ' selected' : ''),
|
||||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 ${glowSize} ${glowColor});">
|
html: `${trackingRing}<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 ${glowSize} ${glowColor});">
|
||||||
<path fill="${color}" d="${path}"/>
|
<path fill="${color}" d="${path}"/>
|
||||||
</svg>`,
|
</svg>`,
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
@@ -362,18 +390,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
function initMap() {
|
async function initMap() {
|
||||||
// Load saved observer location
|
if (observerLocation) {
|
||||||
const saved = localStorage.getItem('ais_observerLocation');
|
document.getElementById('obsLat').value = observerLocation.lat;
|
||||||
if (saved) {
|
document.getElementById('obsLon').value = observerLocation.lon;
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (parsed.lat && parsed.lon) {
|
|
||||||
observerLocation = parsed;
|
|
||||||
document.getElementById('obsLat').value = parsed.lat;
|
|
||||||
document.getElementById('obsLon').value = parsed.lon;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vesselMap = L.map('vesselMap', {
|
vesselMap = L.map('vesselMap', {
|
||||||
@@ -382,11 +402,20 @@
|
|||||||
zoomControl: true
|
zoomControl: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// OpenStreetMap tile layer
|
// Use settings manager for tile layer (allows runtime changes)
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
window.vesselMap = vesselMap;
|
||||||
attribution: '© OpenStreetMap contributors',
|
if (typeof Settings !== 'undefined') {
|
||||||
maxZoom: 19
|
// Wait for settings to load from server before applying tiles
|
||||||
}).addTo(vesselMap);
|
await Settings.init();
|
||||||
|
Settings.createTileLayer().addTo(vesselMap);
|
||||||
|
Settings.registerMap(vesselMap);
|
||||||
|
} else {
|
||||||
|
L.tileLayer('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>',
|
||||||
|
maxZoom: 19,
|
||||||
|
subdomains: 'abcd'
|
||||||
|
}).addTo(vesselMap);
|
||||||
|
}
|
||||||
|
|
||||||
// Add observer marker
|
// Add observer marker
|
||||||
observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], {
|
observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], {
|
||||||
@@ -450,7 +479,11 @@
|
|||||||
const lon = parseFloat(document.getElementById('obsLon').value);
|
const lon = parseFloat(document.getElementById('obsLon').value);
|
||||||
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
||||||
observerLocation = { lat, lon };
|
observerLocation = { lat, lon };
|
||||||
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
|
if (window.ObserverLocation) {
|
||||||
|
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
|
||||||
|
}
|
||||||
if (observerMarker) {
|
if (observerMarker) {
|
||||||
observerMarker.setLatLng([lat, lon]);
|
observerMarker.setLatLng([lat, lon]);
|
||||||
}
|
}
|
||||||
@@ -527,7 +560,7 @@
|
|||||||
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device, gain })
|
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
@@ -552,7 +585,7 @@
|
|||||||
fetch('/ais/start', {
|
fetch('/ais/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device, gain })
|
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -948,6 +981,110 @@
|
|||||||
document.getElementById('utcTime').textContent = utc + ' UTC';
|
document.getElementById('utcTime').textContent = utc + ' UTC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GPS FUNCTIONS (gpsd auto-connect)
|
||||||
|
// ============================================
|
||||||
|
async function autoConnectGps() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'connected') {
|
||||||
|
gpsConnected = true;
|
||||||
|
startGpsStream();
|
||||||
|
showGpsIndicator(true);
|
||||||
|
console.log('GPS: Auto-connected to gpsd');
|
||||||
|
if (data.position) {
|
||||||
|
updateLocationFromGps(data.position);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('GPS: gpsd not available -', data.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('GPS: Auto-connect failed -', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGpsStream() {
|
||||||
|
if (gpsEventSource) {
|
||||||
|
gpsEventSource.close();
|
||||||
|
}
|
||||||
|
if (gpsReconnectTimeout) {
|
||||||
|
clearTimeout(gpsReconnectTimeout);
|
||||||
|
gpsReconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
gpsEventSource = new EventSource('/gps/stream');
|
||||||
|
gpsEventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'position' && data.latitude && data.longitude) {
|
||||||
|
updateLocationFromGps(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('GPS parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
gpsEventSource.onerror = (e) => {
|
||||||
|
// Don't log every error - connection suspends are normal
|
||||||
|
if (gpsEventSource) {
|
||||||
|
gpsEventSource.close();
|
||||||
|
gpsEventSource = null;
|
||||||
|
}
|
||||||
|
// Auto-reconnect after 5 seconds if still connected
|
||||||
|
if (gpsConnected && !gpsReconnectTimeout) {
|
||||||
|
gpsReconnectTimeout = setTimeout(() => {
|
||||||
|
gpsReconnectTimeout = null;
|
||||||
|
if (gpsConnected) {
|
||||||
|
startGpsStream();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect GPS stream when tab becomes visible
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden && gpsConnected && !gpsEventSource) {
|
||||||
|
startGpsStream();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateLocationFromGps(position) {
|
||||||
|
observerLocation.lat = position.latitude;
|
||||||
|
observerLocation.lon = position.longitude;
|
||||||
|
document.getElementById('obsLat').value = position.latitude.toFixed(4);
|
||||||
|
document.getElementById('obsLon').value = position.longitude.toFixed(4);
|
||||||
|
|
||||||
|
// Update observer marker position
|
||||||
|
if (observerMarker) {
|
||||||
|
observerMarker.setLatLng([position.latitude, position.longitude]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center map on GPS location (on first fix)
|
||||||
|
if (vesselMap && !vesselMap._gpsInitialized) {
|
||||||
|
vesselMap.setView([position.latitude, position.longitude], vesselMap.getZoom());
|
||||||
|
vesselMap._gpsInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redraw range rings at new position
|
||||||
|
drawRangeRings();
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
if (window.ObserverLocation) {
|
||||||
|
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGpsIndicator(show) {
|
||||||
|
const indicator = document.getElementById('gpsIndicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.style.display = show ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Session timer functions
|
// Session timer functions
|
||||||
function startSessionTimer() {
|
function startSessionTimer() {
|
||||||
if (!stats.sessionStart) {
|
if (!stats.sessionStart) {
|
||||||
@@ -1344,7 +1481,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', initMap);
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initMap();
|
||||||
|
// Auto-connect to gpsd if available
|
||||||
|
autoConnectGps();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Agent styles -->
|
<!-- Agent styles -->
|
||||||
@@ -1362,7 +1503,7 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.agent-select-sm:focus {
|
.agent-select-sm:focus {
|
||||||
@@ -1414,8 +1555,17 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
{% include 'partials/settings-modal.html' %}
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
||||||
|
|
||||||
<!-- Agent Manager -->
|
<!-- Agent Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
// AIS-specific agent integration
|
// AIS-specific agent integration
|
||||||
let aisCurrentAgent = 'local';
|
let aisCurrentAgent = 'local';
|
||||||
@@ -1500,7 +1650,7 @@
|
|||||||
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device, gain })
|
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{#
|
||||||
|
Card/Panel Component
|
||||||
|
Reusable container with optional header and footer
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- title: Optional card header title
|
||||||
|
- indicator: If true, shows status indicator dot in header
|
||||||
|
- indicator_active: If true, indicator is active/green
|
||||||
|
- no_padding: If true, removes body padding
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
{% if title %}
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
{% if indicator %}
|
||||||
|
<div class="panel-indicator {% if indicator_active %}active{% endif %}"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="panel-content{% if no_padding %}" style="padding: 0;{% else %}{% endif %}">
|
||||||
|
{{ caller() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{#
|
||||||
|
Empty State Component
|
||||||
|
Display when no data is available
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- icon: Optional SVG icon (default: generic empty icon)
|
||||||
|
- title: Main message (default: "No data")
|
||||||
|
- description: Optional helper text
|
||||||
|
- action_text: Optional button text
|
||||||
|
- action_onclick: Optional button onclick handler
|
||||||
|
- action_href: Optional button link
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
{% if icon %}
|
||||||
|
{{ icon|safe }}
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M8 12h8"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="empty-state-title">{{ title|default('No data') }}</div>
|
||||||
|
{% if description %}
|
||||||
|
<div class="empty-state-description">{{ description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if action_text %}
|
||||||
|
<div class="empty-state-action">
|
||||||
|
{% if action_href %}
|
||||||
|
<a href="{{ action_href }}" class="btn btn-primary btn-sm">{{ action_text }}</a>
|
||||||
|
{% elif action_onclick %}
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="{{ action_onclick }}">{{ action_text }}</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{#
|
||||||
|
Loading State Component
|
||||||
|
Display while data is being fetched
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- text: Optional loading text (default: "Loading...")
|
||||||
|
- size: 'sm', 'md', or 'lg' (default: 'md')
|
||||||
|
- overlay: If true, renders as full overlay
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% if overlay %}
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
|
||||||
|
{% if text %}
|
||||||
|
<div class="loading-text mt-3 text-secondary text-sm">{{ text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="loading-inline flex items-center gap-3">
|
||||||
|
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
|
||||||
|
{% if text %}
|
||||||
|
<span class="text-secondary text-sm">{{ text }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{#
|
||||||
|
Stats Strip Component
|
||||||
|
Horizontal bar displaying key metrics
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- stats: List of stat objects with 'id', 'value', 'label', and optional 'title'
|
||||||
|
- show_divider: Show divider after stats (default: true)
|
||||||
|
- status_dot_id: Optional ID for status indicator dot
|
||||||
|
- status_text_id: Optional ID for status text
|
||||||
|
- time_id: Optional ID for time display
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="stats-strip">
|
||||||
|
<div class="stats-strip-inner">
|
||||||
|
{% for stat in stats %}
|
||||||
|
<div class="strip-stat" {% if stat.title %}title="{{ stat.title }}"{% endif %}>
|
||||||
|
<span class="strip-value" id="{{ stat.id }}">{{ stat.value|default('0') }}</span>
|
||||||
|
<span class="strip-label">{{ stat.label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if show_divider|default(true) %}
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Additional content from caller #}
|
||||||
|
{% if caller is defined %}
|
||||||
|
{{ caller() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status_dot_id or status_text_id %}
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<div class="strip-status">
|
||||||
|
{% if status_dot_id %}
|
||||||
|
<div class="status-dot inactive" id="{{ status_dot_id }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if status_text_id %}
|
||||||
|
<span id="{{ status_text_id }}">STANDBY</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if time_id %}
|
||||||
|
<div class="strip-time" id="{{ time_id }}">--:--:-- UTC</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{#
|
||||||
|
Status Badge Component
|
||||||
|
Compact status indicator with dot and text
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- status: 'online', 'offline', 'warning', 'error' (default: 'offline')
|
||||||
|
- text: Status text to display
|
||||||
|
- id: Optional ID for the text element (for JS updates)
|
||||||
|
- dot_id: Optional ID for the dot element (for JS updates)
|
||||||
|
- pulse: If true, adds pulse animation to dot
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set status_class = {
|
||||||
|
'online': 'online',
|
||||||
|
'active': 'online',
|
||||||
|
'offline': 'offline',
|
||||||
|
'warning': 'warning',
|
||||||
|
'error': 'error',
|
||||||
|
'inactive': 'inactive'
|
||||||
|
}.get(status|default('offline'), 'inactive') %}
|
||||||
|
|
||||||
|
<div class="status-badge flex items-center gap-2">
|
||||||
|
<div class="status-dot {{ status_class }}{% if pulse %} pulse{% endif %}"
|
||||||
|
{% if dot_id %}id="{{ dot_id }}"{% endif %}></div>
|
||||||
|
<span class="text-sm"
|
||||||
|
{% if id %}id="{{ id }}"{% endif %}>{{ text|default('Unknown') }}</span>
|
||||||
|
</div>
|
||||||
+715
-359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="{{ theme|default('dark') }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}iNTERCEPT{% endblock %} // iNTERCEPT</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
|
||||||
|
{# Fonts - Conditional CDN/Local loading #}
|
||||||
|
{% if offline_settings and offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Core CSS (Design System) #}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/base.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
|
||||||
|
|
||||||
|
{# Responsive styles #}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
|
||||||
|
{# Page-specific CSS #}
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
|
|
||||||
|
{# Page-specific head content #}
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
{# Global Header #}
|
||||||
|
{% block header %}
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-header-left">
|
||||||
|
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
<a href="/" class="app-logo">
|
||||||
|
<svg class="app-logo-icon" width="40" height="40" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 30 Q5 50, 15 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M22 35 Q14 50, 22 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M29 40 Q23 50, 29 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M85 30 Q95 50, 85 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M78 35 Q86 50, 78 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M71 40 Q77 50, 71 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="50" cy="22" r="6" fill="var(--accent-green)"/>
|
||||||
|
<rect x="44" y="35" width="12" height="45" rx="2" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="35" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
</svg>
|
||||||
|
<span class="app-logo-text">
|
||||||
|
<span class="app-logo-title">iNTERCEPT</span>
|
||||||
|
<span class="app-logo-tagline">// See the Invisible</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% if version %}
|
||||||
|
<span class="badge badge-primary">v{{ version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="app-header-right">
|
||||||
|
{% block header_right %}
|
||||||
|
<div class="header-clock">
|
||||||
|
<span class="header-clock-label">UTC</span>
|
||||||
|
<span id="headerUtcTime">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{# Global Navigation - opt-in for pages that need it #}
|
||||||
|
{# Override this block and include 'partials/nav.html' in child templates #}
|
||||||
|
{% block navigation %}{% endblock %}
|
||||||
|
|
||||||
|
{# Main Content Area #}
|
||||||
|
<main class="app-main">
|
||||||
|
{% block main %}
|
||||||
|
<div class="content-wrapper">
|
||||||
|
{# Optional Sidebar #}
|
||||||
|
{% block sidebar %}{% endblock %}
|
||||||
|
|
||||||
|
{# Page Content #}
|
||||||
|
<div class="app-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{# Toast/Notification Container #}
|
||||||
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Core JavaScript #}
|
||||||
|
<script>
|
||||||
|
// UTC Clock
|
||||||
|
function updateUtcClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const utc = now.toISOString().slice(11, 19);
|
||||||
|
const clockEl = document.getElementById('headerUtcTime');
|
||||||
|
if (clockEl) clockEl.textContent = utc;
|
||||||
|
}
|
||||||
|
setInterval(updateUtcClock, 1000);
|
||||||
|
updateUtcClock();
|
||||||
|
|
||||||
|
// Mobile menu toggle
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
const drawerOverlay = document.getElementById('drawerOverlay');
|
||||||
|
|
||||||
|
if (hamburgerBtn) {
|
||||||
|
hamburgerBtn.addEventListener('click', function() {
|
||||||
|
this.classList.toggle('open');
|
||||||
|
document.querySelector('.app-sidebar')?.classList.toggle('open');
|
||||||
|
drawerOverlay?.classList.toggle('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawerOverlay) {
|
||||||
|
drawerOverlay.addEventListener('click', function() {
|
||||||
|
hamburgerBtn?.classList.remove('open');
|
||||||
|
document.querySelector('.app-sidebar')?.classList.remove('open');
|
||||||
|
this.classList.remove('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle
|
||||||
|
function toggleTheme() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const currentTheme = html.getAttribute('data-theme') || 'dark';
|
||||||
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
html.setAttribute('data-theme', newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved theme
|
||||||
|
const savedTheme = localStorage.getItem('intercept-theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav dropdown handling
|
||||||
|
function toggleNavDropdown(groupName) {
|
||||||
|
const group = document.querySelector(`.nav-group[data-group="${groupName}"]`);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
// Close other dropdowns
|
||||||
|
document.querySelectorAll('.nav-group.open').forEach(g => {
|
||||||
|
if (g !== group) g.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
group.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('.nav-group')) {
|
||||||
|
document.querySelectorAll('.nav-group.open').forEach(g => g.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{# Page-specific JavaScript #}
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
Dashboard Base Template
|
||||||
|
Extended layout for full-screen dashboard pages (ADSB, AIS, Satellite, etc.)
|
||||||
|
Features: Full-height layout, stats strip, sidebar overlay on mobile
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- active_mode: The current mode for nav highlighting (e.g., 'adsb', 'ais', 'satellite')
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
/* Dashboard-specific overrides */
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radar/Grid background effect */
|
||||||
|
.dashboard-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at center, transparent 0%, var(--bg-primary) 70%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 50px, var(--border-color) 50px, var(--border-color) 51px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 50px, var(--border-color) 50px, var(--border-color) 51px);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--border-color) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--border-color) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanline {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
opacity: 0.5;
|
||||||
|
animation: scanline 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanline {
|
||||||
|
0% { top: 0; }
|
||||||
|
100% { top: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations toggle */
|
||||||
|
[data-animations="off"] .scanline,
|
||||||
|
[data-animations="off"] .radar-bg,
|
||||||
|
[data-animations="off"] .grid-bg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard main content */
|
||||||
|
.dashboard-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.dashboard-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<header class="app-header" style="padding: 0 var(--space-3); height: 48px;">
|
||||||
|
<div class="app-header-left" style="gap: var(--space-3);">
|
||||||
|
<a href="/" class="app-logo" style="gap: var(--space-2);">
|
||||||
|
<svg class="app-logo-icon" width="28" height="28" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 30 Q5 50, 15 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M22 35 Q14 50, 22 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M29 40 Q23 50, 29 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M85 30 Q95 50, 85 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M78 35 Q86 50, 78 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M71 40 Q77 50, 71 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="50" cy="22" r="6" fill="var(--accent-green)"/>
|
||||||
|
<rect x="44" y="35" width="12" height="45" rx="2" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="35" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="dashboard-header-title">
|
||||||
|
<span style="font-size: var(--text-lg); font-weight: var(--font-bold); color: var(--text-primary);">
|
||||||
|
{% block dashboard_title %}DASHBOARD{% endblock %}
|
||||||
|
</span>
|
||||||
|
<span style="font-size: var(--text-sm); color: var(--text-dim); margin-left: var(--space-2);">
|
||||||
|
// iNTERCEPT
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-header-right">
|
||||||
|
{% block dashboard_header_center %}{% endblock %}
|
||||||
|
<div class="header-utilities" style="gap: var(--space-2);">
|
||||||
|
{% block agent_selector %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{# Include the unified nav partial with active_mode set #}
|
||||||
|
{% include 'partials/nav.html' with context %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{# Background effects #}
|
||||||
|
<div class="dashboard-bg">
|
||||||
|
{% block dashboard_bg %}
|
||||||
|
<div class="radar-bg"></div>
|
||||||
|
{% endblock %}
|
||||||
|
<div class="scanline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Stats strip #}
|
||||||
|
{% block stats_strip %}{% endblock %}
|
||||||
|
|
||||||
|
{# Dashboard content #}
|
||||||
|
<div class="dashboard-content">
|
||||||
|
{% block dashboard_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
// Dashboard-specific scripts
|
||||||
|
(function() {
|
||||||
|
// Mobile sidebar toggle
|
||||||
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||||
|
const sidebar = document.querySelector('.dashboard-sidebar');
|
||||||
|
const overlay = document.getElementById('drawerOverlay');
|
||||||
|
|
||||||
|
if (sidebarToggle && sidebar) {
|
||||||
|
sidebarToggle.addEventListener('click', function() {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
if (overlay) overlay.classList.toggle('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('click', function() {
|
||||||
|
sidebar?.classList.remove('open');
|
||||||
|
this.classList.remove('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC Clock update
|
||||||
|
function updateUtcClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const utc = now.toISOString().slice(11, 19) + ' UTC';
|
||||||
|
document.querySelectorAll('[id$="utcTime"], [id$="UtcTime"]').forEach(el => {
|
||||||
|
el.textContent = utc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(updateUtcClock, 1000);
|
||||||
|
updateUtcClock();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+1123
-1106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
|||||||
|
{#
|
||||||
|
Help Modal Partial
|
||||||
|
Provides consistent help modal across all pages
|
||||||
|
#}
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
|
||||||
|
<div class="help-content">
|
||||||
|
<button class="help-close" onclick="hideHelp()">×</button>
|
||||||
|
<h2>iNTERCEPT Help</h2>
|
||||||
|
|
||||||
|
<div class="help-tabs">
|
||||||
|
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
|
||||||
|
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
|
||||||
|
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
|
||||||
|
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icons Section -->
|
||||||
|
<div id="help-icons" class="help-section active">
|
||||||
|
<h3>Stats Bar Icons</h3>
|
||||||
|
<div class="icon-grid">
|
||||||
|
<div class="icon-item"><span class="icon">📟</span><span class="desc">POCSAG messages decoded</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📠</span><span class="desc">FLEX messages decoded</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📨</span><span class="desc">Total messages received</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🌡️</span><span class="desc">Unique sensors detected</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📊</span><span class="desc">Device types found</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellites monitored</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📡</span><span class="desc">WiFi Access Points</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">👤</span><span class="desc">Connected WiFi clients</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🤝</span><span class="desc">Captured handshakes</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🚁</span><span class="desc">Detected drones (click for details)</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">⚠️</span><span class="desc">Rogue APs (click for details)</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth devices</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📍</span><span class="desc">BLE beacons / APRS stations</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Mode Tab Icons</h3>
|
||||||
|
<div class="icon-grid">
|
||||||
|
<div class="icon-item"><span class="icon">📟</span><span class="desc">Pager - POCSAG/FLEX decoder</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📡</span><span class="desc">433MHz - Sensor decoder</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">⚡</span><span class="desc">Meters - Utility meter decoder</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🚢</span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📻</span><span class="desc">Spy Stations - Number stations database</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio tracking</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellite - Pass prediction</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📶</span><span class="desc">WiFi - Network scanner</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">📻</span><span class="desc">Listening Post - SDR scanner</span></div>
|
||||||
|
<div class="icon-item"><span class="icon">🔍</span><span class="desc">TSCM - Counter-surveillance</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modes Section -->
|
||||||
|
<div id="help-modes" class="help-section">
|
||||||
|
<h3>Pager Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
|
||||||
|
<li>Set frequency to local pager frequencies (common: 152-158 MHz)</li>
|
||||||
|
<li>Messages are displayed in real-time as they're decoded</li>
|
||||||
|
<li>Use presets for common pager frequencies</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>433MHz Sensor Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes wireless sensors on 433.92 MHz ISM band</li>
|
||||||
|
<li>Detects temperature, humidity, weather stations, tire pressure monitors</li>
|
||||||
|
<li>Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)</li>
|
||||||
|
<li>Device intelligence builds profiles of recurring devices</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Utility Meter Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes utility meter transmissions (water, gas, electric) using rtlamr</li>
|
||||||
|
<li>Supports ERT protocol on 912 MHz (North America) or 868 MHz (Europe)</li>
|
||||||
|
<li>Displays meter IDs and consumption data in real-time</li>
|
||||||
|
<li>Supports SCM, SCM+, IDM, NetIDM, and R900 message types</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Aircraft (Dashboard)</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Opens the dedicated ADS-B Dashboard for aircraft tracking</li>
|
||||||
|
<li>Features radar scope, map view, airband audio, and ACARS decoding</li>
|
||||||
|
<li>Optional history mode persists data to Postgres for long-term analysis</li>
|
||||||
|
<li>Access history dashboard at <code>/adsb/history</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Vessels (Dashboard)</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Opens the AIS Dashboard for maritime vessel tracking</li>
|
||||||
|
<li>Displays vessel name, MMSI, callsign, destination, and navigation data</li>
|
||||||
|
<li><strong>VHF DSC Channel 70:</strong> Monitors maritime distress frequency (156.525 MHz)</li>
|
||||||
|
<li>Decodes DSC messages: Distress, Urgency, Safety, and Routine calls</li>
|
||||||
|
<li>MMSI country identification via Maritime Identification Digits (MID)</li>
|
||||||
|
<li>Visual alerts for DISTRESS and URGENCY messages with map markers</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Spy Stations</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Database of number stations and diplomatic HF networks</li>
|
||||||
|
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
||||||
|
<li>Filter by type (number/diplomatic), country, and mode</li>
|
||||||
|
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
||||||
|
<li>Click "Tune" to listen via Listening Post mode</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>APRS Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
|
||||||
|
<li>Tracks amateur radio operators transmitting position data</li>
|
||||||
|
<li>Regional frequencies: 144.390 MHz (N. America), 144.800 MHz (Europe)</li>
|
||||||
|
<li>Uses Direwolf or multimon-ng for packet decoding</li>
|
||||||
|
<li>Interactive map shows station positions in real-time</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Satellite Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Track satellites using TLE (Two-Line Element) data</li>
|
||||||
|
<li>Add satellites manually or fetch from Celestrak by category</li>
|
||||||
|
<li>Categories: Amateur, Weather, ISS, Starlink, GPS, and more</li>
|
||||||
|
<li>View next pass predictions with elevation and duration</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>WiFi Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Requires a WiFi adapter capable of monitor mode</li>
|
||||||
|
<li>Click "Enable Monitor" to put adapter in monitor mode</li>
|
||||||
|
<li>Scans all channels or lock to a specific channel</li>
|
||||||
|
<li>Detects drones by SSID patterns and manufacturer OUI</li>
|
||||||
|
<li>Rogue AP detection flags same SSID on multiple BSSIDs</li>
|
||||||
|
<li>Click network rows to target for deauth or handshake capture</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Bluetooth Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Scans for classic Bluetooth and BLE devices</li>
|
||||||
|
<li>Shows device names, addresses, and signal strength</li>
|
||||||
|
<li>Manufacturer lookup from MAC address OUI</li>
|
||||||
|
<li>Radar visualization shows device proximity</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Listening Post Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Wideband SDR scanner with spectrum visualization</li>
|
||||||
|
<li>Tune to any frequency supported by your SDR hardware</li>
|
||||||
|
<li>AM/FM/USB/LSB demodulation modes</li>
|
||||||
|
<li>Bookmark frequencies for quick recall</li>
|
||||||
|
<li>Quick tune presets for emergency and marine channels</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>TSCM Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Technical Surveillance Countermeasures sweep</li>
|
||||||
|
<li>Scans for unknown RF transmitters, WiFi devices, Bluetooth</li>
|
||||||
|
<li>Baseline comparison to detect new/anomalous devices</li>
|
||||||
|
<li>Threat classification: Critical, High, Medium, Low</li>
|
||||||
|
<li>Useful for security audits and bug sweeps</li>
|
||||||
|
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Meshtastic Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Integrates with Meshtastic LoRa mesh network devices</li>
|
||||||
|
<li>Connect Heltec, T-Beam, RAK, or other compatible devices via USB</li>
|
||||||
|
<li>Real-time message streaming with RSSI and SNR metrics</li>
|
||||||
|
<li>Configure channels with encryption keys</li>
|
||||||
|
<li>View connected nodes and message history</li>
|
||||||
|
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Network Monitor</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Aggregates data from multiple remote INTERCEPT agents</li>
|
||||||
|
<li>View all WiFi, Bluetooth, ADS-B, AIS data in one unified view</li>
|
||||||
|
<li>Real-time streaming via Server-Sent Events (SSE)</li>
|
||||||
|
<li>Location estimation using multi-agent trilateration</li>
|
||||||
|
<li>Manage agents at <code>/controller/manage</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WiFi Section -->
|
||||||
|
<div id="help-wifi" class="help-section">
|
||||||
|
<h3>Monitor Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
|
||||||
|
<li><strong>Kill Processes:</strong> Optional - stops NetworkManager/wpa_supplicant (may drop other connections)</li>
|
||||||
|
<li>Some adapters rename when entering monitor mode (e.g., wlan0 → wlan0mon)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Handshake Capture</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Click "Capture" on a network to start targeted handshake capture</li>
|
||||||
|
<li>Status panel shows capture progress and file location</li>
|
||||||
|
<li>Use deauth to force clients to reconnect (only on authorized networks!)</li>
|
||||||
|
<li>Handshake files saved to /tmp/intercept_handshake_*.cap</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Drone Detection</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)</li>
|
||||||
|
<li>Also detected by manufacturer OUI in MAC address</li>
|
||||||
|
<li>Distance estimated from signal strength (approximate)</li>
|
||||||
|
<li>Click drone count in stats bar to see all detected drones</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Rogue AP Detection</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Flags networks where same SSID appears on multiple BSSIDs</li>
|
||||||
|
<li>Could indicate evil twin attack or legitimate multi-AP setup</li>
|
||||||
|
<li>Click rogue count to see which SSIDs are flagged</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Proximity Alerts</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Add MAC addresses to watch list for alerts when detected</li>
|
||||||
|
<li>Watch list persists in browser localStorage</li>
|
||||||
|
<li>Useful for tracking specific devices</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Client Probe Analysis</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Shows what networks client devices are looking for</li>
|
||||||
|
<li>Orange highlights indicate sensitive/private network names</li>
|
||||||
|
<li>Reveals user location history (home, work, hotels, airports)</li>
|
||||||
|
<li>Useful for security awareness and pen test reports</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips Section -->
|
||||||
|
<div id="help-tips" class="help-section">
|
||||||
|
<h3>General Tips</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li><strong>Collapsible sections:</strong> Click any section header (∇) to collapse/expand</li>
|
||||||
|
<li><strong>Sound alerts:</strong> Toggle sound on/off in settings for each mode</li>
|
||||||
|
<li><strong>Export data:</strong> Use export buttons to save captured data as JSON</li>
|
||||||
|
<li><strong>Device Intelligence:</strong> Tracks device patterns over time</li>
|
||||||
|
<li><strong>Theme toggle:</strong> Click the theme button in header to switch dark/light mode</li>
|
||||||
|
<li><strong>Settings:</strong> Click the gear icon in the header to access settings</li>
|
||||||
|
<li><strong>Offline mode:</strong> Enable in Settings to use local assets without internet</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Keyboard Shortcuts</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li><strong>F1</strong> - Open this help page</li>
|
||||||
|
<li><strong>?</strong> - Open help (when not typing in a field)</li>
|
||||||
|
<li><strong>Escape</strong> - Close help and modal dialogs</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Requirements</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li><strong>Pager:</strong> RTL-SDR, rtl_fm, multimon-ng</li>
|
||||||
|
<li><strong>433MHz Sensors:</strong> RTL-SDR, rtl_433</li>
|
||||||
|
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
|
||||||
|
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
|
||||||
|
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||||||
|
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
||||||
|
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||||||
|
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
||||||
|
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||||
|
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
|
||||||
|
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||||||
|
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
|
||||||
|
<li>Run as root/sudo for full hardware access</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Legal Notice</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Only use on networks and devices you own or have authorization to test</li>
|
||||||
|
<li>Passive monitoring may be legal; active attacks require authorization</li>
|
||||||
|
<li>Check local laws regarding radio frequency monitoring</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Help modal functions - defined here so all pages have them
|
||||||
|
(function() {
|
||||||
|
// Only define if not already defined (index.html defines its own)
|
||||||
|
if (typeof window.showHelp === 'undefined') {
|
||||||
|
window.showHelp = function() {
|
||||||
|
document.getElementById('helpModal').classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.hideHelp === 'undefined') {
|
||||||
|
window.hideHelp = function() {
|
||||||
|
document.getElementById('helpModal').classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.switchHelpTab === 'undefined') {
|
||||||
|
window.switchHelpTab = function(tab) {
|
||||||
|
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
|
||||||
|
document.querySelector('.help-tab[data-tab="' + tab + '"]').classList.add('active');
|
||||||
|
document.getElementById('help-' + tab).classList.add('active');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts for help (only add once)
|
||||||
|
if (!window._helpKeyboardSetup) {
|
||||||
|
window._helpKeyboardSetup = true;
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') hideHelp();
|
||||||
|
// Open help with F1 or ? key (when not typing in an input)
|
||||||
|
var helpModal = document.getElementById('helpModal');
|
||||||
|
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
|
||||||
|
e.preventDefault();
|
||||||
|
showHelp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
fetch('/ais/start', {
|
fetch('/ais/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device, gain })
|
body: JSON.stringify({ device, gain, bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
||||||
<span id="lpQuickFreq" style="font-size: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">---.--- MHz</span>
|
<span id="lpQuickFreq" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">---.--- MHz</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
||||||
|
|||||||
@@ -100,3 +100,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Traceroute Modal -->
|
||||||
|
<div id="meshTracerouteModal" class="signal-details-modal">
|
||||||
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeTracerouteModal()"></div>
|
||||||
|
<div class="signal-details-modal-content">
|
||||||
|
<div class="signal-details-modal-header">
|
||||||
|
<h3>Traceroute to <span id="meshTracerouteDest">--</span></h3>
|
||||||
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeTracerouteModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="signal-details-modal-body">
|
||||||
|
<div id="meshTracerouteContent" class="mesh-traceroute-content">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-details-modal-footer">
|
||||||
|
<button class="preset-btn" onclick="Meshtastic.closeTracerouteModal()" style="width: 100%;">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!-- SSTV MODE -->
|
||||||
|
<div id="sstvMode" class="mode-content">
|
||||||
|
<div class="section">
|
||||||
|
<h3>ISS SSTV Decoder</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||||
|
Decode Slow-Scan Television images from the International Space Station.
|
||||||
|
ISS SSTV transmits on 145.800 MHz FM during special events.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Decoder Settings</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frequency (MHz)</label>
|
||||||
|
<input type="number" id="sstvFrequency" value="145.800" step="0.001" min="100" max="500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
<a href="https://ariss.org/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
ARISS.org (Event Schedule)
|
||||||
|
</a>
|
||||||
|
<a href="https://www.amsat.org/sstv-from-iss/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
AMSAT SSTV Guide
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>About SSTV</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim);">
|
||||||
|
SSTV (Slow-Scan Television) is a method for transmitting images via radio.
|
||||||
|
The ISS periodically transmits commemorative images during special events
|
||||||
|
which can be received with an RTL-SDR and appropriate software.
|
||||||
|
</p>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 8px;">
|
||||||
|
Common modes: PD120, PD180, Martin1, Scottie1
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
{#
|
||||||
|
Global Navigation Partial
|
||||||
|
Single source of truth for app navigation
|
||||||
|
|
||||||
|
Compatible with:
|
||||||
|
- index.html (uses switchMode() for mode panels)
|
||||||
|
- Dashboard pages (uses navigation links)
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- active_mode: Current active mode (e.g., 'pager', 'adsb', 'wifi')
|
||||||
|
- is_index_page: If true, Satellite/SSTV use switchMode (panel mode)
|
||||||
|
If false (default), Satellite links to dashboard
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set is_index_page = is_index_page|default(false) %}
|
||||||
|
|
||||||
|
{% macro mode_item(mode, label, icon_svg, href=None) -%}
|
||||||
|
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||||
|
{%- if href %}
|
||||||
|
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{%- elif is_index_page %}
|
||||||
|
<button class="mode-nav-btn {{ is_active }}" onclick="switchMode('{{ mode }}')">
|
||||||
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
</button>
|
||||||
|
{%- else %}
|
||||||
|
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
|
||||||
|
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||||
|
{%- if href %}
|
||||||
|
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
|
</a>
|
||||||
|
{%- elif is_index_page %}
|
||||||
|
<button class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" onclick="switchMode('{{ mode }}')">
|
||||||
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
|
</button>
|
||||||
|
{%- else %}
|
||||||
|
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
|
||||||
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
|
</a>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# Desktop Navigation - uses existing CSS class names for compatibility #}
|
||||||
|
<nav class="mode-nav" id="mainNav">
|
||||||
|
{# SDR / RF Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="sdr">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('sdr')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>
|
||||||
|
<span class="nav-label">SDR / RF</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
|
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
|
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
|
{{ mode_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||||
|
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||||
|
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||||
|
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
|
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
|
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Wireless Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="wireless">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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" fill="currentColor" stroke="none"/></svg></span>
|
||||||
|
<span class="nav-label">Wireless</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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" fill="currentColor" stroke="none"/></svg>') }}
|
||||||
|
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Security Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="security">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('security')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||||
|
<span class="nav-label">Security</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Space Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="space">
|
||||||
|
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
|
||||||
|
<span class="nav-label">Space</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{% if is_index_page %}
|
||||||
|
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>') }}
|
||||||
|
{% else %}
|
||||||
|
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
|
||||||
|
{% endif %}
|
||||||
|
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Dynamic dashboard button (shown when in satellite mode) #}
|
||||||
|
<div class="mode-nav-actions">
|
||||||
|
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
|
||||||
|
<span class="nav-label">Full Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Nav Utilities (clock, theme, tools) #}
|
||||||
|
<div class="nav-utilities">
|
||||||
|
<div class="nav-clock">
|
||||||
|
<span class="utc-label">UTC</span>
|
||||||
|
<span class="utc-time" id="headerUtcTime">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-divider"></div>
|
||||||
|
<a href="/" class="nav-dashboard-btn" title="Return to Main Dashboard" style="text-decoration: none;">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
|
||||||
|
<span class="nav-label">Main Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-divider"></div>
|
||||||
|
<div class="nav-tools">
|
||||||
|
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
|
||||||
|
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
|
||||||
|
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
|
||||||
|
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
|
||||||
|
<span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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></span>
|
||||||
|
</button>
|
||||||
|
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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></span>
|
||||||
|
</a>
|
||||||
|
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
|
||||||
|
</a>
|
||||||
|
<button class="nav-tool-btn" onclick="showSettings()" title="Settings">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
|
||||||
|
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
|
||||||
|
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# Mobile Navigation Bar #}
|
||||||
|
<nav class="mobile-nav-bar" id="mobileNavBar">
|
||||||
|
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
|
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
|
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
|
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||||
|
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||||
|
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||||
|
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
||||||
|
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
{% if is_index_page %}
|
||||||
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||||
|
{% else %}
|
||||||
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }}
|
||||||
|
{% endif %}
|
||||||
|
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
|
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
|
<script>
|
||||||
|
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
||||||
|
if (typeof switchMode === 'undefined') {
|
||||||
|
window.switchMode = function(mode) {
|
||||||
|
// On dashboard pages, navigate to main page with mode param
|
||||||
|
window.location.href = '/?mode=' + mode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toggleNavDropdown === 'undefined') {
|
||||||
|
window.toggleNavDropdown = function(groupName) {
|
||||||
|
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${groupName}"]`);
|
||||||
|
if (!dropdown) return;
|
||||||
|
|
||||||
|
// Close other dropdowns
|
||||||
|
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => {
|
||||||
|
if (d !== dropdown) d.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.classList.toggle('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('.mode-nav-dropdown')) {
|
||||||
|
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => d.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toggleAnimations === 'undefined') {
|
||||||
|
window.toggleAnimations = function() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const current = html.getAttribute('data-animations') || 'on';
|
||||||
|
const next = current === 'on' ? 'off' : 'on';
|
||||||
|
html.setAttribute('data-animations', next);
|
||||||
|
localStorage.setItem('animations', next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toggleTheme === 'undefined') {
|
||||||
|
window.toggleTheme = function() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const current = html.getAttribute('data-theme') || 'dark';
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
html.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('intercept-theme', next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof showSettings === 'undefined') {
|
||||||
|
window.showSettings = function() {
|
||||||
|
// Try to open settings modal if it exists on this page
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('active');
|
||||||
|
if (typeof Settings !== 'undefined' && Settings.init) {
|
||||||
|
Settings.init().then(() => {
|
||||||
|
if (Settings.checkAssets) Settings.checkAssets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to navigating to main page settings
|
||||||
|
window.location.href = '/?settings=1';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof hideSettings === 'undefined') {
|
||||||
|
window.hideSettings = function() {
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// showHelp is defined by the help-modal.html partial
|
||||||
|
|
||||||
|
if (typeof logout === 'undefined') {
|
||||||
|
window.logout = function(e) {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (confirm('Are you sure you want to logout?')) {
|
||||||
|
window.location.href = '/logout';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved preferences and start clock
|
||||||
|
(function() {
|
||||||
|
const savedTheme = localStorage.getItem('intercept-theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedAnimations = localStorage.getItem('intercept-animations');
|
||||||
|
if (savedAnimations) {
|
||||||
|
document.documentElement.setAttribute('data-animations', savedAnimations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC Clock update (if not already defined by parent page)
|
||||||
|
if (typeof window._navClockStarted === 'undefined') {
|
||||||
|
window._navClockStarted = true;
|
||||||
|
function updateNavUtcClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const utc = now.toISOString().slice(11, 19);
|
||||||
|
const el = document.getElementById('headerUtcTime');
|
||||||
|
if (el) el.textContent = utc;
|
||||||
|
}
|
||||||
|
setInterval(updateNavUtcClock, 1000);
|
||||||
|
updateNavUtcClock();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{#
|
||||||
|
Page Header Partial
|
||||||
|
Consistent page title with optional description and actions
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- title: Page title (required)
|
||||||
|
- description: Optional description text
|
||||||
|
- back_url: Optional back link URL
|
||||||
|
- back_text: Optional back link text (default: "Back")
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
{% if back_url %}
|
||||||
|
<a href="{{ back_url }}" class="back-link mb-4">
|
||||||
|
<span class="icon icon--sm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{{ back_text|default('Back') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">{{ title }}</h1>
|
||||||
|
{% if description %}
|
||||||
|
<p class="page-description">{{ description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if caller is defined %}
|
||||||
|
<div class="page-actions">
|
||||||
|
{{ caller() }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,167 +1,317 @@
|
|||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()">
|
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()">
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2>
|
<h2>
|
||||||
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
<button class="settings-close" onclick="hideSettings()">×</button>
|
<button class="settings-close" onclick="hideSettings()">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
|
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
|
||||||
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
<button class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')">Location</button>
|
||||||
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
||||||
</div>
|
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
||||||
|
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
||||||
<!-- Offline Section -->
|
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
||||||
<div id="settings-offline" class="settings-section active">
|
</div>
|
||||||
<div class="settings-group">
|
|
||||||
<div class="settings-group-title">Offline Mode</div>
|
<!-- Offline Section -->
|
||||||
|
<div id="settings-offline" class="settings-section active">
|
||||||
<div class="settings-row">
|
<div class="settings-group">
|
||||||
<div class="settings-label">
|
<div class="settings-group-title">Offline Mode</div>
|
||||||
<span class="settings-label-text">Enable Offline Mode</span>
|
|
||||||
<span class="settings-label-desc">Use local assets instead of CDN</span>
|
<div class="settings-row">
|
||||||
</div>
|
<div class="settings-label">
|
||||||
<label class="toggle-switch">
|
<span class="settings-label-text">Enable Offline Mode</span>
|
||||||
<input type="checkbox" id="offlineEnabled" onchange="Settings.toggleOfflineMode(this.checked)">
|
<span class="settings-label-desc">Use local assets instead of CDN</span>
|
||||||
<span class="toggle-slider"></span>
|
</div>
|
||||||
</label>
|
<label class="toggle-switch">
|
||||||
</div>
|
<input type="checkbox" id="offlineEnabled" onchange="Settings.toggleOfflineMode(this.checked)">
|
||||||
</div>
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
<div class="settings-group">
|
</div>
|
||||||
<div class="settings-group-title">Asset Sources</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-row">
|
<div class="settings-group">
|
||||||
<div class="settings-label">
|
<div class="settings-group-title">Asset Sources</div>
|
||||||
<span class="settings-label-text">JavaScript/CSS Libraries</span>
|
|
||||||
<span class="settings-label-desc">Leaflet, Chart.js</span>
|
<div class="settings-row">
|
||||||
</div>
|
<div class="settings-label">
|
||||||
<select id="assetsSource" class="settings-select" onchange="Settings.setAssetSource(this.value)">
|
<span class="settings-label-text">JavaScript/CSS Libraries</span>
|
||||||
<option value="cdn">CDN (Online)</option>
|
<span class="settings-label-desc">Leaflet, Chart.js</span>
|
||||||
<option value="local">Local</option>
|
</div>
|
||||||
</select>
|
<select id="assetsSource" class="settings-select" onchange="Settings.setAssetSource(this.value)">
|
||||||
</div>
|
<option value="cdn">CDN (Online)</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
<div class="settings-row">
|
</select>
|
||||||
<div class="settings-label">
|
</div>
|
||||||
<span class="settings-label-text">Web Fonts</span>
|
|
||||||
<span class="settings-label-desc">Inter, JetBrains Mono</span>
|
<div class="settings-row">
|
||||||
</div>
|
<div class="settings-label">
|
||||||
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
<span class="settings-label-text">Web Fonts</span>
|
||||||
<option value="cdn">Google Fonts (Online)</option>
|
<span class="settings-label-desc">Space Mono</span>
|
||||||
<option value="local">Local</option>
|
</div>
|
||||||
</select>
|
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
||||||
</div>
|
<option value="cdn">Google Fonts (Online)</option>
|
||||||
</div>
|
<option value="local">Local</option>
|
||||||
|
</select>
|
||||||
<div class="settings-group">
|
</div>
|
||||||
<div class="settings-group-title">Map Tiles</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-row">
|
<div class="settings-group">
|
||||||
<div class="settings-label">
|
<div class="settings-group-title">Map Tiles</div>
|
||||||
<span class="settings-label-text">Tile Provider</span>
|
|
||||||
<span class="settings-label-desc">Map background imagery</span>
|
<div class="settings-row">
|
||||||
</div>
|
<div class="settings-label">
|
||||||
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
<span class="settings-label-text">Tile Provider</span>
|
||||||
<option value="openstreetmap">OpenStreetMap</option>
|
<span class="settings-label-desc">Map background imagery</span>
|
||||||
<option value="cartodb_dark">CartoDB Dark</option>
|
</div>
|
||||||
<option value="cartodb_light">CartoDB Positron</option>
|
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
||||||
<option value="esri_world">ESRI World Imagery</option>
|
<option value="openstreetmap">OpenStreetMap</option>
|
||||||
<option value="custom">Custom URL</option>
|
<option value="cartodb_dark">CartoDB Dark</option>
|
||||||
</select>
|
<option value="cartodb_light">CartoDB Positron</option>
|
||||||
</div>
|
<option value="esri_world">ESRI World Imagery</option>
|
||||||
|
<option value="custom">Custom URL</option>
|
||||||
<div class="settings-row custom-url-row" id="customTileUrlRow" style="display: none;">
|
</select>
|
||||||
<div class="settings-label" style="width: 100%;">
|
</div>
|
||||||
<span class="settings-label-text">Custom Tile URL</span>
|
|
||||||
<span class="settings-label-desc">e.g., http://localhost:8080/{z}/{x}/{y}.png</span>
|
<div class="settings-row custom-url-row" id="customTileUrlRow" style="display: none;">
|
||||||
<input type="text" id="customTileUrl" class="settings-input"
|
<div class="settings-label" style="width: 100%;">
|
||||||
placeholder="http://tile-server/{z}/{x}/{y}.png"
|
<span class="settings-label-text">Custom Tile URL</span>
|
||||||
onchange="Settings.setCustomTileUrl(this.value)">
|
<span class="settings-label-desc">e.g., http://localhost:8080/{z}/{x}/{y}.png</span>
|
||||||
</div>
|
<input type="text" id="customTileUrl" class="settings-input"
|
||||||
</div>
|
placeholder="http://tile-server/{z}/{x}/{y}.png"
|
||||||
</div>
|
onchange="Settings.setCustomTileUrl(this.value)">
|
||||||
|
</div>
|
||||||
<div class="settings-group">
|
</div>
|
||||||
<div class="settings-group-title">Local Asset Status</div>
|
</div>
|
||||||
<div class="asset-status" id="assetStatus">
|
|
||||||
<div class="asset-status-row">
|
<div class="settings-group">
|
||||||
<span class="asset-name">Leaflet JS/CSS</span>
|
<div class="settings-group-title">Local Asset Status</div>
|
||||||
<span class="asset-badge checking" id="statusLeaflet">Checking...</span>
|
<div class="asset-status" id="assetStatus">
|
||||||
</div>
|
<div class="asset-status-row">
|
||||||
<div class="asset-status-row">
|
<span class="asset-name">Leaflet JS/CSS</span>
|
||||||
<span class="asset-name">Chart.js</span>
|
<span class="asset-badge checking" id="statusLeaflet">Checking...</span>
|
||||||
<span class="asset-badge checking" id="statusChartjs">Checking...</span>
|
</div>
|
||||||
</div>
|
<div class="asset-status-row">
|
||||||
<div class="asset-status-row">
|
<span class="asset-name">Chart.js</span>
|
||||||
<span class="asset-name">Inter Font</span>
|
<span class="asset-badge checking" id="statusChartjs">Checking...</span>
|
||||||
<span class="asset-badge checking" id="statusInter">Checking...</span>
|
</div>
|
||||||
</div>
|
<div class="asset-status-row">
|
||||||
<div class="asset-status-row">
|
<span class="asset-name">Inter Font</span>
|
||||||
<span class="asset-name">JetBrains Mono</span>
|
<span class="asset-badge checking" id="statusInter">Checking...</span>
|
||||||
<span class="asset-badge checking" id="statusJetBrains">Checking...</span>
|
</div>
|
||||||
</div>
|
<div class="asset-status-row">
|
||||||
</div>
|
<span class="asset-name">Space Mono</span>
|
||||||
<button class="check-assets-btn" onclick="Settings.checkAssets()">
|
<span class="asset-badge checking" id="statusJetbrains">Checking...</span>
|
||||||
Check Assets
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<button class="check-assets-btn" onclick="Settings.checkAssets()">
|
||||||
|
Check Assets
|
||||||
<div class="settings-info">
|
</button>
|
||||||
<strong>Note:</strong> Changes to asset sources require a page reload to take effect.
|
</div>
|
||||||
Local assets must be available in <code>/static/vendor/</code>.
|
|
||||||
</div>
|
<div class="settings-info">
|
||||||
</div>
|
<strong>Note:</strong> Changes to asset sources require a page reload to take effect.
|
||||||
|
Local assets must be available in <code>/static/vendor/</code>.
|
||||||
<!-- Display Section -->
|
</div>
|
||||||
<div id="settings-display" class="settings-section">
|
</div>
|
||||||
<div class="settings-group">
|
|
||||||
<div class="settings-group-title">Visual Preferences</div>
|
<!-- Location Section -->
|
||||||
|
<div id="settings-location" class="settings-section">
|
||||||
<div class="settings-row">
|
<div class="settings-group">
|
||||||
<div class="settings-label">
|
<div class="settings-group-title">Observer Location</div>
|
||||||
<span class="settings-label-text">Theme</span>
|
<p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
|
||||||
<span class="settings-label-desc">Color scheme preference</span>
|
Set your geographic coordinates for satellite pass predictions and ISS tracking.
|
||||||
</div>
|
</p>
|
||||||
<select id="themeSelect" class="settings-select" onchange="setThemePreference(this.value)">
|
|
||||||
<option value="dark">Dark</option>
|
<div class="settings-row">
|
||||||
<option value="light">Light</option>
|
<div class="settings-label">
|
||||||
</select>
|
<span class="settings-label-text">Latitude</span>
|
||||||
</div>
|
<span class="settings-label-desc">Decimal degrees (-90 to 90)</span>
|
||||||
|
</div>
|
||||||
<div class="settings-row">
|
<input type="number" id="observerLatInput" class="settings-input"
|
||||||
<div class="settings-label">
|
step="0.0001" min="-90" max="90" placeholder="51.5074"
|
||||||
<span class="settings-label-text">Animations</span>
|
style="width: 120px; text-align: right;">
|
||||||
<span class="settings-label-desc">Enable visual effects and animations</span>
|
</div>
|
||||||
</div>
|
|
||||||
<label class="toggle-switch">
|
<div class="settings-row">
|
||||||
<input type="checkbox" id="animationsEnabled" checked onchange="setAnimationsEnabled(this.checked)">
|
<div class="settings-label">
|
||||||
<span class="toggle-slider"></span>
|
<span class="settings-label-text">Longitude</span>
|
||||||
</label>
|
<span class="settings-label-desc">Decimal degrees (-180 to 180)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<input type="number" id="observerLonInput" class="settings-input"
|
||||||
</div>
|
step="0.0001" min="-180" max="180" placeholder="-0.1278"
|
||||||
|
style="width: 120px; text-align: right;">
|
||||||
<!-- About Section -->
|
</div>
|
||||||
<div id="settings-about" class="settings-section">
|
|
||||||
<div class="settings-group">
|
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
||||||
<div class="about-info">
|
<button class="check-assets-btn" onclick="detectLocationGPS(this)" style="flex: 1;">
|
||||||
<p><strong>iNTERCEPT</strong> - Signal Intelligence Platform</p>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; vertical-align: -2px; margin-right: 5px;">
|
||||||
<p>Version: <span class="about-version">{{ version }}</span></p>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<p>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
A unified web interface for software-defined radio (SDR) tools,
|
<line x1="12" y1="2" x2="12" y2="6"/>
|
||||||
supporting pager decoding, sensor monitoring, aircraft tracking,
|
<line x1="12" y1="18" x2="12" y2="22"/>
|
||||||
WiFi/Bluetooth scanning, and more.
|
<line x1="2" y1="12" x2="6" y2="12"/>
|
||||||
</p>
|
<line x1="18" y1="12" x2="22" y2="12"/>
|
||||||
<p>
|
</svg>
|
||||||
<a href="https://github.com/intercept" target="_blank">GitHub Repository</a>
|
Use GPS
|
||||||
</p>
|
</button>
|
||||||
</div>
|
<button class="check-assets-btn" onclick="saveObserverLocation()" style="flex: 1; background: var(--accent-cyan); color: #000;">
|
||||||
</div>
|
Save Location
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Current Location</div>
|
||||||
|
<div id="currentLocationDisplay" style="padding: 12px; background: var(--bg-tertiary); border-radius: 6px; font-family: var(--font-mono); font-size: 12px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;">
|
||||||
|
<span style="color: var(--text-dim);">Latitude</span>
|
||||||
|
<span id="currentLatDisplay" style="color: var(--accent-cyan);">Not set</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
<span style="color: var(--text-dim);">Longitude</span>
|
||||||
|
<span id="currentLonDisplay" style="color: var(--accent-cyan);">Not set</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-info">
|
||||||
|
<strong>Note:</strong> Location is used for ISS pass predictions in SSTV mode and satellite tracking.
|
||||||
|
Your location is stored locally and never sent to external servers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Section -->
|
||||||
|
<div id="settings-display" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Visual Preferences</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Theme</span>
|
||||||
|
<span class="settings-label-desc">Color scheme preference</span>
|
||||||
|
</div>
|
||||||
|
<select id="themeSelect" class="settings-select" onchange="setThemePreference(this.value)">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Animations</span>
|
||||||
|
<span class="settings-label-desc">Enable visual effects and animations</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="animationsEnabled" checked onchange="setAnimationsEnabled(this.checked)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Updates Section -->
|
||||||
|
<div id="settings-updates" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Update Status</div>
|
||||||
|
<div id="updateStatusContent" style="padding: 10px 0;">
|
||||||
|
<div style="text-align: center; padding: 20px; color: var(--text-dim);">
|
||||||
|
Loading update status...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="check-assets-btn" onclick="checkForUpdatesManual()" style="margin-top: 10px;">
|
||||||
|
Check Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Update Settings</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Auto-Check for Updates</span>
|
||||||
|
<span class="settings-label-desc">Periodically check GitHub for new releases</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="updateCheckEnabled" checked onchange="toggleUpdateCheck(this.checked)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-info">
|
||||||
|
<strong>Note:</strong> Updates are fetched from GitHub and applied via git pull.
|
||||||
|
Make sure you have git installed and the application is in a git repository.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tools Section -->
|
||||||
|
<div id="settings-tools" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Tool Dependencies</div>
|
||||||
|
<p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
|
||||||
|
Check which external tools are installed for each mode.
|
||||||
|
<span style="color: var(--accent-green);">●</span> = Installed,
|
||||||
|
<span style="color: var(--accent-red);">●</span> = Missing
|
||||||
|
</p>
|
||||||
|
<div id="settingsToolsContent" style="max-height: 45vh; overflow-y: auto;">
|
||||||
|
<div style="text-align: center; padding: 30px; color: var(--text-dim);">
|
||||||
|
Loading dependencies...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group" style="margin-top: 15px;">
|
||||||
|
<div class="settings-group-title">Quick Install (Debian/Ubuntu)</div>
|
||||||
|
<div style="background: var(--bg-tertiary); padding: 10px; border-radius: 4px; font-family: var(--font-mono); font-size: 10px; overflow-x: auto;">
|
||||||
|
<div>sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxdumptool hcxtools</div>
|
||||||
|
<div style="margin-top: 5px;">pip install skyfield flask</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px; font-size: 11px; color: var(--text-dim);">
|
||||||
|
<strong>Note:</strong> ACARS decoding requires <code>acarsdec</code> which must be built from source.
|
||||||
|
See <a href="https://github.com/TLeconte/acarsdec" target="_blank" style="color: var(--accent-cyan);">github.com/TLeconte/acarsdec</a> or run <code>./setup.sh</code> for automated installation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<div id="settings-about" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="about-info">
|
||||||
|
<p><strong>iNTERCEPT</strong> - Signal Intelligence Platform</p>
|
||||||
|
<p>Version: <span class="about-version">{{ version }}</span></p>
|
||||||
|
<p>
|
||||||
|
A unified web interface for software-defined radio (SDR) tools,
|
||||||
|
supporting pager decoding, sensor monitoring, aircraft tracking,
|
||||||
|
WiFi/Bluetooth scanning, and more.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/smittix/intercept" target="_blank">GitHub Repository</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Support the Project</div>
|
||||||
|
<p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
|
||||||
|
If you find iNTERCEPT useful, consider supporting its development.
|
||||||
|
</p>
|
||||||
|
<a href="https://buymeacoffee.com/smittix" target="_blank" rel="noopener noreferrer" class="donate-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 18px; height: 18px; vertical-align: -3px; margin-right: 8px;">
|
||||||
|
<path d="M17 8h1a4 4 0 1 1 0 8h-1"/>
|
||||||
|
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/>
|
||||||
|
<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>
|
||||||
|
Buy Me a Coffee
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
+1101
-1039
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for deauthentication attack detector.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from utils.wifi.deauth_detector import (
|
||||||
|
DeauthDetector,
|
||||||
|
DeauthPacketInfo,
|
||||||
|
DeauthTracker,
|
||||||
|
DeauthAlert,
|
||||||
|
DEAUTH_REASON_CODES,
|
||||||
|
)
|
||||||
|
from utils.constants import (
|
||||||
|
DEAUTH_DETECTION_WINDOW,
|
||||||
|
DEAUTH_ALERT_THRESHOLD,
|
||||||
|
DEAUTH_CRITICAL_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeauthPacketInfo:
|
||||||
|
"""Tests for DeauthPacketInfo dataclass."""
|
||||||
|
|
||||||
|
def test_creation(self):
|
||||||
|
"""Test basic creation of packet info."""
|
||||||
|
pkt = DeauthPacketInfo(
|
||||||
|
timestamp=1234567890.0,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
signal_dbm=-45,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pkt.frame_type == 'deauth'
|
||||||
|
assert pkt.src_mac == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
assert pkt.reason_code == 7
|
||||||
|
assert pkt.signal_dbm == -45
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeauthTracker:
|
||||||
|
"""Tests for DeauthTracker."""
|
||||||
|
|
||||||
|
def test_add_packet(self):
|
||||||
|
"""Test adding packets to tracker."""
|
||||||
|
tracker = DeauthTracker()
|
||||||
|
|
||||||
|
pkt1 = DeauthPacketInfo(
|
||||||
|
timestamp=100.0,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
)
|
||||||
|
tracker.add_packet(pkt1)
|
||||||
|
|
||||||
|
assert len(tracker.packets) == 1
|
||||||
|
assert tracker.first_seen == 100.0
|
||||||
|
assert tracker.last_seen == 100.0
|
||||||
|
|
||||||
|
def test_multiple_packets(self):
|
||||||
|
"""Test adding multiple packets."""
|
||||||
|
tracker = DeauthTracker()
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
pkt = DeauthPacketInfo(
|
||||||
|
timestamp=100.0 + i,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
)
|
||||||
|
tracker.add_packet(pkt)
|
||||||
|
|
||||||
|
assert len(tracker.packets) == 5
|
||||||
|
assert tracker.first_seen == 100.0
|
||||||
|
assert tracker.last_seen == 104.0
|
||||||
|
|
||||||
|
def test_get_packets_in_window(self):
|
||||||
|
"""Test filtering packets by time window."""
|
||||||
|
tracker = DeauthTracker()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Add old packet
|
||||||
|
tracker.add_packet(DeauthPacketInfo(
|
||||||
|
timestamp=now - 10,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add recent packets
|
||||||
|
for i in range(3):
|
||||||
|
tracker.add_packet(DeauthPacketInfo(
|
||||||
|
timestamp=now - i,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 5-second window should only include the 3 recent packets
|
||||||
|
in_window = tracker.get_packets_in_window(5.0)
|
||||||
|
assert len(in_window) == 3
|
||||||
|
|
||||||
|
def test_cleanup_old_packets(self):
|
||||||
|
"""Test removing old packets."""
|
||||||
|
tracker = DeauthTracker()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Add old packet
|
||||||
|
tracker.add_packet(DeauthPacketInfo(
|
||||||
|
timestamp=now - 20,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add recent packet
|
||||||
|
tracker.add_packet(DeauthPacketInfo(
|
||||||
|
timestamp=now,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
|
||||||
|
tracker.alert_sent = True
|
||||||
|
|
||||||
|
# Cleanup with 10-second window
|
||||||
|
tracker.cleanup_old_packets(10.0)
|
||||||
|
|
||||||
|
assert len(tracker.packets) == 1
|
||||||
|
assert tracker.packets[0].timestamp == now
|
||||||
|
|
||||||
|
def test_cleanup_resets_alert_sent(self):
|
||||||
|
"""Test that cleanup resets alert_sent when all packets removed."""
|
||||||
|
tracker = DeauthTracker()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
tracker.add_packet(DeauthPacketInfo(
|
||||||
|
timestamp=now - 100, # Very old
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
|
||||||
|
tracker.alert_sent = True
|
||||||
|
|
||||||
|
# Cleanup should remove all packets
|
||||||
|
tracker.cleanup_old_packets(10.0)
|
||||||
|
|
||||||
|
assert len(tracker.packets) == 0
|
||||||
|
assert tracker.alert_sent is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeauthAlert:
|
||||||
|
"""Tests for DeauthAlert."""
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test conversion to dictionary."""
|
||||||
|
alert = DeauthAlert(
|
||||||
|
id='deauth-123-1',
|
||||||
|
timestamp=1234567890.0,
|
||||||
|
severity='high',
|
||||||
|
attacker_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
attacker_vendor='Unknown',
|
||||||
|
attacker_signal_dbm=-45,
|
||||||
|
is_spoofed_ap=True,
|
||||||
|
target_mac='11:22:33:44:55:66',
|
||||||
|
target_vendor='Apple',
|
||||||
|
target_type='client',
|
||||||
|
target_known_from_scan=True,
|
||||||
|
ap_bssid='AA:BB:CC:DD:EE:FF',
|
||||||
|
ap_essid='TestNetwork',
|
||||||
|
ap_channel=6,
|
||||||
|
frame_type='deauth',
|
||||||
|
reason_code=7,
|
||||||
|
reason_text='Class 3 frame received from nonassociated STA',
|
||||||
|
packet_count=50,
|
||||||
|
window_seconds=5.0,
|
||||||
|
packets_per_second=10.0,
|
||||||
|
attack_type='targeted',
|
||||||
|
description='Targeted deauth flood against known client',
|
||||||
|
)
|
||||||
|
|
||||||
|
d = alert.to_dict()
|
||||||
|
|
||||||
|
assert d['id'] == 'deauth-123-1'
|
||||||
|
assert d['type'] == 'deauth_alert'
|
||||||
|
assert d['severity'] == 'high'
|
||||||
|
assert d['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
assert d['attacker']['is_spoofed_ap'] is True
|
||||||
|
assert d['target']['type'] == 'client'
|
||||||
|
assert d['access_point']['essid'] == 'TestNetwork'
|
||||||
|
assert d['attack_info']['packet_count'] == 50
|
||||||
|
assert d['analysis']['attack_type'] == 'targeted'
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeauthDetector:
|
||||||
|
"""Tests for DeauthDetector."""
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
"""Test detector initialization."""
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert detector.interface == 'wlan0mon'
|
||||||
|
assert detector.event_callback == callback
|
||||||
|
assert not detector.is_running
|
||||||
|
|
||||||
|
def test_stats(self):
|
||||||
|
"""Test stats property."""
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = detector.stats
|
||||||
|
assert stats['is_running'] is False
|
||||||
|
assert stats['interface'] == 'wlan0mon'
|
||||||
|
assert stats['packets_captured'] == 0
|
||||||
|
assert stats['alerts_generated'] == 0
|
||||||
|
|
||||||
|
def test_get_alerts_empty(self):
|
||||||
|
"""Test getting alerts when none exist."""
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
alerts = detector.get_alerts()
|
||||||
|
assert alerts == []
|
||||||
|
|
||||||
|
def test_clear_alerts(self):
|
||||||
|
"""Test clearing alerts."""
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add a mock alert
|
||||||
|
detector._alerts.append(MagicMock())
|
||||||
|
detector._trackers[('A', 'B', 'C')] = DeauthTracker()
|
||||||
|
detector._alert_counter = 5
|
||||||
|
|
||||||
|
detector.clear_alerts()
|
||||||
|
|
||||||
|
assert len(detector._alerts) == 0
|
||||||
|
assert len(detector._trackers) == 0
|
||||||
|
assert detector._alert_counter == 0
|
||||||
|
|
||||||
|
@patch('utils.wifi.deauth_detector.time.time')
|
||||||
|
def test_generate_alert_severity_low(self, mock_time):
|
||||||
|
"""Test alert generation with low severity."""
|
||||||
|
mock_time.return_value = 1000.0
|
||||||
|
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create packets just at threshold
|
||||||
|
packets = []
|
||||||
|
for i in range(DEAUTH_ALERT_THRESHOLD):
|
||||||
|
packets.append(DeauthPacketInfo(
|
||||||
|
timestamp=1000.0 - (DEAUTH_ALERT_THRESHOLD - 1 - i) * 0.1,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='99:88:77:66:55:44',
|
||||||
|
reason_code=7,
|
||||||
|
signal_dbm=-50,
|
||||||
|
))
|
||||||
|
|
||||||
|
alert = detector._generate_alert(
|
||||||
|
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
|
||||||
|
packets=packets,
|
||||||
|
packet_count=DEAUTH_ALERT_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert alert.severity == 'low'
|
||||||
|
assert alert.packet_count == DEAUTH_ALERT_THRESHOLD
|
||||||
|
|
||||||
|
@patch('utils.wifi.deauth_detector.time.time')
|
||||||
|
def test_generate_alert_severity_high(self, mock_time):
|
||||||
|
"""Test alert generation with high severity."""
|
||||||
|
mock_time.return_value = 1000.0
|
||||||
|
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create packets above critical threshold
|
||||||
|
packets = []
|
||||||
|
for i in range(DEAUTH_CRITICAL_THRESHOLD):
|
||||||
|
packets.append(DeauthPacketInfo(
|
||||||
|
timestamp=1000.0 - (DEAUTH_CRITICAL_THRESHOLD - 1 - i) * 0.1,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='99:88:77:66:55:44',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
|
||||||
|
alert = detector._generate_alert(
|
||||||
|
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
|
||||||
|
packets=packets,
|
||||||
|
packet_count=DEAUTH_CRITICAL_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert alert.severity == 'high'
|
||||||
|
|
||||||
|
@patch('utils.wifi.deauth_detector.time.time')
|
||||||
|
def test_generate_alert_broadcast_attack(self, mock_time):
|
||||||
|
"""Test alert classification for broadcast attack."""
|
||||||
|
mock_time.return_value = 1000.0
|
||||||
|
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
packets = [DeauthPacketInfo(
|
||||||
|
timestamp=999.9,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='FF:FF:FF:FF:FF:FF', # Broadcast
|
||||||
|
bssid='99:88:77:66:55:44',
|
||||||
|
reason_code=7,
|
||||||
|
)]
|
||||||
|
|
||||||
|
alert = detector._generate_alert(
|
||||||
|
tracker_key=('AA:BB:CC:DD:EE:FF', 'FF:FF:FF:FF:FF:FF', '99:88:77:66:55:44'),
|
||||||
|
packets=packets,
|
||||||
|
packet_count=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert alert.attack_type == 'broadcast'
|
||||||
|
assert alert.target_type == 'broadcast'
|
||||||
|
assert 'all clients' in alert.description.lower()
|
||||||
|
|
||||||
|
def test_lookup_ap_no_callback(self):
|
||||||
|
"""Test AP lookup when no callback is provided."""
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
get_networks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
|
||||||
|
|
||||||
|
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
assert result['essid'] is None
|
||||||
|
assert result['channel'] is None
|
||||||
|
|
||||||
|
def test_lookup_ap_with_callback(self):
|
||||||
|
"""Test AP lookup with callback."""
|
||||||
|
callback = MagicMock()
|
||||||
|
get_networks = MagicMock(return_value={
|
||||||
|
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet', 'channel': 6}
|
||||||
|
})
|
||||||
|
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
get_networks=get_networks,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
|
||||||
|
|
||||||
|
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
assert result['essid'] == 'TestNet'
|
||||||
|
assert result['channel'] == 6
|
||||||
|
|
||||||
|
def test_check_spoofed_source(self):
|
||||||
|
"""Test detection of spoofed AP source."""
|
||||||
|
callback = MagicMock()
|
||||||
|
get_networks = MagicMock(return_value={
|
||||||
|
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet'}
|
||||||
|
})
|
||||||
|
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
get_networks=get_networks,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source matches known AP - spoofed
|
||||||
|
assert detector._check_spoofed_source('AA:BB:CC:DD:EE:FF') is True
|
||||||
|
|
||||||
|
# Source does not match any AP - not spoofed
|
||||||
|
assert detector._check_spoofed_source('11:22:33:44:55:66') is False
|
||||||
|
|
||||||
|
def test_cleanup_old_trackers(self):
|
||||||
|
"""Test cleanup of old trackers."""
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Add an old tracker
|
||||||
|
old_tracker = DeauthTracker()
|
||||||
|
old_tracker.add_packet(DeauthPacketInfo(
|
||||||
|
timestamp=now - 100, # Very old
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='99:88:77:66:55:44',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
detector._trackers[('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')] = old_tracker
|
||||||
|
|
||||||
|
# Add a recent tracker
|
||||||
|
recent_tracker = DeauthTracker()
|
||||||
|
recent_tracker.add_packet(DeauthPacketInfo(
|
||||||
|
timestamp=now,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='BB:CC:DD:EE:FF:AA',
|
||||||
|
dst_mac='22:33:44:55:66:77',
|
||||||
|
bssid='88:77:66:55:44:33',
|
||||||
|
reason_code=7,
|
||||||
|
))
|
||||||
|
detector._trackers[('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33')] = recent_tracker
|
||||||
|
|
||||||
|
detector._cleanup_old_trackers()
|
||||||
|
|
||||||
|
# Old tracker should be removed
|
||||||
|
assert ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44') not in detector._trackers
|
||||||
|
# Recent tracker should remain
|
||||||
|
assert ('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33') in detector._trackers
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasonCodes:
|
||||||
|
"""Tests for reason code dictionary."""
|
||||||
|
|
||||||
|
def test_common_reason_codes(self):
|
||||||
|
"""Test that common reason codes are defined."""
|
||||||
|
assert 1 in DEAUTH_REASON_CODES # Unspecified
|
||||||
|
assert 7 in DEAUTH_REASON_CODES # Class 3 frame
|
||||||
|
assert 14 in DEAUTH_REASON_CODES # MIC failure
|
||||||
|
|
||||||
|
def test_reason_code_descriptions(self):
|
||||||
|
"""Test reason code descriptions are strings."""
|
||||||
|
for code, desc in DEAUTH_REASON_CODES.items():
|
||||||
|
assert isinstance(code, int)
|
||||||
|
assert isinstance(desc, str)
|
||||||
|
assert len(desc) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeauthDetectorIntegration:
|
||||||
|
"""Integration tests for DeauthDetector with mocked scapy."""
|
||||||
|
|
||||||
|
@patch('utils.wifi.deauth_detector.time.time')
|
||||||
|
def test_process_deauth_packet_generates_alert(self, mock_time):
|
||||||
|
"""Test that processing packets generates alert when threshold exceeded."""
|
||||||
|
mock_time.return_value = 1000.0
|
||||||
|
|
||||||
|
callback = MagicMock()
|
||||||
|
detector = DeauthDetector(
|
||||||
|
interface='wlan0mon',
|
||||||
|
event_callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a mock scapy packet
|
||||||
|
mock_pkt = MagicMock()
|
||||||
|
|
||||||
|
# Mock Dot11Deauth layer
|
||||||
|
mock_deauth = MagicMock()
|
||||||
|
mock_deauth.reason = 7
|
||||||
|
|
||||||
|
# Mock Dot11 layer
|
||||||
|
mock_dot11 = MagicMock()
|
||||||
|
mock_dot11.addr1 = '11:22:33:44:55:66' # dst
|
||||||
|
mock_dot11.addr2 = 'AA:BB:CC:DD:EE:FF' # src
|
||||||
|
mock_dot11.addr3 = '99:88:77:66:55:44' # bssid
|
||||||
|
|
||||||
|
# Mock RadioTap layer
|
||||||
|
mock_radiotap = MagicMock()
|
||||||
|
mock_radiotap.dBm_AntSignal = -50
|
||||||
|
|
||||||
|
# Set up haslayer behavior
|
||||||
|
def haslayer_side_effect(layer):
|
||||||
|
if 'Dot11Deauth' in str(layer):
|
||||||
|
return True
|
||||||
|
if 'Dot11Disas' in str(layer):
|
||||||
|
return False
|
||||||
|
if 'RadioTap' in str(layer):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
mock_pkt.haslayer = haslayer_side_effect
|
||||||
|
|
||||||
|
# Set up __getitem__ behavior
|
||||||
|
def getitem_side_effect(layer):
|
||||||
|
if 'Dot11Deauth' in str(layer):
|
||||||
|
return mock_deauth
|
||||||
|
if 'Dot11' in str(layer) and 'Deauth' not in str(layer):
|
||||||
|
return mock_dot11
|
||||||
|
if 'RadioTap' in str(layer):
|
||||||
|
return mock_radiotap
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
mock_pkt.__getitem__ = getitem_side_effect
|
||||||
|
|
||||||
|
# Patch the scapy imports inside _process_deauth_packet
|
||||||
|
with patch('utils.wifi.deauth_detector.DeauthDetector._process_deauth_packet.__globals__', {
|
||||||
|
'Dot11': MagicMock,
|
||||||
|
'Dot11Deauth': MagicMock,
|
||||||
|
'Dot11Disas': MagicMock,
|
||||||
|
'RadioTap': MagicMock,
|
||||||
|
}):
|
||||||
|
# Process enough packets to trigger alert
|
||||||
|
for i in range(DEAUTH_ALERT_THRESHOLD + 5):
|
||||||
|
mock_time.return_value = 1000.0 + i * 0.1
|
||||||
|
|
||||||
|
# Manually simulate what _process_deauth_packet does
|
||||||
|
pkt_info = DeauthPacketInfo(
|
||||||
|
timestamp=mock_time.return_value,
|
||||||
|
frame_type='deauth',
|
||||||
|
src_mac='AA:BB:CC:DD:EE:FF',
|
||||||
|
dst_mac='11:22:33:44:55:66',
|
||||||
|
bssid='99:88:77:66:55:44',
|
||||||
|
reason_code=7,
|
||||||
|
signal_dbm=-50,
|
||||||
|
)
|
||||||
|
|
||||||
|
detector._packets_captured += 1
|
||||||
|
|
||||||
|
tracker_key = ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')
|
||||||
|
tracker = detector._trackers[tracker_key]
|
||||||
|
tracker.add_packet(pkt_info)
|
||||||
|
|
||||||
|
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
|
||||||
|
packet_count = len(packets_in_window)
|
||||||
|
|
||||||
|
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
|
||||||
|
alert = detector._generate_alert(
|
||||||
|
tracker_key=tracker_key,
|
||||||
|
packets=packets_in_window,
|
||||||
|
packet_count=packet_count,
|
||||||
|
)
|
||||||
|
detector._alerts.append(alert)
|
||||||
|
detector._alerts_generated += 1
|
||||||
|
tracker.alert_sent = True
|
||||||
|
detector.event_callback(alert.to_dict())
|
||||||
|
|
||||||
|
# Verify alert was generated
|
||||||
|
assert detector._alerts_generated == 1
|
||||||
|
assert len(detector._alerts) == 1
|
||||||
|
assert callback.called
|
||||||
|
|
||||||
|
# Verify callback was called with alert data
|
||||||
|
call_args = callback.call_args[0][0]
|
||||||
|
assert call_args['type'] == 'deauth_alert'
|
||||||
|
assert call_args['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
assert call_args['target']['mac'] == '11:22:33:44:55:66'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user