diff --git a/routes/acars.py b/routes/acars.py index accce19..7b9da78 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -21,7 +21,7 @@ import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.sdr import SDRFactory, SDRType -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, @@ -411,31 +411,25 @@ def stop_acars() -> Response: return jsonify({'status': 'stopped'}) -@acars_bp.route('/stream') -def stream_acars() -> Response: - """SSE stream for ACARS messages.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - - while True: - try: - msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('acars', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@acars_bp.route('/stream') +def stream_acars() -> Response: + """SSE stream for ACARS messages.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('acars', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.acars_queue, + channel_key='acars', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @acars_bp.route('/frequencies') diff --git a/routes/adsb.py b/routes/adsb.py index 4b7e8de..65e44a2 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -535,7 +535,7 @@ def parse_sbs_stream(service_addr): # Geofence check _gf_lat = snapshot.get('lat') _gf_lon = snapshot.get('lon') - if _gf_lat and _gf_lon: + if _gf_lat is not None and _gf_lon is not None: try: from utils.geofence import get_geofence_manager for _gf_evt in get_geofence_manager().check_position( diff --git a/routes/ais.py b/routes/ais.py index 414f97c..038be87 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -18,7 +18,7 @@ import app as app_module from config import SHARED_OBSERVER_LOCATION_ENABLED from utils.logging import get_logger from utils.validation import validate_device_index, validate_gain -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.sdr import SDRFactory, SDRType from utils.constants import ( @@ -502,25 +502,19 @@ def stop_ais(): @ais_bp.route('/stream') def stream_ais(): """SSE stream for AIS vessels.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() + def _on_msg(msg: dict[str, Any]) -> None: + process_event('ais', msg, msg.get('type')) - while True: - try: - msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('ais', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') + response = Response( + sse_stream_fanout( + source_queue=app_module.ais_queue, + channel_key='ais', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) response.headers['Cache-Control'] = 'no-cache' response.headers['X-Accel-Buffering'] = 'no' return response diff --git a/routes/analytics.py b/routes/analytics.py index 23efb65..17c8db4 100644 --- a/routes/analytics.py +++ b/routes/analytics.py @@ -6,6 +6,7 @@ import csv import io import json from datetime import datetime, timezone +from typing import Any from flask import Blueprint, Response, jsonify, request @@ -25,11 +26,11 @@ analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics') # Map mode names to DataStore attribute(s) -MODE_STORES: dict[str, list[str]] = { - 'adsb': ['adsb_aircraft'], - 'ais': ['ais_vessels'], - 'wifi': ['wifi_networks', 'wifi_clients'], - 'bluetooth': ['bt_devices'], +MODE_STORES: dict[str, list[str]] = { + 'adsb': ['adsb_aircraft'], + 'ais': ['ais_vessels'], + 'wifi': ['wifi_networks', 'wifi_clients'], + 'bluetooth': ['bt_devices'], 'dsc': ['dsc_messages'], } @@ -77,6 +78,156 @@ def analytics_patterns(): }) +@analytics_bp.route('/target') +def analytics_target(): + """Search entities across multiple modes for a target-centric view.""" + query = (request.args.get('q') or '').strip() + requested_limit = request.args.get('limit', default=120, type=int) or 120 + limit = max(1, min(500, requested_limit)) + + if not query: + return jsonify({ + 'status': 'success', + 'query': '', + 'results': [], + 'mode_counts': {}, + }) + + needle = query.lower() + results: list[dict[str, Any]] = [] + mode_counts: dict[str, int] = {} + + def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None: + if len(results) >= limit: + return + results.append({ + 'mode': mode, + 'id': entity_id, + 'title': title, + 'subtitle': subtitle, + 'last_seen': last_seen, + }) + mode_counts[mode] = mode_counts.get(mode, 0) + 1 + + # ADS-B + for icao, aircraft in app_module.adsb_aircraft.items(): + if not isinstance(aircraft, dict): + continue + fields = [ + icao, + aircraft.get('icao'), + aircraft.get('hex'), + aircraft.get('callsign'), + aircraft.get('registration'), + aircraft.get('flight'), + ] + if not _matches_query(needle, fields): + continue + title = str(aircraft.get('callsign') or icao or 'Aircraft').strip() + subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}" + push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen')) + if len(results) >= limit: + break + + # AIS + if len(results) < limit: + for mmsi, vessel in app_module.ais_vessels.items(): + if not isinstance(vessel, dict): + continue + fields = [ + mmsi, + vessel.get('mmsi'), + vessel.get('name'), + vessel.get('shipname'), + vessel.get('callsign'), + vessel.get('imo'), + ] + if not _matches_query(needle, fields): + continue + vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel' + subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}" + push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen')) + if len(results) >= limit: + break + + # WiFi networks and clients + if len(results) < limit: + for bssid, net in app_module.wifi_networks.items(): + if not isinstance(net, dict): + continue + fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')] + if not _matches_query(needle, fields): + continue + title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network') + subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}" + push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen')) + if len(results) >= limit: + break + + if len(results) < limit: + for client_mac, client in app_module.wifi_clients.items(): + if not isinstance(client, dict): + continue + fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')] + if not _matches_query(needle, fields): + continue + title = str(client.get('mac') or client_mac or 'WiFi Client') + subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}" + push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen')) + if len(results) >= limit: + break + + # Bluetooth + if len(results) < limit: + for address, dev in app_module.bt_devices.items(): + if not isinstance(dev, dict): + continue + fields = [ + address, + dev.get('address'), + dev.get('mac'), + dev.get('name'), + dev.get('manufacturer'), + dev.get('vendor'), + ] + if not _matches_query(needle, fields): + continue + title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device') + subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}" + push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen')) + if len(results) >= limit: + break + + # DSC recent messages + if len(results) < limit: + for msg_id, msg in app_module.dsc_messages.items(): + if not isinstance(msg, dict): + continue + fields = [ + msg_id, + msg.get('mmsi'), + msg.get('from_mmsi'), + msg.get('to_mmsi'), + msg.get('from_callsign'), + msg.get('to_callsign'), + msg.get('category'), + ] + if not _matches_query(needle, fields): + continue + title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message') + subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}" + push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen')) + if len(results) >= limit: + break + + return jsonify({ + 'status': 'success', + 'query': query, + 'results': results, + 'mode_counts': mode_counts, + }) + + @analytics_bp.route('/insights') def analytics_insights(): """Return actionable insight cards and top changes.""" @@ -195,6 +346,15 @@ def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]: return rows +def _matches_query(needle: str, values: list[Any]) -> bool: + for value in values: + if value is None: + continue + if needle in str(value).lower(): + return True + return False + + def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int: now = datetime.now(timezone.utc) count = 0 diff --git a/routes/aprs.py b/routes/aprs.py index 9083f88..b8fa86e 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -21,7 +21,7 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.sdr import SDRFactory, SDRType from utils.constants import ( @@ -1763,31 +1763,25 @@ def stop_aprs() -> Response: return jsonify({'status': 'stopped'}) -@aprs_bp.route('/stream') -def stream_aprs() -> Response: - """SSE stream for APRS packets.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - - while True: - try: - msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('aprs', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@aprs_bp.route('/stream') +def stream_aprs() -> Response: + """SSE stream for APRS packets.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('aprs', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.aprs_queue, + channel_key='aprs', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @aprs_bp.route('/frequencies') diff --git a/routes/bluetooth.py b/routes/bluetooth.py index f7f7d1f..9f48ab6 100644 --- a/routes/bluetooth.py +++ b/routes/bluetooth.py @@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.dependencies import check_tool from utils.logging import bluetooth_logger as logger -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.validation import validate_bluetooth_interface from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer @@ -553,30 +553,23 @@ def get_bt_devices(): }) -@bluetooth_bp.route('/stream') -def stream_bt(): - """SSE stream for Bluetooth events.""" - def generate(): - last_keepalive = time.time() - keepalive_interval = 30.0 - - while True: - try: - msg = app_module.bt_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('bluetooth', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' +@bluetooth_bp.route('/stream') +def stream_bt(): + """SSE stream for Bluetooth events.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('bluetooth', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.bt_queue, + channel_key='bluetooth', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' return response diff --git a/routes/dmr.py b/routes/dmr.py index 9465657..39750f1 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -17,7 +17,7 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.process import register_process, unregister_process from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm @@ -735,24 +735,19 @@ def stream_dmr_audio() -> Response: @dmr_bp.route('/stream') def stream_dmr() -> Response: """SSE stream for DMR decoder events.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - while True: - try: - msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('dmr', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now + def _on_msg(msg: dict[str, Any]) -> None: + process_event('dmr', msg, msg.get('type')) - response = Response(generate(), mimetype='text/event-stream') + response = Response( + sse_stream_fanout( + source_queue=dmr_queue, + channel_key='dmr', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) response.headers['Cache-Control'] = 'no-cache' response.headers['X-Accel-Buffering'] = 'no' return response diff --git a/routes/dsc.py b/routes/dsc.py index 5dadfcd..cbdbe33 100644 --- a/routes/dsc.py +++ b/routes/dsc.py @@ -35,7 +35,7 @@ from utils.database import ( get_dsc_alert_summary, ) from utils.dsc.parser import parse_dsc_message -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.validation import validate_device_index, validate_gain from utils.sdr import SDRFactory, SDRType @@ -518,26 +518,19 @@ def stop_decoding() -> Response: @dsc_bp.route('/stream') def stream() -> Response: """SSE stream for real-time DSC messages.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 + def _on_msg(msg: dict[str, Any]) -> None: + process_event('dsc', msg, msg.get('type')) - while True: - try: - msg = app_module.dsc_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('dsc', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') + response = Response( + sse_stream_fanout( + source_queue=app_module.dsc_queue, + channel_key='dsc', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) response.headers['Cache-Control'] = 'no-cache' response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' diff --git a/routes/gps.py b/routes/gps.py index 3952e9e..4d85e0b 100644 --- a/routes/gps.py +++ b/routes/gps.py @@ -21,7 +21,7 @@ from utils.gps import ( stop_gpsd_daemon, ) from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import sse_stream_fanout logger = get_logger('intercept.gps') @@ -228,26 +228,19 @@ def get_satellites(): }) -@gps_bp.route('/stream') -def stream_gps(): - """SSE stream of GPS position and sky updates.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 - - while True: - try: - data = _gps_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(data) - 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' +@gps_bp.route('/stream') +def stream_gps(): + """SSE stream of GPS position and sky updates.""" + response = Response( + sse_stream_fanout( + source_queue=_gps_queue, + channel_key='gps', + timeout=1.0, + keepalive_interval=30.0, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' return response diff --git a/routes/listening_post.py b/routes/listening_post.py index ec90bb7..b09772e 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -19,7 +19,7 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.constants import ( SSE_QUEUE_TIMEOUT, @@ -1179,31 +1179,25 @@ def scanner_status() -> Response: }) -@listening_post_bp.route('/scanner/stream') -def stream_scanner_events() -> Response: - """SSE stream for scanner events.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - - while True: - try: - msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('listening_scanner', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@listening_post_bp.route('/scanner/stream') +def stream_scanner_events() -> Response: + """SSE stream for scanner events.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('listening_scanner', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=scanner_queue, + channel_key='listening_scanner', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @listening_post_bp.route('/scanner/log') @@ -1831,30 +1825,25 @@ def stop_waterfall() -> Response: return jsonify({'status': 'stopped'}) -@listening_post_bp.route('/waterfall/stream') -def stream_waterfall() -> Response: - """SSE stream for waterfall data.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - while True: - try: - msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('waterfall', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@listening_post_bp.route('/waterfall/stream') +def stream_waterfall() -> Response: + """SSE stream for waterfall data.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('waterfall', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=waterfall_queue, + channel_key='listening_waterfall', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response def _downsample_bins(values: list[float], target: int) -> list[float]: """Downsample bins to a target length using simple averaging.""" if target <= 0 or len(values) <= target: diff --git a/routes/meshtastic.py b/routes/meshtastic.py index c72d040..48f958b 100644 --- a/routes/meshtastic.py +++ b/routes/meshtastic.py @@ -17,7 +17,7 @@ from typing import Generator from flask import Blueprint, jsonify, request, Response from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.meshtastic import ( get_meshtastic_client, start_meshtastic, @@ -453,8 +453,8 @@ def get_messages(): }) -@meshtastic_bp.route('/stream') -def stream_messages(): +@meshtastic_bp.route('/stream') +def stream_messages(): """ SSE stream of Meshtastic messages. @@ -469,25 +469,18 @@ def stream_messages(): Returns: SSE stream (text/event-stream) """ - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 - - while True: - try: - msg = _mesh_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response = Response( + sse_stream_fanout( + source_queue=_mesh_queue, + channel_key='meshtastic', + timeout=1.0, + keepalive_interval=30.0, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' return response diff --git a/routes/pager.py b/routes/pager.py index 3253a6c..8d6f59a 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -24,7 +24,7 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port ) -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.process import safe_terminate, register_process, unregister_process from utils.sdr import SDRFactory, SDRType, SDRValidationError @@ -538,31 +538,22 @@ def toggle_logging() -> Response: return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) -@pager_bp.route('/stream') -def stream() -> Response: - import json - - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second - - while True: - try: - msg = app_module.output_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('pager', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' - return response +@pager_bp.route('/stream') +def stream() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('pager', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.output_queue, + channel_key='pager', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response diff --git a/routes/recordings.py b/routes/recordings.py index 063fe93..6eca285 100644 --- a/routes/recordings.py +++ b/routes/recordings.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from flask import Blueprint, jsonify, request, send_file @@ -107,3 +108,59 @@ def download_recording(session_id: str): as_attachment=True, download_name=file_path.name, ) + + +@recordings_bp.route('//events', methods=['GET']) +def get_recording_events(session_id: str): + """Return parsed events from a recording for in-app replay.""" + manager = get_recording_manager() + rec = manager.get_recording(session_id) + if not rec: + return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 + + file_path = Path(rec['file_path']) + try: + resolved_root = RECORDING_ROOT.resolve() + resolved_file = file_path.resolve() + if resolved_root not in resolved_file.parents: + return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 + except Exception: + return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 + + if not file_path.exists(): + return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404 + + limit = max(1, min(5000, request.args.get('limit', default=500, type=int))) + offset = max(0, request.args.get('offset', default=0, type=int)) + + events: list[dict] = [] + seen = 0 + with file_path.open('r', encoding='utf-8', errors='replace') as fh: + for idx, line in enumerate(fh): + if idx < offset: + continue + if seen >= limit: + break + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + seen += 1 + except json.JSONDecodeError: + continue + + return jsonify({ + 'status': 'success', + 'recording': { + 'id': rec['id'], + 'mode': rec['mode'], + 'started_at': rec['started_at'], + 'stopped_at': rec['stopped_at'], + 'event_count': rec['event_count'], + }, + 'offset': offset, + 'limit': limit, + 'returned': len(events), + 'events': events, + }) diff --git a/routes/rtlamr.py b/routes/rtlamr.py index abbfd1d..96bdc44 100644 --- a/routes/rtlamr.py +++ b/routes/rtlamr.py @@ -17,7 +17,7 @@ from utils.logging import sensor_logger as logger from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm ) -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.process import safe_terminate, register_process, unregister_process @@ -288,26 +288,19 @@ def stop_rtlamr() -> Response: @rtlamr_bp.route('/stream_rtlamr') def stream_rtlamr() -> Response: - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 + def _on_msg(msg: dict[str, Any]) -> None: + process_event('rtlamr', msg, msg.get('type')) - while True: - try: - msg = app_module.rtlamr_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('rtlamr', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') + response = Response( + sse_stream_fanout( + source_queue=app_module.rtlamr_queue, + channel_key='rtlamr', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) response.headers['Cache-Control'] = 'no-cache' response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' diff --git a/routes/sensor.py b/routes/sensor.py index 98f8a04..9faf8e9 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -18,7 +18,7 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port ) -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.process import safe_terminate, register_process, unregister_process from utils.sdr import SDRFactory, SDRType @@ -272,32 +272,25 @@ def stop_sensor() -> Response: return jsonify({'status': 'not_running'}) -@sensor_bp.route('/stream_sensor') -def stream_sensor() -> Response: - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 - - while True: - try: - msg = app_module.sensor_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('sensor', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' - return response +@sensor_bp.route('/stream_sensor') +def stream_sensor() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('sensor', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.sensor_queue, + channel_key='sensor', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response @sensor_bp.route('/sensor/rssi_history') diff --git a/routes/sstv.py b/routes/sstv.py index fbfe0d0..37df4e5 100644 --- a/routes/sstv.py +++ b/routes/sstv.py @@ -15,7 +15,7 @@ from flask import Blueprint, jsonify, request, Response, send_file import app as app_module from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.sstv import ( get_sstv_decoder, @@ -409,8 +409,8 @@ def delete_all_images(): return jsonify({'status': 'ok', 'deleted': count}) -@sstv_bp.route('/stream') -def stream_progress(): +@sstv_bp.route('/stream') +def stream_progress(): """ SSE stream of SSTV decode progress. @@ -422,29 +422,22 @@ def stream_progress(): 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() - try: - process_event('sstv', progress, progress.get('type')) - except Exception: - pass - 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' + def _on_msg(msg: dict[str, Any]) -> None: + process_event('sstv', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=_sstv_queue, + channel_key='sstv', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' return response diff --git a/routes/sstv_general.py b/routes/sstv_general.py index f7d39c9..b305702 100644 --- a/routes/sstv_general.py +++ b/routes/sstv_general.py @@ -15,7 +15,7 @@ from flask import Blueprint, Response, jsonify, request, send_file import app as app_module from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.sstv import ( get_general_sstv_decoder, @@ -289,26 +289,19 @@ def delete_all_images(): @sstv_general_bp.route('/stream') def stream_progress(): """SSE stream of SSTV decode progress.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 + def _on_msg(msg: dict[str, Any]) -> None: + process_event('sstv_general', msg, msg.get('type')) - while True: - try: - progress = _sstv_general_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('sstv_general', progress, progress.get('type')) - except Exception: - pass - 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 = Response( + sse_stream_fanout( + source_queue=_sstv_general_queue, + channel_key='sstv_general', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) response.headers['Cache-Control'] = 'no-cache' response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' diff --git a/routes/tscm.py b/routes/tscm.py index e110495..4f60b42 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -61,6 +61,7 @@ from utils.tscm.device_identity import ( ingest_wifi_dict, ) from utils.event_pipeline import process_event +from utils.sse import sse_stream_fanout # Import unified Bluetooth scanner helper for TSCM integration try: @@ -629,24 +630,17 @@ def sweep_status(): @tscm_bp.route('/sweep/stream') def sweep_stream(): """SSE stream for real-time sweep updates.""" - def generate(): - while True: - try: - if tscm_queue: - msg = tscm_queue.get(timeout=1) - try: - process_event('tscm', msg, msg.get('type')) - except Exception: - pass - yield f"data: {json.dumps(msg)}\n\n" - else: - time.sleep(1) - yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" - except queue.Empty: - yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('tscm', msg, msg.get('type')) return Response( - generate(), + sse_stream_fanout( + source_queue=tscm_queue, + channel_key='tscm', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', diff --git a/routes/vdl2.py b/routes/vdl2.py index 426f373..ca2200a 100644 --- a/routes/vdl2.py +++ b/routes/vdl2.py @@ -21,7 +21,7 @@ import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.sdr import SDRFactory, SDRType -from utils.sse import format_sse +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, @@ -349,31 +349,25 @@ def stop_vdl2() -> Response: return jsonify({'status': 'stopped'}) -@vdl2_bp.route('/stream') -def stream_vdl2() -> Response: - """SSE stream for VDL2 messages.""" - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - - while True: - try: - msg = app_module.vdl2_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('vdl2', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@vdl2_bp.route('/stream') +def stream_vdl2() -> Response: + """SSE stream for VDL2 messages.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('vdl2', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.vdl2_queue, + channel_key='vdl2', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @vdl2_bp.route('/frequencies') diff --git a/routes/wifi.py b/routes/wifi.py index 3c6018a..38f85a6 100644 --- a/routes/wifi.py +++ b/routes/wifi.py @@ -20,7 +20,7 @@ from utils.dependencies import check_tool, get_tool_path from utils.logging import wifi_logger as logger from utils.process import is_valid_mac, is_valid_channel from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface -from utils.sse import format_sse +from utils.sse import format_sse, sse_stream_fanout from utils.event_pipeline import process_event from data.oui import get_manufacturer from utils.constants import ( @@ -1132,33 +1132,26 @@ def get_wifi_networks(): }) -@wifi_bp.route('/stream') -def stream_wifi(): - """SSE stream for WiFi events.""" - def generate(): - last_keepalive = time.time() - keepalive_interval = 30.0 - - while True: - try: - msg = app_module.wifi_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('wifi', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' - return response +@wifi_bp.route('/stream') +def stream_wifi(): + """SSE stream for WiFi events.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('wifi', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.wifi_queue, + channel_key='wifi', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response # ============================================================================= @@ -1545,8 +1538,8 @@ def v2_deauth_status(): return jsonify({'error': str(e)}), 500 -@wifi_bp.route('/v2/deauth/stream') -def v2_deauth_stream(): +@wifi_bp.route('/v2/deauth/stream') +def v2_deauth_stream(): """ SSE stream for real-time deauth alerts. @@ -1557,26 +1550,18 @@ def v2_deauth_stream(): - 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' + response = Response( + sse_stream_fanout( + source_queue=app_module.deauth_detector_queue, + channel_key='wifi_deauth', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' return response diff --git a/static/css/components/ux-platform.css b/static/css/components/ux-platform.css new file mode 100644 index 0000000..9a40ace --- /dev/null +++ b/static/css/components/ux-platform.css @@ -0,0 +1,435 @@ +/* Shared UX platform components: run-state strip, command palette, setup assistant, and toasts */ + +.run-state-strip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 14px; + margin: 6px 12px 0; + border: 1px solid var(--border-color, #1e2d3d); + border-radius: 8px; + background: linear-gradient(180deg, rgba(17, 26, 37, 0.95), rgba(13, 20, 30, 0.95)); +} + +.run-state-left { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +#runStateChips { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.run-state-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim, #8697aa); + font-weight: 600; +} + +.run-state-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 7px; + border-radius: 999px; + border: 1px solid var(--border-color, #1e2d3d); + background: rgba(255, 255, 255, 0.02); + font-size: 10px; + color: var(--text-secondary, #b1c2d4); + white-space: nowrap; +} + +.run-state-chip .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #667788; + box-shadow: 0 0 0 0 rgba(102, 119, 136, 0.45); +} + +.run-state-chip.running .dot { + background: var(--accent-green, #28c27a); + box-shadow: 0 0 0 4px rgba(40, 194, 122, 0.15); +} + +.run-state-chip.active { + border-color: rgba(74, 163, 255, 0.55); + color: var(--text-primary, #e6edf5); +} + +.run-state-right { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.run-state-value { + font-size: 10px; + color: var(--text-dim, #8697aa); +} + +.run-state-btn { + background: transparent; + color: var(--accent-cyan, #4aa3ff); + border: 1px solid rgba(74, 163, 255, 0.45); + border-radius: 6px; + font-size: 10px; + padding: 4px 8px; + cursor: pointer; +} + +.run-state-btn:hover { + background: rgba(74, 163, 255, 0.12); +} + +.command-palette-overlay { + position: fixed; + inset: 0; + display: none; + align-items: flex-start; + justify-content: center; + padding: 10vh 18px 0; + z-index: 25000; + background: rgba(4, 8, 14, 0.65); + backdrop-filter: blur(3px); +} + +.command-palette-overlay.open { + display: flex; +} + +.command-palette { + width: min(760px, 100%); + border: 1px solid var(--border-color, #1e2d3d); + border-radius: 12px; + background: #0f1823; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55); + overflow: hidden; +} + +.command-palette-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color, #1e2d3d); +} + +.command-palette-input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: var(--text-primary, #e6edf5); + font-size: 14px; + padding: 2px 0; +} + +.command-palette-hint { + font-size: 10px; + color: var(--text-dim, #8697aa); + white-space: nowrap; +} + +.command-palette-list { + max-height: min(62vh, 520px); + overflow-y: auto; +} + +.command-palette-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + background: transparent; + color: var(--text-secondary, #b1c2d4); + cursor: pointer; + text-align: left; +} + +.command-palette-item:last-child { + border-bottom: none; +} + +.command-palette-item .meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.command-palette-item .title { + color: var(--text-primary, #e6edf5); + font-size: 12px; + font-weight: 600; +} + +.command-palette-item .desc { + color: var(--text-dim, #8697aa); + font-size: 10px; +} + +.command-palette-item .kbd { + font-size: 9px; + color: var(--text-dim, #8697aa); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 1px 5px; +} + +.command-palette-item.active, +.command-palette-item:hover, +.command-palette-item:focus-visible { + background: rgba(74, 163, 255, 0.12); + outline: none; +} + +.command-palette-empty { + padding: 22px 16px; + color: var(--text-dim, #8697aa); + font-size: 11px; + text-align: center; +} + +.setup-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + z-index: 26000; + background: rgba(4, 8, 14, 0.72); + backdrop-filter: blur(4px); + padding: 14px; +} + +.setup-overlay.open { + display: flex; +} + +.setup-modal { + width: min(760px, 100%); + max-height: 84vh; + overflow-y: auto; + border: 1px solid var(--border-color, #1e2d3d); + border-radius: 12px; + background: #101926; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6); +} + +.setup-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + border-bottom: 1px solid var(--border-color, #1e2d3d); + padding: 14px; +} + +.setup-title { + font-size: 16px; + margin: 0; + color: var(--text-primary, #e6edf5); +} + +.setup-subtitle { + margin: 4px 0 0; + font-size: 11px; + color: var(--text-dim, #8697aa); +} + +.setup-close { + background: transparent; + border: none; + color: var(--text-dim, #8697aa); + font-size: 22px; + cursor: pointer; + line-height: 1; +} + +.setup-content { + padding: 14px; + display: grid; + gap: 10px; +} + +.setup-step { + border: 1px solid var(--border-color, #1e2d3d); + border-radius: 8px; + padding: 10px; + background: rgba(255, 255, 255, 0.02); +} + +.setup-step-header { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.setup-step-title { + font-size: 12px; + color: var(--text-primary, #e6edf5); + font-weight: 600; +} + +.setup-step-status { + font-size: 10px; + color: var(--text-dim, #8697aa); +} + +.setup-step-status.done { + color: var(--accent-green, #28c27a); +} + +.setup-step-desc { + font-size: 11px; + color: var(--text-secondary, #b1c2d4); + margin: 0 0 8px; +} + +.setup-step-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.setup-btn { + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--border-color, #1e2d3d); + background: var(--bg-tertiary, #121f2d); + color: var(--text-secondary, #b1c2d4); + font-size: 11px; + cursor: pointer; +} + +.setup-btn.primary { + color: #fff; + background: var(--accent-cyan, #4aa3ff); + border-color: var(--accent-cyan, #4aa3ff); +} + +.setup-footer { + padding: 12px 14px; + border-top: 1px solid var(--border-color, #1e2d3d); + display: flex; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.setup-footer-note { + color: var(--text-dim, #8697aa); + font-size: 10px; +} + +.app-toast-stack { + position: fixed; + right: 14px; + bottom: 16px; + z-index: 25500; + display: flex; + flex-direction: column; + gap: 8px; + max-width: min(380px, calc(100vw - 24px)); +} + +.app-toast { + border: 1px solid var(--border-color, #1e2d3d); + border-left: 3px solid var(--accent-cyan, #4aa3ff); + border-radius: 8px; + background: rgba(15, 24, 35, 0.97); + color: var(--text-secondary, #b1c2d4); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35); + padding: 8px 10px; + font-size: 11px; +} + +.app-toast.error { + border-left-color: var(--accent-red, #e25d5d); +} + +.app-toast.warning { + border-left-color: var(--accent-orange, #d6a85e); +} + +.app-toast-title { + font-size: 11px; + color: var(--text-primary, #e6edf5); + font-weight: 600; + margin-bottom: 4px; +} + +.app-toast-msg { + color: var(--text-secondary, #b1c2d4); +} + +.app-toast-actions { + margin-top: 7px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.app-toast-actions button { + border: 1px solid var(--border-color, #1e2d3d); + border-radius: 4px; + background: var(--bg-tertiary, #132133); + color: var(--text-secondary, #b1c2d4); + font-size: 10px; + padding: 3px 6px; + cursor: pointer; +} + +.app-toast-actions button:hover { + border-color: rgba(74, 163, 255, 0.5); + color: var(--text-primary, #e6edf5); +} + +@media (max-width: 920px) { + .run-state-strip { + flex-direction: column; + align-items: stretch; + } + + .run-state-right { + justify-content: space-between; + } +} + +@media (max-width: 640px) { + .command-palette-overlay { + padding: 8vh 10px 0; + } + + .command-palette-item { + padding: 9px 10px; + } + + .setup-header, + .setup-content, + .setup-footer { + padding: 10px; + } + + .app-toast-stack { + left: 10px; + right: 10px; + max-width: none; + } +} diff --git a/static/css/modes/analytics.css b/static/css/modes/analytics.css index baf43c9..31d6120 100644 --- a/static/css/modes/analytics.css +++ b/static/css/modes/analytics.css @@ -403,10 +403,98 @@ } /* Empty state */ -.analytics-empty { - text-align: center; - color: var(--text-dim, #5a6a7a); - font-size: var(--text-xs, 10px); - padding: var(--space-4, 16px); - font-style: italic; -} +.analytics-empty { + text-align: center; + color: var(--text-dim, #5a6a7a); + font-size: var(--text-xs, 10px); + padding: var(--space-4, 16px); + font-style: italic; +} + +.analytics-target-toolbar, +.analytics-replay-toolbar { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 10px; +} + +.analytics-target-toolbar input { + flex: 1; + min-width: 220px; + background: var(--bg-card, #151f2b); + color: var(--text-primary, #e0e6ed); + border: 1px solid var(--border-color, #1e2d3d); + border-radius: 4px; + padding: 6px 8px; + font-size: 11px; +} + +.analytics-target-toolbar button, +.analytics-replay-toolbar button, +.analytics-replay-toolbar select { + font-size: 10px; + padding: 5px 9px; + border-radius: 4px; + border: 1px solid var(--border-color, #1e2d3d); + background: var(--bg-card, #151f2b); + color: var(--text-primary, #e0e6ed); +} + +.analytics-target-toolbar button, +.analytics-replay-toolbar button { + cursor: pointer; + background: rgba(74, 163, 255, 0.2); + border-color: rgba(74, 163, 255, 0.45); +} + +.analytics-target-summary { + font-size: 10px; + color: var(--text-dim, #5a6a7a); + margin-bottom: 8px; +} + +.analytics-target-item, +.analytics-replay-item { + border-bottom: 1px solid var(--border-color, #1e2d3d); + padding: 7px 0; + display: grid; + gap: 4px; +} + +.analytics-target-item:last-child, +.analytics-replay-item:last-child { + border-bottom: none; +} + +.analytics-target-item .title, +.analytics-replay-item .title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 11px; + color: var(--text-primary, #e0e6ed); + font-weight: 600; +} + +.analytics-target-item .mode, +.analytics-replay-item .mode { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + border: 1px solid rgba(74, 163, 255, 0.35); + color: var(--accent-cyan, #4aa3ff); + border-radius: 4px; + padding: 1px 6px; +} + +.analytics-target-item .meta, +.analytics-replay-item .meta { + font-size: 10px; + color: var(--text-dim, #5a6a7a); + display: flex; + gap: 10px; + flex-wrap: wrap; +} diff --git a/static/js/core/alerts.js b/static/js/core/alerts.js index 52dcb84..4c3f8a1 100644 --- a/static/js/core/alerts.js +++ b/static/js/core/alerts.js @@ -1,11 +1,12 @@ const AlertCenter = (function() { 'use strict'; + const TRACKER_RULE_NAME = 'Tracker Detected'; + let alerts = []; let rules = []; let eventSource = null; - - const TRACKER_RULE_NAME = 'Tracker Detected'; + let reconnectTimer = null; function init() { loadRules(); @@ -17,6 +18,7 @@ const AlertCenter = (function() { if (eventSource) { eventSource.close(); } + eventSource = new EventSource('/alerts/stream'); eventSource.onmessage = function(e) { try { @@ -27,21 +29,26 @@ const AlertCenter = (function() { console.error('[Alerts] SSE parse error', err); } }; + eventSource.onerror = function() { console.warn('[Alerts] SSE connection error'); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connect, 2500); }; } function handleAlert(alert) { alerts.unshift(alert); - alerts = alerts.slice(0, 50); + alerts = alerts.slice(0, 60); updateFeedUI(); - if (typeof showNotification === 'function') { - const severity = (alert.severity || '').toLowerCase(); - if (['high', 'critical'].includes(severity)) { - showNotification(alert.title || 'Alert', alert.message || 'Alert triggered'); - } + const severity = String(alert.severity || '').toLowerCase(); + if (typeof showNotification === 'function' && ['high', 'critical'].includes(severity)) { + showNotification(alert.title || 'Alert', alert.message || 'Alert triggered'); + } + + if (typeof showAppToast === 'function' && ['high', 'critical'].includes(severity)) { + showAppToast(alert.title || 'Alert', alert.message || 'Alert triggered', 'warning'); } } @@ -56,7 +63,7 @@ const AlertCenter = (function() { return; } - list.innerHTML = alerts.map(alert => { + list.innerHTML = alerts.map((alert) => { const title = escapeHtml(alert.title || 'Alert'); const message = escapeHtml(alert.message || ''); const severity = escapeHtml(alert.severity || 'medium'); @@ -74,27 +81,218 @@ const AlertCenter = (function() { }).join(''); } + function renderRulesUI() { + const list = document.getElementById('alertsRulesList'); + if (!list) return; + + if (!rules.length) { + list.innerHTML = '
No rules yet
'; + return; + } + + list.innerHTML = rules.map((rule) => { + const enabled = Boolean(rule.enabled); + const mode = rule.mode || 'all'; + const eventType = rule.event_type || 'any'; + const severity = (rule.severity || 'medium').toUpperCase(); + const match = formatMatch(rule.match); + const statusText = enabled ? 'ENABLED' : 'DISABLED'; + + return ` +
+
+ ${escapeHtml(rule.name || 'Rule')} + ${statusText} +
+
Mode: ${escapeHtml(mode)} | Event: ${escapeHtml(eventType)} | Severity: ${escapeHtml(severity)}
+
Match: ${escapeHtml(match)}
+
+ + + +
+
+ `; + }).join(''); + } + + function formatMatch(match) { + if (!match || typeof match !== 'object' || !Object.keys(match).length) { + return 'none'; + } + const [k, v] = Object.entries(match)[0]; + return `${k}=${v}`; + } + function loadFeed() { - fetch('/alerts/events?limit=20') - .then(r => r.json()) - .then(data => { + fetch('/alerts/events?limit=30') + .then((r) => r.json()) + .then((data) => { if (data.status === 'success') { alerts = data.events || []; updateFeedUI(); } }) - .catch(err => console.error('[Alerts] Load feed failed', err)); + .catch((err) => console.error('[Alerts] Load feed failed', err)); } function loadRules() { - fetch('/alerts/rules?all=1') - .then(r => r.json()) - .then(data => { + return fetch('/alerts/rules?all=1') + .then((r) => r.json()) + .then((data) => { if (data.status === 'success') { rules = data.rules || []; + renderRulesUI(); } }) - .catch(err => console.error('[Alerts] Load rules failed', err)); + .catch((err) => { + console.error('[Alerts] Load rules failed', err); + if (typeof reportActionableError === 'function') { + reportActionableError('Alert Rules', err, { onRetry: loadRules }); + } + }); + } + + function saveRule() { + const editingId = getEditingRuleId(); + const payload = buildRulePayload(); + + if (!payload.name) { + payload.name = payload.mode ? `${payload.mode} alert` : 'Alert Rule'; + } + + const url = editingId ? `/alerts/rules/${editingId}` : '/alerts/rules'; + const method = editingId ? 'PATCH' : 'POST'; + + fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then((r) => r.json()) + .then((data) => { + if (data.status !== 'success') { + throw new Error(data.message || 'Failed to save rule'); + } + clearRuleForm(); + return loadRules(); + }) + .then(() => { + if (typeof showAppToast === 'function') { + showAppToast('Alerts', editingId ? 'Rule updated' : 'Rule created', 'info'); + } + }) + .catch((err) => { + if (typeof reportActionableError === 'function') { + reportActionableError('Save Alert Rule', err); + } + }); + } + + function buildRulePayload() { + const nameEl = document.getElementById('alertsRuleName'); + const modeEl = document.getElementById('alertsRuleMode'); + const eventTypeEl = document.getElementById('alertsRuleEventType'); + const keyEl = document.getElementById('alertsRuleMatchKey'); + const valueEl = document.getElementById('alertsRuleMatchValue'); + const severityEl = document.getElementById('alertsRuleSeverity'); + + const match = {}; + const key = keyEl ? String(keyEl.value || '').trim() : ''; + const value = valueEl ? String(valueEl.value || '').trim() : ''; + if (key && value) { + match[key] = value; + } + + return { + name: nameEl ? String(nameEl.value || '').trim() : 'Alert Rule', + mode: modeEl ? String(modeEl.value || '').trim() || null : null, + event_type: eventTypeEl ? String(eventTypeEl.value || '').trim() || null : null, + match, + severity: severityEl ? String(severityEl.value || 'medium') : 'medium', + enabled: true, + notify: { webhook: true }, + }; + } + + function clearRuleForm() { + setField('alertsRuleName', ''); + setField('alertsRuleMode', ''); + setField('alertsRuleEventType', ''); + setField('alertsRuleMatchKey', ''); + setField('alertsRuleMatchValue', ''); + setField('alertsRuleSeverity', 'medium'); + setField('alertsRuleEditingId', ''); + } + + function editRule(ruleId) { + const rule = rules.find((r) => Number(r.id) === Number(ruleId)); + if (!rule) return; + + const matchEntries = Object.entries(rule.match || {}); + const firstMatch = matchEntries.length ? matchEntries[0] : ['', '']; + + setField('alertsRuleName', rule.name || ''); + setField('alertsRuleMode', rule.mode || ''); + setField('alertsRuleEventType', rule.event_type || ''); + setField('alertsRuleMatchKey', firstMatch[0] || ''); + setField('alertsRuleMatchValue', firstMatch[1] == null ? '' : String(firstMatch[1])); + setField('alertsRuleSeverity', rule.severity || 'medium'); + setField('alertsRuleEditingId', String(rule.id)); + } + + function toggleRule(ruleId, enabled) { + fetch(`/alerts/rules/${ruleId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: Boolean(enabled) }), + }) + .then((r) => r.json()) + .then((data) => { + if (data.status !== 'success') { + throw new Error(data.message || 'Failed to update rule'); + } + return loadRules(); + }) + .catch((err) => { + if (typeof reportActionableError === 'function') { + reportActionableError('Toggle Alert Rule', err); + } + }); + } + + function deleteRule(ruleId) { + if (!confirm('Delete this alert rule?')) return; + + fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' }) + .then((r) => r.json()) + .then((data) => { + if (data.status !== 'success') { + throw new Error(data.message || 'Failed to delete rule'); + } + if (Number(getEditingRuleId()) === Number(ruleId)) { + clearRuleForm(); + } + return loadRules(); + }) + .catch((err) => { + if (typeof reportActionableError === 'function') { + reportActionableError('Delete Alert Rule', err); + } + }); + } + + function getEditingRuleId() { + const el = document.getElementById('alertsRuleEditingId'); + if (!el || !el.value) return null; + const parsed = Number(el.value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + function setField(id, value) { + const el = document.getElementById(id); + if (!el) return; + el.value = value; } function enableTrackerAlerts() { @@ -106,17 +304,18 @@ const AlertCenter = (function() { } function ensureTrackerRule(enabled) { - loadRules(); - setTimeout(() => { - const existing = rules.find(r => r.name === TRACKER_RULE_NAME); + loadRules().then(() => { + const existing = rules.find((r) => r.name === TRACKER_RULE_NAME); if (existing) { - fetch(`/alerts/rules/${existing.id}`, { + return fetch(`/alerts/rules/${existing.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) + body: JSON.stringify({ enabled }), }).then(() => loadRules()); - } else if (enabled) { - fetch('/alerts/rules', { + } + + if (enabled) { + return fetch('/alerts/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -126,44 +325,49 @@ const AlertCenter = (function() { match: { is_tracker: true }, severity: 'high', enabled: true, - notify: { webhook: true } - }) + notify: { webhook: true }, + }), }).then(() => loadRules()); } - }, 150); + return null; + }); } function addBluetoothWatchlist(address, name) { if (!address) return; - const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address); - if (existing) { - return; - } + const upper = String(address).toUpperCase(); + const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper); + if (existing) return; + fetch('/alerts/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - name: name ? `Watchlist ${name}` : `Watchlist ${address}`, + name: name ? `Watchlist ${name}` : `Watchlist ${upper}`, mode: 'bluetooth', event_type: 'device_update', - match: { address: address }, + match: { address: upper }, severity: 'medium', enabled: true, - notify: { webhook: true } - }) + notify: { webhook: true }, + }), }).then(() => loadRules()); } function removeBluetoothWatchlist(address) { if (!address) return; - const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address); + const upper = String(address).toUpperCase(); + const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper); if (!existing) return; + fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' }) .then(() => loadRules()); } function isWatchlisted(address) { - return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled); + if (!address) return false; + const upper = String(address).toUpperCase(); + return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled); } function escapeHtml(str) { @@ -179,6 +383,12 @@ const AlertCenter = (function() { return { init, loadFeed, + loadRules, + saveRule, + clearRuleForm, + editRule, + toggleRule, + deleteRule, enableTrackerAlerts, disableTrackerAlerts, addBluetoothWatchlist, diff --git a/static/js/core/app.js b/static/js/core/app.js index 6eaf918..f6a7d49 100644 --- a/static/js/core/app.js +++ b/static/js/core/app.js @@ -36,12 +36,12 @@ let observerLocation = (function() { return ObserverLocation.getForModule('observerLocation'); } const saved = localStorage.getItem('observerLocation'); - if (saved) { - try { - const parsed = JSON.parse(saved); - if (parsed.lat && parsed.lon) return parsed; - } catch (e) {} - } + if (saved) { + try { + const parsed = JSON.parse(saved); + if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed; + } catch (e) {} + } return { lat: 51.5074, lon: -0.1278 }; })(); diff --git a/static/js/core/command-palette.js b/static/js/core/command-palette.js new file mode 100644 index 0000000..8f8e1c2 --- /dev/null +++ b/static/js/core/command-palette.js @@ -0,0 +1,322 @@ +const CommandPalette = (function() { + 'use strict'; + + let overlayEl = null; + let inputEl = null; + let listEl = null; + let isOpen = false; + let activeIndex = 0; + let filteredItems = []; + + const modeCommands = [ + { mode: 'pager', label: 'Pager' }, + { mode: 'sensor', label: '433MHz Sensors' }, + { mode: 'rtlamr', label: 'Meters' }, + { mode: 'listening', label: 'Listening Post' }, + { mode: 'subghz', label: 'SubGHz' }, + { mode: 'aprs', label: 'APRS' }, + { mode: 'wifi', label: 'WiFi Scanner' }, + { mode: 'bluetooth', label: 'Bluetooth Scanner' }, + { mode: 'bt_locate', label: 'BT Locate' }, + { mode: 'satellite', label: 'Satellite' }, + { mode: 'sstv', label: 'ISS SSTV' }, + { mode: 'weathersat', label: 'Weather Sat' }, + { mode: 'sstv_general', label: 'HF SSTV' }, + { mode: 'gps', label: 'GPS' }, + { mode: 'meshtastic', label: 'Meshtastic' }, + { mode: 'dmr', label: 'Digital Voice' }, + { mode: 'websdr', label: 'WebSDR' }, + { mode: 'analytics', label: 'Analytics' }, + { mode: 'spaceweather', label: 'Space Weather' }, + ]; + + function init() { + buildDOM(); + registerHotkeys(); + renderItems(''); + } + + function buildDOM() { + overlayEl = document.createElement('div'); + overlayEl.className = 'command-palette-overlay'; + overlayEl.id = 'commandPaletteOverlay'; + overlayEl.addEventListener('click', (event) => { + if (event.target === overlayEl) close(); + }); + + const palette = document.createElement('div'); + palette.className = 'command-palette'; + + const header = document.createElement('div'); + header.className = 'command-palette-header'; + + inputEl = document.createElement('input'); + inputEl.className = 'command-palette-input'; + inputEl.type = 'text'; + inputEl.autocomplete = 'off'; + inputEl.placeholder = 'Search commands and modes...'; + inputEl.setAttribute('aria-label', 'Command Palette Search'); + inputEl.addEventListener('input', () => { + renderItems(inputEl.value || ''); + }); + inputEl.addEventListener('keydown', onInputKeyDown); + + const hint = document.createElement('span'); + hint.className = 'command-palette-hint'; + hint.textContent = 'Esc close'; + + header.appendChild(inputEl); + header.appendChild(hint); + + listEl = document.createElement('div'); + listEl.className = 'command-palette-list'; + listEl.id = 'commandPaletteList'; + + palette.appendChild(header); + palette.appendChild(listEl); + overlayEl.appendChild(palette); + document.body.appendChild(overlayEl); + } + + function registerHotkeys() { + document.addEventListener('keydown', (event) => { + const cmdK = (event.key.toLowerCase() === 'k') && (event.ctrlKey || event.metaKey); + if (cmdK) { + event.preventDefault(); + if (isOpen) { + close(); + } else { + open(); + } + return; + } + + if (!isOpen) return; + if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }); + } + + function onInputKeyDown(event) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + activeIndex = Math.min(activeIndex + 1, Math.max(filteredItems.length - 1, 0)); + renderSelection(); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + activeIndex = Math.max(activeIndex - 1, 0); + renderSelection(); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + const item = filteredItems[activeIndex]; + if (item && typeof item.run === 'function') { + item.run(); + } + close(); + } + } + + function getCommands() { + const commands = [ + { + title: 'Open Settings', + description: 'Open global settings panel', + keyword: 'settings configure tools', + shortcut: 'S', + run: () => { + if (typeof showSettings === 'function') showSettings(); + } + }, + { + title: 'Settings: Alerts', + description: 'Open alert rules and feed', + keyword: 'settings alerts rule', + run: () => openSettingsTab('alerts') + }, + { + title: 'Settings: Recording', + description: 'Open recording manager', + keyword: 'settings recording replay', + run: () => openSettingsTab('recording') + }, + { + title: 'Settings: Location', + description: 'Configure observer location', + keyword: 'settings location gps lat lon', + run: () => openSettingsTab('location') + }, + { + title: 'View Aircraft Dashboard', + description: 'Open dedicated ADS-B dashboard page', + keyword: 'aircraft adsb dashboard', + run: () => { window.location.href = '/adsb/dashboard'; } + }, + { + title: 'View Vessel Dashboard', + description: 'Open dedicated AIS dashboard page', + keyword: 'vessel ais dashboard', + run: () => { window.location.href = '/ais/dashboard'; } + }, + { + title: 'Kill All Running Processes', + description: 'Stop all decoders and scans', + keyword: 'kill stop processes emergency', + run: () => { + if (typeof killAll === 'function') { + killAll(); + } else if (typeof fetch === 'function') { + fetch('/killall', { method: 'POST' }); + } + } + }, + { + title: 'Toggle Sidebar Width', + description: 'Collapse or expand the left sidebar', + keyword: 'sidebar collapse layout', + run: () => { + if (typeof toggleMainSidebarCollapse === 'function') { + toggleMainSidebarCollapse(); + } + } + }, + ]; + + for (const modeEntry of modeCommands) { + commands.push({ + title: `Switch Mode: ${modeEntry.label}`, + description: 'Navigate directly to mode', + keyword: `mode ${modeEntry.mode} ${modeEntry.label.toLowerCase()}`, + run: () => goToMode(modeEntry.mode), + }); + } + + return commands; + } + + function renderItems(query) { + const q = String(query || '').trim().toLowerCase(); + const allItems = getCommands(); + + filteredItems = allItems.filter((item) => { + if (!q) return true; + const haystack = `${item.title} ${item.description || ''} ${item.keyword || ''}`.toLowerCase(); + return haystack.includes(q); + }).slice(0, 80); + + activeIndex = 0; + + listEl.innerHTML = ''; + if (filteredItems.length === 0) { + const empty = document.createElement('div'); + empty.className = 'command-palette-empty'; + empty.textContent = 'No matching commands'; + listEl.appendChild(empty); + return; + } + + filteredItems.forEach((item, idx) => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'command-palette-item'; + row.dataset.index = String(idx); + row.addEventListener('click', () => { + item.run(); + close(); + }); + + const meta = document.createElement('span'); + meta.className = 'meta'; + + const title = document.createElement('span'); + title.className = 'title'; + title.textContent = item.title; + meta.appendChild(title); + + const desc = document.createElement('span'); + desc.className = 'desc'; + desc.textContent = item.description || ''; + meta.appendChild(desc); + + row.appendChild(meta); + + if (item.shortcut) { + const kbd = document.createElement('span'); + kbd.className = 'kbd'; + kbd.textContent = item.shortcut; + row.appendChild(kbd); + } + + listEl.appendChild(row); + }); + + renderSelection(); + } + + function renderSelection() { + const rows = listEl.querySelectorAll('.command-palette-item'); + rows.forEach((row) => { + const idx = Number(row.dataset.index || 0); + row.classList.toggle('active', idx === activeIndex); + }); + + const activeRow = listEl.querySelector(`.command-palette-item[data-index="${activeIndex}"]`); + if (activeRow) { + activeRow.scrollIntoView({ block: 'nearest' }); + } + } + + function goToMode(mode) { + const welcome = document.getElementById('welcomePage'); + if (welcome && getComputedStyle(welcome).display !== 'none') { + welcome.style.display = 'none'; + } + + if (typeof switchMode === 'function') { + switchMode(mode, { updateUrl: true }); + } + } + + function openSettingsTab(tab) { + if (typeof showSettings === 'function') { + showSettings(); + } + if (typeof switchSettingsTab === 'function') { + switchSettingsTab(tab); + } + } + + function open() { + if (!overlayEl) return; + isOpen = true; + overlayEl.classList.add('open'); + renderItems(''); + inputEl.value = ''; + requestAnimationFrame(() => { + inputEl.focus(); + }); + } + + function close() { + if (!overlayEl) return; + isOpen = false; + overlayEl.classList.remove('open'); + } + + return { + init, + open, + close, + }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + CommandPalette.init(); +}); diff --git a/static/js/core/first-run-setup.js b/static/js/core/first-run-setup.js new file mode 100644 index 0000000..8ed64d9 --- /dev/null +++ b/static/js/core/first-run-setup.js @@ -0,0 +1,373 @@ +const FirstRunSetup = (function() { + 'use strict'; + + const COMPLETE_KEY = 'intercept.setup.complete.v1'; + const DEFAULT_MODE_KEY = 'intercept.default_mode'; + + let overlayEl = null; + let depsStatusEl = null; + let locationStatusEl = null; + let notifyStatusEl = null; + let modeStatusEl = null; + let modeSelectEl = null; + + let dependencyReady = null; + + function init() { + buildDOM(); + maybeShow(); + } + + function maybeShow() { + if (localStorage.getItem(COMPLETE_KEY) === 'true') return; + + if (localStorage.getItem('disclaimerAccepted') === 'true') { + open(); + refreshStatuses(); + return; + } + + let attempts = 0; + const waitTimer = setInterval(() => { + attempts += 1; + if (localStorage.getItem(COMPLETE_KEY) === 'true') { + clearInterval(waitTimer); + return; + } + if (localStorage.getItem('disclaimerAccepted') === 'true') { + clearInterval(waitTimer); + open(); + refreshStatuses(); + } + if (attempts > 30) { + clearInterval(waitTimer); + } + }, 1000); + } + + function buildDOM() { + overlayEl = document.createElement('div'); + overlayEl.id = 'firstRunSetupOverlay'; + overlayEl.className = 'setup-overlay'; + + const modal = document.createElement('div'); + modal.className = 'setup-modal'; + + const header = document.createElement('div'); + header.className = 'setup-header'; + + const headingWrap = document.createElement('div'); + const title = document.createElement('h2'); + title.className = 'setup-title'; + title.textContent = 'Quick Setup'; + headingWrap.appendChild(title); + + const subtitle = document.createElement('p'); + subtitle.className = 'setup-subtitle'; + subtitle.textContent = 'Complete these checks once so all modes work reliably.'; + headingWrap.appendChild(subtitle); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'setup-close'; + closeBtn.textContent = '×'; + closeBtn.setAttribute('aria-label', 'Close setup assistant'); + closeBtn.addEventListener('click', close); + + header.appendChild(headingWrap); + header.appendChild(closeBtn); + + const content = document.createElement('div'); + content.className = 'setup-content'; + + const depsStep = createStep( + 'Dependencies', + 'Verify required tools are installed for enabled modes.', + (statusEl, actionsEl) => { + depsStatusEl = statusEl; + + const checkBtn = buildButton('Recheck', () => checkDependencies()); + const openToolsBtn = buildButton('Open Tools', () => { + if (typeof showSettings === 'function') showSettings(); + if (typeof switchSettingsTab === 'function') switchSettingsTab('tools'); + }); + actionsEl.appendChild(checkBtn); + actionsEl.appendChild(openToolsBtn); + } + ); + + const locationStep = createStep( + 'Observer Location', + 'Set latitude/longitude for pass prediction and mapping features.', + (statusEl, actionsEl) => { + locationStatusEl = statusEl; + actionsEl.appendChild(buildButton('Open Location', () => { + if (typeof showSettings === 'function') showSettings(); + if (typeof switchSettingsTab === 'function') switchSettingsTab('location'); + })); + actionsEl.appendChild(buildButton('Recheck', refreshStatuses)); + } + ); + + const notifyStep = createStep( + 'Desktop Alerts', + 'Allow notifications so high-priority alerts are visible when the tab is hidden.', + (statusEl, actionsEl) => { + notifyStatusEl = statusEl; + actionsEl.appendChild(buildButton('Enable Notifications', requestNotifications)); + } + ); + + const modeStep = createStep( + 'Default Start Mode', + 'Choose which mode should be selected by default.', + (statusEl, actionsEl) => { + modeStatusEl = statusEl; + + modeSelectEl = document.createElement('select'); + modeSelectEl.className = 'setup-btn'; + const modes = [ + ['pager', 'Pager'], + ['sensor', '433MHz'], + ['rtlamr', 'Meters'], + ['listening', 'Listening Post'], + ['wifi', 'WiFi'], + ['bluetooth', 'Bluetooth'], + ['bt_locate', 'BT Locate'], + ['aprs', 'APRS'], + ['satellite', 'Satellite'], + ['sstv', 'ISS SSTV'], + ['weathersat', 'Weather Sat'], + ['sstv_general', 'HF SSTV'], + ['analytics', 'Analytics'], + ]; + for (const [value, label] of modes) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + modeSelectEl.appendChild(opt); + } + + const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY); + if (savedDefaultMode) { + modeSelectEl.value = savedDefaultMode; + } + + actionsEl.appendChild(modeSelectEl); + actionsEl.appendChild(buildButton('Save', () => { + const selected = modeSelectEl.value || 'pager'; + localStorage.setItem(DEFAULT_MODE_KEY, selected); + refreshStatuses(); + if (typeof showAppToast === 'function') { + showAppToast('Default Mode Saved', `New sessions will default to ${selected}.`, 'info'); + } + })); + } + ); + + content.appendChild(depsStep); + content.appendChild(locationStep); + content.appendChild(notifyStep); + content.appendChild(modeStep); + + const footer = document.createElement('div'); + footer.className = 'setup-footer'; + + const note = document.createElement('span'); + note.className = 'setup-footer-note'; + note.textContent = 'You can reopen these options anytime in Settings.'; + + const footerActions = document.createElement('div'); + footerActions.style.display = 'inline-flex'; + footerActions.style.gap = '8px'; + + const laterBtn = buildButton('Remind Me Later', close); + const completeBtn = buildButton('Mark Setup Complete', completeSetup, true); + completeBtn.id = 'setupCompleteBtn'; + + footerActions.appendChild(laterBtn); + footerActions.appendChild(completeBtn); + + footer.appendChild(note); + footer.appendChild(footerActions); + + modal.appendChild(header); + modal.appendChild(content); + modal.appendChild(footer); + + overlayEl.appendChild(modal); + document.body.appendChild(overlayEl); + } + + function createStep(title, description, initActions) { + const root = document.createElement('div'); + root.className = 'setup-step'; + + const header = document.createElement('div'); + header.className = 'setup-step-header'; + + const titleEl = document.createElement('span'); + titleEl.className = 'setup-step-title'; + titleEl.textContent = title; + + const statusEl = document.createElement('span'); + statusEl.className = 'setup-step-status'; + statusEl.textContent = 'Pending'; + + header.appendChild(titleEl); + header.appendChild(statusEl); + + const descEl = document.createElement('p'); + descEl.className = 'setup-step-desc'; + descEl.textContent = description; + + const actionsEl = document.createElement('div'); + actionsEl.className = 'setup-step-actions'; + + if (typeof initActions === 'function') { + initActions(statusEl, actionsEl); + } + + root.appendChild(header); + root.appendChild(descEl); + root.appendChild(actionsEl); + return root; + } + + function buildButton(label, onClick, primary) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `setup-btn${primary ? ' primary' : ''}`; + btn.textContent = label; + btn.addEventListener('click', onClick); + return btn; + } + + async function checkDependencies() { + if (depsStatusEl) depsStatusEl.textContent = 'Checking...'; + try { + const response = await fetch('/dependencies'); + const data = await response.json(); + if (data.status !== 'success') { + dependencyReady = false; + } else { + const modes = Object.values(data.modes || {}); + dependencyReady = modes.every((modeInfo) => Boolean(modeInfo.ready)); + } + } catch (err) { + dependencyReady = false; + if (typeof reportActionableError === 'function') { + reportActionableError('Dependency Check', err, { + onRetry: checkDependencies, + }); + } + } + refreshStatuses(); + } + + function refreshStatuses() { + const hasLocation = hasValidLocation(); + const notifications = notificationStatus(); + const hasDefaultMode = Boolean(localStorage.getItem(DEFAULT_MODE_KEY)); + + setStatus(locationStatusEl, hasLocation, hasLocation ? 'Configured' : 'Not set'); + setStatus(notifyStatusEl, notifications.ready, notifications.label); + setStatus(modeStatusEl, hasDefaultMode, hasDefaultMode ? localStorage.getItem(DEFAULT_MODE_KEY) : 'Not set'); + + if (dependencyReady === null) { + checkDependencies(); + return; + } + setStatus(depsStatusEl, dependencyReady, dependencyReady ? 'Ready' : 'Missing tools'); + + const doneCount = Number(dependencyReady) + Number(hasLocation) + Number(notifications.ready) + Number(hasDefaultMode); + const completeBtn = document.getElementById('setupCompleteBtn'); + if (completeBtn) { + completeBtn.textContent = doneCount >= 3 ? 'Mark Setup Complete' : 'Complete Anyway'; + } + } + + function setStatus(el, done, label) { + if (!el) return; + el.classList.toggle('done', Boolean(done)); + el.textContent = String(label || (done ? 'Done' : 'Pending')); + } + + function hasValidLocation() { + const rawLat = localStorage.getItem('observerLat'); + const rawLon = localStorage.getItem('observerLon'); + + if (rawLat === null || rawLon === null || rawLat === '' || rawLon === '') { + return false; + } + + const lat = Number(rawLat); + const lon = Number(rawLon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; + } + + function notificationStatus() { + if (!('Notification' in window)) { + return { ready: true, label: 'Unsupported (optional)' }; + } + + if (Notification.permission === 'granted') { + return { ready: true, label: 'Enabled' }; + } + + if (Notification.permission === 'denied') { + return { ready: false, label: 'Blocked in browser' }; + } + + return { ready: false, label: 'Permission needed' }; + } + + async function requestNotifications() { + if (!('Notification' in window)) { + refreshStatuses(); + return; + } + + try { + await Notification.requestPermission(); + } catch (err) { + if (typeof reportActionableError === 'function') { + reportActionableError('Notifications', err); + } + } + refreshStatuses(); + } + + function completeSetup() { + localStorage.setItem(COMPLETE_KEY, 'true'); + close(); + + if (typeof showAppToast === 'function') { + showAppToast('Setup Complete', 'You can revisit these options in Settings.', 'info'); + } + } + + function open() { + if (!overlayEl) return; + overlayEl.classList.add('open'); + } + + function close() { + if (!overlayEl) return; + overlayEl.classList.remove('open'); + } + + return { + init, + open, + close, + refreshStatuses, + completeSetup, + }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + FirstRunSetup.init(); +}); diff --git a/static/js/core/recordings.js b/static/js/core/recordings.js index d4188b7..8aba475 100644 --- a/static/js/core/recordings.js +++ b/static/js/core/recordings.js @@ -96,7 +96,10 @@ const RecordingUI = (function() {
${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''} - +
+ + +
${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}
Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}
@@ -109,6 +112,17 @@ const RecordingUI = (function() { window.open(`/recordings/${sessionId}/download`, '_blank'); } + function openReplay(sessionId) { + if (!sessionId) return; + localStorage.setItem('analyticsReplaySession', sessionId); + if (typeof hideSettings === 'function') hideSettings(); + if (typeof switchMode === 'function') { + switchMode('analytics', { updateUrl: true }); + return; + } + window.location.href = '/?mode=analytics'; + } + function escapeHtml(str) { if (!str) return ''; return String(str) @@ -126,6 +140,7 @@ const RecordingUI = (function() { stop, stopById, download, + openReplay, }; })(); diff --git a/static/js/core/run-state.js b/static/js/core/run-state.js new file mode 100644 index 0000000..900f27f --- /dev/null +++ b/static/js/core/run-state.js @@ -0,0 +1,206 @@ +const RunState = (function() { + 'use strict'; + + const REFRESH_MS = 5000; + const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz']; + + const modeLabels = { + pager: 'Pager', + sensor: '433', + wifi: 'WiFi', + bluetooth: 'BT', + adsb: 'ADS-B', + ais: 'AIS', + acars: 'ACARS', + vdl2: 'VDL2', + aprs: 'APRS', + dsc: 'DSC', + dmr: 'DMR', + subghz: 'SubGHz', + }; + + let refreshTimer = null; + let activeMode = null; + let lastHealth = null; + let lastErrorToastAt = 0; + + function init() { + const root = document.getElementById('runStateStrip'); + if (!root) return; + + wireActions(); + wrapModeSwitch(); + activeMode = inferCurrentMode(); + renderHealth(null); + refresh(); + + if (!refreshTimer) { + refreshTimer = window.setInterval(refresh, REFRESH_MS); + } + + document.addEventListener('visibilitychange', () => { + if (!document.hidden) refresh(); + }); + } + + function wireActions() { + const refreshBtn = document.getElementById('runStateRefreshBtn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => refresh()); + } + + const settingsBtn = document.getElementById('runStateSettingsBtn'); + if (settingsBtn) { + settingsBtn.addEventListener('click', () => { + if (typeof showSettings === 'function') { + showSettings(); + if (typeof switchSettingsTab === 'function') { + switchSettingsTab('tools'); + } + } + }); + } + } + + function wrapModeSwitch() { + if (typeof window.switchMode !== 'function') return; + if (window.switchMode.__runStateWrapped) return; + + const original = window.switchMode; + const wrapped = function(mode) { + if (mode) { + activeMode = String(mode); + } + const result = original.apply(this, arguments); + markActiveChip(); + return result; + }; + wrapped.__runStateWrapped = true; + window.switchMode = wrapped; + } + + async function refresh() { + try { + const response = await fetch('/health'); + const data = await response.json(); + lastHealth = data; + renderHealth(data); + } catch (err) { + renderHealth(null, err); + const now = Date.now(); + if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) { + lastErrorToastAt = now; + reportActionableError('Run State', err, { persistent: false }); + } + } + } + + function renderHealth(data, err) { + const chipsContainer = document.getElementById('runStateChips'); + const summaryEl = document.getElementById('runStateSummary'); + if (!chipsContainer || !summaryEl) return; + + chipsContainer.innerHTML = ''; + + if (!data || data.status !== 'healthy') { + const offline = buildChip('API', false); + offline.classList.add('active'); + chipsContainer.appendChild(offline); + summaryEl.textContent = err ? `Health unavailable: ${extractMessage(err)}` : 'Health unavailable'; + return; + } + + const processes = data.processes || {}; + for (const mode of CHIP_MODES) { + const isRunning = Boolean(processes[mode]); + chipsContainer.appendChild(buildChip(modeLabels[mode] || mode.toUpperCase(), isRunning, mode)); + } + + const counts = data.data || {}; + summaryEl.textContent = `Aircraft ${counts.aircraft_count || 0} | Vessels ${counts.vessel_count || 0} | WiFi ${counts.wifi_networks_count || 0} | BT ${counts.bt_devices_count || 0}`; + markActiveChip(); + } + + function buildChip(label, running, mode) { + const chip = document.createElement('span'); + chip.className = `run-state-chip${running ? ' running' : ''}`; + if (mode) { + chip.dataset.mode = mode; + } + + const dot = document.createElement('span'); + dot.className = 'dot'; + chip.appendChild(dot); + + const text = document.createElement('span'); + text.textContent = label; + chip.appendChild(text); + + return chip; + } + + function markActiveChip() { + if (!activeMode) { + activeMode = inferCurrentMode(); + } + + document.querySelectorAll('#runStateChips .run-state-chip').forEach((chip) => { + chip.classList.remove('active'); + if (chip.dataset.mode && chip.dataset.mode === activeMode) { + chip.classList.add('active'); + } + }); + } + + function inferCurrentMode() { + const modeParam = new URLSearchParams(window.location.search).get('mode'); + if (modeParam) return modeParam; + + const indicator = document.getElementById('activeModeIndicator'); + if (!indicator) return 'pager'; + + const text = indicator.textContent || ''; + const normalized = text.toLowerCase(); + if (normalized.includes('wifi')) return 'wifi'; + if (normalized.includes('bluetooth')) return 'bluetooth'; + if (normalized.includes('ads-b')) return 'adsb'; + if (normalized.includes('ais')) return 'ais'; + if (normalized.includes('acars')) return 'acars'; + if (normalized.includes('vdl2')) return 'vdl2'; + if (normalized.includes('aprs')) return 'aprs'; + if (normalized.includes('dsc')) return 'dsc'; + if (normalized.includes('subghz')) return 'subghz'; + if (normalized.includes('dmr')) return 'dmr'; + if (normalized.includes('433')) return 'sensor'; + return 'pager'; + } + + function extractMessage(err) { + if (!err) return 'Unknown error'; + if (typeof err === 'string') return err; + if (err.message) return err.message; + return String(err); + } + + function getLastHealth() { + return lastHealth; + } + + function destroy() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + + return { + init, + refresh, + destroy, + getLastHealth, + }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + RunState.init(); +}); diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 6cfe2a3..85c3ea0 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -594,7 +594,7 @@ function loadObserverLocation() { } // Sync dashboard-specific location keys for backward compatibility - if (lat && lon) { + if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') { const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) }); if (!localStorage.getItem('observerLocation')) { localStorage.setItem('observerLocation', locationObj); diff --git a/static/js/core/ui-feedback.js b/static/js/core/ui-feedback.js new file mode 100644 index 0000000..4132888 --- /dev/null +++ b/static/js/core/ui-feedback.js @@ -0,0 +1,212 @@ +const AppFeedback = (function() { + 'use strict'; + + let stackEl = null; + let nextToastId = 1; + + function init() { + ensureStack(); + installGlobalHandlers(); + } + + function ensureStack() { + if (stackEl && document.body.contains(stackEl)) return stackEl; + + stackEl = document.getElementById('appToastStack'); + if (!stackEl) { + stackEl = document.createElement('div'); + stackEl.id = 'appToastStack'; + stackEl.className = 'app-toast-stack'; + document.body.appendChild(stackEl); + } + return stackEl; + } + + function toast(options) { + const opts = options || {}; + const type = normalizeType(opts.type); + const id = nextToastId++; + const durationMs = Number.isFinite(opts.durationMs) ? opts.durationMs : 6500; + + const root = document.createElement('div'); + root.className = `app-toast ${type}`; + root.dataset.toastId = String(id); + + const titleEl = document.createElement('div'); + titleEl.className = 'app-toast-title'; + titleEl.textContent = String(opts.title || defaultTitle(type)); + root.appendChild(titleEl); + + const msgEl = document.createElement('div'); + msgEl.className = 'app-toast-msg'; + msgEl.textContent = String(opts.message || ''); + root.appendChild(msgEl); + + const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean).slice(0, 3) : []; + if (actions.length > 0) { + const actionsEl = document.createElement('div'); + actionsEl.className = 'app-toast-actions'; + for (const action of actions) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = String(action.label || 'Action'); + btn.addEventListener('click', () => { + try { + if (typeof action.onClick === 'function') { + action.onClick(); + } + } finally { + removeToast(id); + } + }); + actionsEl.appendChild(btn); + } + root.appendChild(actionsEl); + } + + ensureStack().appendChild(root); + + if (durationMs > 0) { + window.setTimeout(() => { + removeToast(id); + }, durationMs); + } + + return id; + } + + function removeToast(id) { + if (!stackEl) return; + const toastEl = stackEl.querySelector(`[data-toast-id="${id}"]`); + if (!toastEl) return; + toastEl.remove(); + } + + function reportError(context, error, options) { + const opts = options || {}; + const message = extractMessage(error); + const actions = []; + + if (isSettingsError(message)) { + actions.push({ + label: 'Open Settings', + onClick: () => { + if (typeof showSettings === 'function') { + showSettings(); + } + } + }); + } + + if (isNetworkError(message)) { + actions.push({ + label: 'Retry', + onClick: () => { + if (typeof opts.onRetry === 'function') { + opts.onRetry(); + } + } + }); + } + + if (typeof opts.extraAction === 'function' && opts.extraActionLabel) { + actions.push({ + label: String(opts.extraActionLabel), + onClick: opts.extraAction, + }); + } + + return toast({ + type: 'error', + title: context || 'Action Failed', + message, + actions, + durationMs: opts.persistent ? 0 : 8500, + }); + } + + function installGlobalHandlers() { + window.addEventListener('error', (event) => { + const target = event && event.target; + if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) { + return; + } + + const message = extractMessage(event && event.error) || String(event.message || 'Unknown error'); + if (shouldIgnore(message)) return; + toast({ + type: 'warning', + title: 'Unhandled Error', + message, + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + const message = extractMessage(event && event.reason); + if (shouldIgnore(message)) return; + toast({ + type: 'warning', + title: 'Promise Rejection', + message, + }); + }); + } + + function normalizeType(type) { + const t = String(type || 'info').toLowerCase(); + if (t === 'error' || t === 'warning') return t; + return 'info'; + } + + function defaultTitle(type) { + if (type === 'error') return 'Error'; + if (type === 'warning') return 'Warning'; + return 'Notice'; + } + + function extractMessage(error) { + if (!error) return 'Unknown error'; + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message || error.name; + if (typeof error.message === 'string') return error.message; + return String(error); + } + + function shouldIgnore(message) { + const text = String(message || '').toLowerCase(); + return text.includes('script error') || text.includes('resizeobserver loop limit exceeded'); + } + + function isNetworkError(message) { + const text = String(message || '').toLowerCase(); + return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout'); + } + + function isSettingsError(message) { + const text = String(message || '').toLowerCase(); + return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool'); + } + + return { + init, + toast, + reportError, + removeToast, + }; +})(); + +window.showAppToast = function(title, message, type) { + return AppFeedback.toast({ + title, + message, + type, + }); +}; + +window.reportActionableError = function(context, error, options) { + return AppFeedback.reportError(context, error, options); +}; + +document.addEventListener('DOMContentLoaded', () => { + AppFeedback.init(); +}); diff --git a/static/js/core/updater.js b/static/js/core/updater.js index d14b63f..3f4eb25 100644 --- a/static/js/core/updater.js +++ b/static/js/core/updater.js @@ -78,13 +78,14 @@ const Updater = { * 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 = ` + showUpdateToast(data) { + // Remove existing toast if present + this.hideToast(); + const latestVersion = this._escape(data.latest_version || ''); + + const toast = document.createElement('div'); + toast.className = 'update-toast'; + toast.innerHTML = `
@@ -97,11 +98,11 @@ const Updater = { Update Available -
-
- Version ${data.latest_version} is ready -
-
+
+
+ Version ${latestVersion} is ready +
+
@@ -172,14 +173,17 @@ const Updater = { 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'; + // Remove existing modal if present + this.hideModal(); + + const data = this._updateData; + const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.'); + const safeCurrentVersion = this._escape(data.current_version || ''); + const safeLatestVersion = this._escape(data.latest_version || ''); + const safeReleaseUrl = this._safeUrl(data.release_url || ''); + + const modal = document.createElement('div'); + modal.className = 'update-modal-overlay'; modal.onclick = (e) => { if (e.target === modal) this.hideModal(); }; @@ -201,21 +205,21 @@ const Updater = {
-
- Current - v${data.current_version} -
+
+ Current + v${safeCurrentVersion} +
-
- Latest - v${data.latest_version} -
-
+
+ Latest + v${safeLatestVersion} +
+
Release Notes
@@ -249,11 +253,11 @@ const Updater = {
-
- +
${message}
`; - } else { - resultEl.className = 'update-result update-result-info'; - resultEl.innerHTML = ` + } else { + resultEl.className = 'update-result update-result-info'; + resultEl.innerHTML = `
-
-
${data.message || 'Already up to date.'}
- `; - } - } else { - if (isManual) { - resultEl.className = 'update-result update-result-warning'; +
+
${this._escape(data.message || 'Already up to date.')}
+ `; + } + } else { + if (isManual) { + resultEl.className = 'update-result update-result-warning'; resultEl.innerHTML = `
@@ -403,14 +409,14 @@ const Updater = { -
-
- Manual update required
- ${data.message || 'Please download the latest release from GitHub.'} -
- `; - } else { - resultEl.className = 'update-result update-result-error'; + +
+ Manual update required
+ ${safeMessage || 'Please download the latest release from GitHub.'} +
+ `; + } else { + resultEl.className = 'update-result update-result-error'; resultEl.innerHTML = `
@@ -418,16 +424,16 @@ const Updater = { -
-
- Update failed
- ${data.message || data.error || 'An error occurred during the update.'} - ${data.details ? '
' + data.details.substring(0, 200) + '' : ''} -
- `; - } - } - }, + +
+ Update failed
+ ${safeMessage} + ${safeDetails ? '
' + safeDetails + '' : ''} +
+ `; + } + } + }, /** * Format release notes (basic markdown to HTML) @@ -461,11 +467,33 @@ const Updater = { // Line breaks .replace(/\n/g, '
'); - // Wrap list items - html = html.replace(/(
  • .*<\/li>)+/g, '
      $&
    '); - - return '

    ' + html + '

    '; - }, + // Wrap list items + html = html.replace(/(
  • .*<\/li>)+/g, '
      $&
    '); + + return '

    ' + html + '

    '; + }, + + _escape(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, + + _safeUrl(url) { + if (!url) return ''; + try { + const parsed = new URL(url, window.location.origin); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.href; + } + } catch (e) { + return ''; + } + return ''; + }, /** * Manual trigger for settings panel diff --git a/static/js/modes/analytics.js b/static/js/modes/analytics.js index c521c2a..11586fd 100644 --- a/static/js/modes/analytics.js +++ b/static/js/modes/analytics.js @@ -1,26 +1,32 @@ -/** - * Analytics Dashboard Module - * Cross-mode summary, sparklines, alerts, correlations, geofence management, export. - */ -const Analytics = (function () { - 'use strict'; - - let refreshTimer = null; - - function init() { - refresh(); - if (!refreshTimer) { - refreshTimer = setInterval(refresh, 5000); - } - } - - function destroy() { - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } - } - +/** + * Analytics Dashboard Module + * Cross-mode summary, sparklines, alerts, correlations, target view, and replay. + */ +const Analytics = (function () { + 'use strict'; + + let refreshTimer = null; + let replayTimer = null; + let replaySessions = []; + let replayEvents = []; + let replayIndex = 0; + + function init() { + refresh(); + loadReplaySessions(); + if (!refreshTimer) { + refreshTimer = setInterval(refresh, 5000); + } + } + + function destroy() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + pauseReplay(); + } + function refresh() { Promise.all([ fetch('/analytics/summary').then(r => r.json()).catch(() => null), @@ -40,55 +46,53 @@ const Analytics = (function () { if (geofences) renderGeofences(geofences.zones || []); }); } - - function renderSummary(data) { - const counts = data.counts || {}; - _setText('analyticsCountAdsb', counts.adsb || 0); - _setText('analyticsCountAis', counts.ais || 0); - _setText('analyticsCountWifi', counts.wifi || 0); - _setText('analyticsCountBt', counts.bluetooth || 0); - _setText('analyticsCountDsc', counts.dsc || 0); - _setText('analyticsCountAcars', counts.acars || 0); - _setText('analyticsCountVdl2', counts.vdl2 || 0); - _setText('analyticsCountAprs', counts.aprs || 0); - _setText('analyticsCountMesh', counts.meshtastic || 0); - - // Health - const health = data.health || {}; - const container = document.getElementById('analyticsHealth'); - if (container) { - let html = ''; - const modeLabels = { - pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS', - acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi', - bluetooth: 'BT', dsc: 'DSC' - }; - for (const [mode, info] of Object.entries(health)) { - if (mode === 'sdr_devices') continue; - const running = info && info.running; - const label = modeLabels[mode] || mode; - html += '
    ' + _esc(label) + '
    '; - } - container.innerHTML = html; - } - - // Squawks - const squawks = data.squawks || []; - const sqSection = document.getElementById('analyticsSquawkSection'); - const sqList = document.getElementById('analyticsSquawkList'); - if (sqSection && sqList) { - if (squawks.length > 0) { - sqSection.style.display = ''; - sqList.innerHTML = squawks.map(s => - '
    ' + _esc(s.squawk) + ' ' + - _esc(s.meaning) + ' — ' + _esc(s.callsign || s.icao) + '
    ' - ).join(''); - } else { - sqSection.style.display = 'none'; - } - } - } - + + function renderSummary(data) { + const counts = data.counts || {}; + _setText('analyticsCountAdsb', counts.adsb || 0); + _setText('analyticsCountAis', counts.ais || 0); + _setText('analyticsCountWifi', counts.wifi || 0); + _setText('analyticsCountBt', counts.bluetooth || 0); + _setText('analyticsCountDsc', counts.dsc || 0); + _setText('analyticsCountAcars', counts.acars || 0); + _setText('analyticsCountVdl2', counts.vdl2 || 0); + _setText('analyticsCountAprs', counts.aprs || 0); + _setText('analyticsCountMesh', counts.meshtastic || 0); + + const health = data.health || {}; + const container = document.getElementById('analyticsHealth'); + if (container) { + let html = ''; + const modeLabels = { + pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS', + acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi', + bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh' + }; + for (const [mode, info] of Object.entries(health)) { + if (mode === 'sdr_devices') continue; + const running = info && info.running; + const label = modeLabels[mode] || mode; + html += '
    ' + _esc(label) + '
    '; + } + container.innerHTML = html; + } + + const squawks = data.squawks || []; + const sqSection = document.getElementById('analyticsSquawkSection'); + const sqList = document.getElementById('analyticsSquawkList'); + if (sqSection && sqList) { + if (squawks.length > 0) { + sqSection.style.display = ''; + sqList.innerHTML = squawks.map(s => + '
    ' + _esc(s.squawk) + ' ' + + _esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '
    ' + ).join(''); + } else { + sqSection.style.display = 'none'; + } + } + } + function renderSparklines(sparklines) { const map = { adsb: 'analyticsSparkAdsb', @@ -101,22 +105,22 @@ const Analytics = (function () { aprs: 'analyticsSparkAprs', meshtastic: 'analyticsSparkMesh', }; - - for (const [mode, elId] of Object.entries(map)) { - const el = document.getElementById(elId); - if (!el) continue; - const data = sparklines[mode] || []; - if (data.length < 2) { - el.innerHTML = ''; - continue; - } - const max = Math.max(...data, 1); - const w = 100; - const h = 24; - const step = w / (data.length - 1); - const points = data.map((v, i) => - (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1) - ).join(' '); + + for (const [mode, elId] of Object.entries(map)) { + const el = document.getElementById(elId); + if (!el) continue; + const data = sparklines[mode] || []; + if (data.length < 2) { + el.innerHTML = ''; + continue; + } + const max = Math.max(...data, 1); + const w = 100; + const h = 24; + const step = w / (data.length - 1); + const points = data.map((v, i) => + (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1) + ).join(' '); el.innerHTML = ''; } } @@ -177,15 +181,8 @@ const Analytics = (function () { } const modeLabels = { - adsb: 'ADS-B', - ais: 'AIS', - wifi: 'WiFi', - bluetooth: 'Bluetooth', - dsc: 'DSC', - acars: 'ACARS', - vdl2: 'VDL2', - aprs: 'APRS', - meshtastic: 'Meshtastic', + adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth', + dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic', }; const sorted = patterns @@ -212,100 +209,309 @@ const Analytics = (function () { ''; }).join(''); } - - function renderAlerts(events) { - const container = document.getElementById('analyticsAlertFeed'); - if (!container) return; - if (!events || events.length === 0) { - container.innerHTML = '
    No recent alerts
    '; - return; - } - container.innerHTML = events.slice(0, 20).map(e => { - const sev = e.severity || 'medium'; - const title = e.title || e.event_type || 'Alert'; - const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : ''; - return '
    ' + - '' + _esc(sev) + '' + - '' + _esc(title) + '' + - '' + _esc(time) + '' + - '
    '; - }).join(''); - } - - function renderCorrelations(data) { - const container = document.getElementById('analyticsCorrelations'); - if (!container) return; - const pairs = (data && data.correlations) || []; - if (pairs.length === 0) { - container.innerHTML = '
    No correlations detected
    '; - return; - } - container.innerHTML = pairs.slice(0, 20).map(p => { - const conf = Math.round((p.confidence || 0) * 100); - return '
    ' + - '' + _esc(p.wifi_mac || '') + '' + - '' + - '' + _esc(p.bt_mac || '') + '' + - '
    ' + - '' + conf + '%' + - '
    '; - }).join(''); - } - - function renderGeofences(zones) { - const container = document.getElementById('analyticsGeofenceList'); - if (!container) return; - if (!zones || zones.length === 0) { - container.innerHTML = '
    No geofence zones defined
    '; - return; - } - container.innerHTML = zones.map(z => - '
    ' + - '' + _esc(z.name) + '' + - '' + z.radius_m + 'm' + - '' + - '
    ' - ).join(''); - } - - function addGeofence() { - const name = prompt('Zone name:'); - if (!name) return; - const lat = parseFloat(prompt('Latitude:', '0')); - const lon = parseFloat(prompt('Longitude:', '0')); - const radius = parseFloat(prompt('Radius (meters):', '1000')); - if (isNaN(lat) || isNaN(lon) || isNaN(radius)) { - alert('Invalid input'); - return; - } - fetch('/analytics/geofences', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, lat, lon, radius_m: radius }), - }) - .then(r => r.json()) - .then(() => refresh()); - } - - function deleteGeofence(id) { - if (!confirm('Delete this geofence zone?')) return; - fetch('/analytics/geofences/' + id, { method: 'DELETE' }) - .then(r => r.json()) - .then(() => refresh()); - } - - function exportData(mode) { - const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb'; - const f = (document.getElementById('exportFormat') || {}).value || 'json'; - window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank'); - } - - // Helpers - function _setText(id, val) { - const el = document.getElementById(id); - if (el) el.textContent = val; - } - + + function renderAlerts(events) { + const container = document.getElementById('analyticsAlertFeed'); + if (!container) return; + if (!events || events.length === 0) { + container.innerHTML = '
    No recent alerts
    '; + return; + } + container.innerHTML = events.slice(0, 20).map(e => { + const sev = e.severity || 'medium'; + const title = e.title || e.event_type || 'Alert'; + const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : ''; + return '
    ' + + '' + _esc(sev) + '' + + '' + _esc(title) + '' + + '' + _esc(time) + '' + + '
    '; + }).join(''); + } + + function renderCorrelations(data) { + const container = document.getElementById('analyticsCorrelations'); + if (!container) return; + const pairs = (data && data.correlations) || []; + if (pairs.length === 0) { + container.innerHTML = '
    No correlations detected
    '; + return; + } + container.innerHTML = pairs.slice(0, 20).map(p => { + const conf = Math.round((p.confidence || 0) * 100); + return '
    ' + + '' + _esc(p.wifi_mac || '') + '' + + '' + + '' + _esc(p.bt_mac || '') + '' + + '
    ' + + '' + conf + '%' + + '
    '; + }).join(''); + } + + function renderGeofences(zones) { + const container = document.getElementById('analyticsGeofenceList'); + if (!container) return; + if (!zones || zones.length === 0) { + container.innerHTML = '
    No geofence zones defined
    '; + return; + } + container.innerHTML = zones.map(z => + '
    ' + + '' + _esc(z.name) + '' + + '' + z.radius_m + 'm' + + '' + + '
    ' + ).join(''); + } + + function addGeofence() { + const name = prompt('Zone name:'); + if (!name) return; + const lat = parseFloat(prompt('Latitude:', '0')); + const lon = parseFloat(prompt('Longitude:', '0')); + const radius = parseFloat(prompt('Radius (meters):', '1000')); + if (isNaN(lat) || isNaN(lon) || isNaN(radius)) { + alert('Invalid input'); + return; + } + fetch('/analytics/geofences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, lat, lon, radius_m: radius }), + }) + .then(r => r.json()) + .then(() => refresh()); + } + + function deleteGeofence(id) { + if (!confirm('Delete this geofence zone?')) return; + fetch('/analytics/geofences/' + id, { method: 'DELETE' }) + .then(r => r.json()) + .then(() => refresh()); + } + + function exportData(mode) { + const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb'; + const f = (document.getElementById('exportFormat') || {}).value || 'json'; + window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank'); + } + + function searchTarget() { + const input = document.getElementById('analyticsTargetQuery'); + const summaryEl = document.getElementById('analyticsTargetSummary'); + const q = (input && input.value || '').trim(); + if (!q) { + if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities'; + renderTargetResults([]); + return; + } + + fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120') + .then((r) => r.json()) + .then((data) => { + const results = data.results || []; + if (summaryEl) { + const modeCounts = data.mode_counts || {}; + const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | '); + summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`; + } + renderTargetResults(results); + }) + .catch((err) => { + if (summaryEl) summaryEl.textContent = 'Search failed'; + if (typeof reportActionableError === 'function') { + reportActionableError('Target View Search', err, { onRetry: searchTarget }); + } + }); + } + + function renderTargetResults(results) { + const container = document.getElementById('analyticsTargetResults'); + if (!container) return; + + if (!results || !results.length) { + container.innerHTML = '
    No matching entities
    '; + return; + } + + container.innerHTML = results.map((item) => { + const title = _esc(item.title || item.id || 'Entity'); + const subtitle = _esc(item.subtitle || ''); + const mode = _esc(item.mode || 'unknown'); + const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : ''; + const lastSeen = _esc(item.last_seen || ''); + return '
    ' + + '
    ' + mode + '' + title + '
    ' + + '
    ' + subtitle + '' + + (lastSeen ? 'Last seen ' + lastSeen + '' : '') + + (confidence ? '' + confidence + '' : '') + + '
    ' + + '
    '; + }).join(''); + } + + function loadReplaySessions() { + const select = document.getElementById('analyticsReplaySelect'); + if (!select) return; + + fetch('/recordings?limit=60') + .then((r) => r.json()) + .then((data) => { + replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0); + + if (!replaySessions.length) { + select.innerHTML = ''; + return; + } + + select.innerHTML = replaySessions.map((rec) => { + const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`; + return ``; + }).join(''); + + const pendingReplay = localStorage.getItem('analyticsReplaySession'); + if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) { + select.value = pendingReplay; + localStorage.removeItem('analyticsReplaySession'); + loadReplay(); + } + }) + .catch((err) => { + if (typeof reportActionableError === 'function') { + reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions }); + } + }); + } + + function loadReplay() { + pauseReplay(); + replayEvents = []; + replayIndex = 0; + + const select = document.getElementById('analyticsReplaySelect'); + const meta = document.getElementById('analyticsReplayMeta'); + const timeline = document.getElementById('analyticsReplayTimeline'); + if (!select || !meta || !timeline) return; + + const id = select.value; + if (!id) { + meta.textContent = 'Select a recording'; + timeline.innerHTML = '
    No recording selected
    '; + return; + } + + meta.textContent = 'Loading replay events...'; + + fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600') + .then((r) => r.json()) + .then((data) => { + replayEvents = data.events || []; + replayIndex = 0; + if (!replayEvents.length) { + meta.textContent = 'No events found in selected recording'; + timeline.innerHTML = '
    No events to replay
    '; + return; + } + + const rec = replaySessions.find((s) => s.id === id); + const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown'; + meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`; + renderReplayWindow(); + }) + .catch((err) => { + meta.textContent = 'Replay load failed'; + if (typeof reportActionableError === 'function') { + reportActionableError('Load Replay', err, { onRetry: loadReplay }); + } + }); + } + + function playReplay() { + if (!replayEvents.length) { + loadReplay(); + return; + } + + if (replayTimer) return; + + replayTimer = setInterval(() => { + if (replayIndex >= replayEvents.length - 1) { + pauseReplay(); + return; + } + replayIndex += 1; + renderReplayWindow(); + }, 260); + } + + function pauseReplay() { + if (replayTimer) { + clearInterval(replayTimer); + replayTimer = null; + } + } + + function stepReplay() { + if (!replayEvents.length) { + loadReplay(); + return; + } + + pauseReplay(); + replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1); + renderReplayWindow(); + } + + function renderReplayWindow() { + const timeline = document.getElementById('analyticsReplayTimeline'); + const meta = document.getElementById('analyticsReplayMeta'); + if (!timeline || !meta) return; + + const total = replayEvents.length; + if (!total) { + timeline.innerHTML = '
    No events to replay
    '; + return; + } + + const start = Math.max(0, replayIndex - 15); + const end = Math.min(total, replayIndex + 20); + const windowed = replayEvents.slice(start, end); + + timeline.innerHTML = windowed.map((row, i) => { + const absolute = start + i; + const active = absolute === replayIndex; + const eventType = _esc(row.event_type || 'event'); + const mode = _esc(row.mode || '--'); + const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--'); + const detail = summarizeReplayEvent(row.event || {}); + return '
    ' + + '
    ' + mode + '' + eventType + '
    ' + + '
    ' + ts + '' + _esc(detail) + '
    ' + + '
    '; + }).join(''); + + meta.textContent = `Event ${replayIndex + 1}/${total}`; + } + + function summarizeReplayEvent(event) { + if (!event || typeof event !== 'object') return 'No details'; + if (event.callsign) return `Callsign ${event.callsign}`; + if (event.icao) return `ICAO ${event.icao}`; + if (event.ssid) return `SSID ${event.ssid}`; + if (event.bssid) return `BSSID ${event.bssid}`; + if (event.address) return `Address ${event.address}`; + if (event.name) return `Name ${event.name}`; + const keys = Object.keys(event); + if (!keys.length) return 'No fields'; + return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`; + } + + function _setText(id, val) { + const el = document.getElementById(id); + if (el) el.textContent = val; + } + function _esc(s) { if (typeof s !== 'string') s = String(s == null ? '' : s); return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); @@ -325,6 +531,19 @@ const Analytics = (function () { const hours = mins / 60; return hours.toFixed(hours < 10 ? 1 : 0) + 'h'; } - - return { init, destroy, refresh, addGeofence, deleteGeofence, exportData }; -})(); + + return { + init, + destroy, + refresh, + addGeofence, + deleteGeofence, + exportData, + searchTarget, + loadReplay, + playReplay, + pauseReplay, + stepReplay, + loadReplaySessions, + }; +})(); diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 1dfbd71..211a6b4 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -152,10 +152,10 @@ const BtLocate = (function() { // Include user location as fallback when GPS unavailable const userLat = localStorage.getItem('observerLat'); const userLon = localStorage.getItem('observerLon'); - if (userLat && userLon) { - body.fallback_lat = parseFloat(userLat); - body.fallback_lon = parseFloat(userLon); - } + if (userLat !== null && userLon !== null) { + body.fallback_lat = parseFloat(userLat); + body.fallback_lon = parseFloat(userLon); + } console.log('[BtLocate] Starting with body:', body); diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index ee98fe7..6f6a093 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -401,10 +401,10 @@ const Meshtastic = (function() { // Position is nested in the response const pos = info.position; - if (pos && pos.latitude && pos.longitude) { - if (posRow) posRow.style.display = 'flex'; - if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; - } else { + if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { + if (posRow) posRow.style.display = 'flex'; + if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; + } else { if (posRow) posRow.style.display = 'none'; } } diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index ffb1459..c5d3607 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -599,7 +599,7 @@ if (saved) { try { const parsed = JSON.parse(saved); - if (parsed.lat && parsed.lon) return parsed; + if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed; } catch (e) {} } return { lat: 51.5074, lon: -0.1278 }; @@ -985,7 +985,7 @@ } // Distance calculation - if (ac.lat && ac.lon) { + if (ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) { const distance = calculateDistanceNm( observerLocation.lat, observerLocation.lon, ac.lat, ac.lon @@ -1037,7 +1037,7 @@ fastest = ac.speed; fastestIcao = icao; } - if (ac.lat && ac.lon) { + if (ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) { const dist = calculateDistanceNm( observerLocation.lat, observerLocation.lon, ac.lat, ac.lon @@ -1555,7 +1555,7 @@ ACARS: ${r.statistics.acarsMessages} messages`; gpsEventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); - if (data.type === 'position' && data.latitude && data.longitude) { + if (data.type === 'position' && data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) { updateLocationFromGps(data); } } catch (e) { @@ -1627,7 +1627,7 @@ ACARS: ${r.statistics.acarsMessages} messages`; Object.keys(markerState).forEach(icao => delete markerState[icao]); pendingMarkerUpdates.clear(); Object.keys(aircraft).forEach(icao => { - if (aircraft[icao].lat && aircraft[icao].lon) { + if (aircraft[icao].lat !== undefined && aircraft[icao].lat !== null && aircraft[icao].lon !== undefined && aircraft[icao].lon !== null) { pendingMarkerUpdates.add(icao); } }); @@ -2556,7 +2556,7 @@ sudo make install updateStatistics(icao, aircraft[icao]); // Record trail point - if (data.lat && data.lon) { + if (data.lat !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) { recordTrailPoint(icao, data.lat, data.lon, data.altitude); if (showTrails) { updateTrailLine(icao); @@ -2571,7 +2571,7 @@ sudo make install function updateMarkerImmediate(icao) { const ac = aircraft[icao]; - if (!ac || !ac.lat || !ac.lon) return; + if (!ac || ac.lat === undefined || ac.lat === null || ac.lon === undefined || ac.lon === null) return; if (!passesFilter(icao, ac)) { if (markers[icao]) { @@ -2808,7 +2808,7 @@ sudo make install updateFlightLookupBtn(); const ac = aircraft[icao]; - if (ac && ac.lat && ac.lon) { + if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) { radarMap.setView([ac.lat, ac.lon], 10); } } @@ -5124,7 +5124,7 @@ sudo make install const gps = typeof data.agent.gps_coords === 'string' ? JSON.parse(data.agent.gps_coords) : data.agent.gps_coords; - if (gps.lat && gps.lon) { + if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) { document.getElementById('obsLat').value = gps.lat.toFixed(4); document.getElementById('obsLon').value = gps.lon.toFixed(4); updateObserverLoc(); diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html index 891d091..9362638 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -750,7 +750,7 @@ stats.fastestSpeed = data.speed; } - if (data.lat && data.lon) { + if (data.lat !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) { const dist = calculateDistance(observerLocation.lat, observerLocation.lon, data.lat, data.lon); if (dist > stats.maxRange) stats.maxRange = dist; if (dist < stats.closestDistance) stats.closestDistance = dist; @@ -1019,7 +1019,7 @@ gpsEventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); - if (data.type === 'position' && data.latitude && data.longitude) { + if (data.type === 'position' && data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) { updateLocationFromGps(data); } } catch (e) { @@ -1345,7 +1345,7 @@ } // Add position marker if coordinates present - if (data.latitude && data.longitude) { + if (data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) { addDscPositionMarker(data); } @@ -1600,7 +1600,7 @@ const gps = typeof data.agent.gps_coords === 'string' ? JSON.parse(data.agent.gps_coords) : data.agent.gps_coords; - if (gps.lat && gps.lon) { + if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) { document.getElementById('obsLat').value = gps.lat.toFixed(4); document.getElementById('obsLon').value = gps.lon.toFixed(4); updateObserverLoc(); diff --git a/templates/index.html b/templates/index.html index ce7e17c..fae3770 100644 --- a/templates/index.html +++ b/templates/index.html @@ -72,6 +72,7 @@ + @@ -405,6 +406,18 @@ +
    +
    + Run State +
    +
    +
    + Loading... + + +
    +
    + {% set is_index_page = true %} {% set active_mode = 'pager' %} @@ -3358,7 +3371,7 @@ } // Selected mode from welcome screen - let selectedStartMode = 'pager'; + let selectedStartMode = localStorage.getItem('intercept.default_mode') || 'pager'; // Mode selection from welcome page function selectMode(mode) { @@ -3488,6 +3501,17 @@ const utc = now.toISOString().substring(11, 19); document.getElementById('headerUtcTime').textContent = utc; } + + function setActiveModeIndicator(label) { + const indicator = document.getElementById('activeModeIndicator'); + if (!indicator) return; + + indicator.textContent = ''; + const dot = document.createElement('span'); + dot.className = 'pulse-dot'; + indicator.appendChild(dot); + indicator.appendChild(document.createTextNode(String(label || ''))); + } // Update clock every second setInterval(updateHeaderClock, 1000); updateHeaderClock(); // Initial call @@ -3883,7 +3907,10 @@ if (isRunning) stopDecoding(); if (isSensorRunning) stopSensorDecoding(); if (isWifiRunning) stopWifiScan(); - if (isBtRunning) stopBtScan(); + const btScanActive = (typeof BluetoothMode !== 'undefined' && + typeof BluetoothMode.isScanning === 'function' && + BluetoothMode.isScanning()) || isBtRunning; + if (btScanActive && typeof stopBtScan === 'function') stopBtScan(); if (isAprsRunning) stopAprs(); if (isTscmRunning) stopTscmSweep(); } @@ -3994,8 +4021,7 @@ 'analytics': 'ANALYTICS', 'spaceweather': 'SPACE WX' }; - const activeModeIndicator = document.getElementById('activeModeIndicator'); - if (activeModeIndicator) activeModeIndicator.innerHTML = '' + (modeNames[mode] || mode.toUpperCase()); + setActiveModeIndicator(modeNames[mode] || mode.toUpperCase()); const wifiLayoutContainer = document.getElementById('wifiLayoutContainer'); const btLayoutContainer = document.getElementById('btLayoutContainer'); const satelliteVisuals = document.getElementById('satelliteVisuals'); @@ -4872,9 +4898,9 @@ // Update mode indicator with frequency if (running) { const freq = document.getElementById('rtlamrFrequency').value; - document.getElementById('activeModeIndicator').innerHTML = 'METERS @ ' + freq + ' MHz'; + setActiveModeIndicator('METERS @ ' + freq + ' MHz'); } else { - document.getElementById('activeModeIndicator').innerHTML = 'METERS'; + setActiveModeIndicator('METERS'); } } @@ -7206,7 +7232,8 @@ // Fallback: simple OUI-based correlation deviceCorrelations = []; const wifiMacs = Object.keys(wifiNetworks).concat(Object.keys(wifiClients)); - const btMacs = Object.keys(btDevices || {}); + const btDeviceMap = getBluetoothDevicesSnapshot(); + const btMacs = Object.keys(btDeviceMap); wifiMacs.forEach(wifiMac => { const wifiOui = wifiMac.substring(0, 8).toUpperCase(); @@ -7214,7 +7241,7 @@ const btOui = btMac.substring(0, 8).toUpperCase(); if (wifiOui === btOui) { const wifiDev = wifiNetworks[wifiMac] || wifiClients[wifiMac]; - const btDev = btDevices[btMac]; + const btDev = btDeviceMap[btMac]; deviceCorrelations.push({ wifi_mac: wifiMac, bt_mac: btMac, @@ -7229,6 +7256,23 @@ updateCorrelationDisplay(); } + function getBluetoothDevicesSnapshot() { + const snapshot = {}; + if (typeof BluetoothMode === 'undefined' || typeof BluetoothMode.getDevices !== 'function') { + return snapshot; + } + + const devices = BluetoothMode.getDevices(); + devices.forEach(device => { + const address = String(device.address || device.mac || '').toUpperCase(); + // Correlation fallback is OUI-based, so only include MAC-form addresses. + if (!/^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/.test(address)) return; + snapshot[address] = device; + }); + + return snapshot; + } + function updateCorrelationDisplay() { const list = document.getElementById('correlationList'); if (!list) return; @@ -8846,847 +8890,61 @@ } }; - // ============== BLUETOOTH RECONNAISSANCE ============== + // ============== BLUETOOTH COMPATIBILITY SHIMS ============== - let btEventSource = null; - let btDevices = {}; - let btDeviceCount = 0; - let btBeaconCount = 0; - let btRadarCtx = null; - let btRadarAngle = 0; - let btRadarAnimFrame = null; - let btRadarDevices = []; + function getBluetoothModeApi() { + if (typeof BluetoothMode === 'undefined') return null; + return BluetoothMode; + } - // Refresh Bluetooth interfaces (legacy - now handled by BluetoothMode.init()) - function refreshBtInterfaces() { - // New Bluetooth mode uses /api/bluetooth/capabilities instead - // This function is kept for backwards compatibility but uses new API - if (typeof BluetoothMode !== 'undefined') { - BluetoothMode.checkCapabilities(); - return; + function syncBtRunningState() { + const bt = getBluetoothModeApi(); + if (!bt || typeof bt.isScanning !== 'function') { + return isBtRunning; } - // Legacy fallback (shouldn't be needed) - const select = document.getElementById('btInterfaceSelect') || document.getElementById('btAdapterSelect'); - if (!select) return; - - fetch('/bt/interfaces') - .then(r => r.json()) - .then(data => { - if (!data.interfaces || data.interfaces.length === 0) { - select.innerHTML = ''; - } else { - select.innerHTML = data.interfaces.map(i => - `` - ).join(''); - } - - // Update tool status (if element exists) - const statusDiv = document.getElementById('btToolStatus'); - if (statusDiv) { - statusDiv.innerHTML = ` - hcitool:${data.tools.hcitool ? 'OK' : 'Missing'} - bluetoothctl:${data.tools.bluetoothctl ? 'OK' : 'Missing'} - `; - } - }) - .catch(err => console.warn('Legacy BT interface check failed:', err)); + isBtRunning = bt.isScanning(); + return isBtRunning; + } + + function refreshBtInterfaces() { + const bt = getBluetoothModeApi(); + if (!bt) return; + if (typeof bt.checkCapabilities === 'function') bt.checkCapabilities(); + syncBtRunningState(); } - // Start Bluetooth scan function startBtScan() { - const scanMode = document.querySelector('input[name="btScanMode"]:checked').value; - const iface = document.getElementById('btInterfaceSelect').value; - const duration = document.getElementById('btScanDuration').value; - const scanBLE = document.getElementById('btScanBLE').checked; - const scanClassic = document.getElementById('btScanClassic').checked; - - fetch('/bt/scan/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mode: scanMode, - interface: iface, - duration: parseInt(duration), - scan_ble: scanBLE, - scan_classic: scanClassic - }) - }).then(r => r.json()) - .then(data => { - if (data.status === 'started') { - setBtRunning(true); - startBtStream(); - } else { - alert('Error: ' + data.message); - } - }); + const bt = getBluetoothModeApi(); + if (!bt || typeof bt.startScan !== 'function') return; + bt.startScan(); + setTimeout(syncBtRunningState, 0); } - // Stop Bluetooth scan function stopBtScan() { - fetch('/bt/scan/stop', { method: 'POST' }) - .then(r => r.json()) - .then(data => { - setBtRunning(false); - if (btEventSource) { - btEventSource.close(); - btEventSource = null; - } - }); - } - - function resetBtAdapter() { - const iface = document.getElementById('btInterfaceSelect')?.value || 'hci0'; - fetch('/bt/reset', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface }) - }).then(r => r.json()) - .then(data => { - setBtRunning(false); - if (btEventSource) { - btEventSource.close(); - btEventSource = null; - } - if (data.status === 'success') { - showInfo('Bluetooth adapter reset. Status: ' + (data.is_up ? 'UP' : 'DOWN')); - // Refresh interface list - if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces(); - } else { - showError('Reset failed: ' + data.message); - } - }); + const bt = getBluetoothModeApi(); + if (bt && typeof bt.stopScan === 'function') { + bt.stopScan(); + } + setTimeout(syncBtRunningState, 0); } function setBtRunning(running) { - isBtRunning = running; - document.getElementById('statusDot').classList.toggle('running', running); - document.getElementById('statusText').textContent = running ? 'Scanning...' : 'Idle'; - document.getElementById('startBtBtn').style.display = running ? 'none' : 'block'; - document.getElementById('stopBtBtn').style.display = running ? 'block' : 'none'; + isBtRunning = !!running; + syncBtRunningState(); } - // Batching state for Bluetooth updates - let pendingBtUpdate = false; - let pendingBtDevices = []; - - function scheduleBtUIUpdate() { - if (pendingBtUpdate) return; - pendingBtUpdate = true; - requestAnimationFrame(() => { - // Process devices (limit to 10 per frame) - const devicesToProcess = pendingBtDevices.slice(0, 10); - pendingBtDevices = pendingBtDevices.slice(10); - - devicesToProcess.forEach(data => handleBtDeviceImmediate(data)); - - // If more pending, schedule another frame - if (pendingBtDevices.length > 0) { - pendingBtUpdate = false; - scheduleBtUIUpdate(); - return; - } - - pendingBtUpdate = false; - }); - } - - // Start Bluetooth event stream - function startBtStream() { - if (btEventSource) btEventSource.close(); - - btEventSource = new EventSource('/bt/stream'); - - btEventSource.onmessage = function (e) { - const data = JSON.parse(e.data); - - if (data.type === 'device') { - pendingBtDevices.push(data); - scheduleBtUIUpdate(); - } else if (data.type === 'info' || data.type === 'raw') { - showInfo(data.text); - } else if (data.type === 'error') { - showError(data.text); - } else if (data.type === 'status') { - if (data.text === 'stopped') { - setBtRunning(false); - } - } - }; - - btEventSource.onerror = function () { - console.error('BT stream error'); - }; - } - - // Tracker following detection - let trackerHistory = {}; // MAC -> { firstSeen, lastSeen, seenCount, locations: [] } - const FOLLOWING_THRESHOLD_MINUTES = 30; - const FOLLOWING_MIN_DETECTIONS = 5; - - // Find My network detection patterns - const FINDMY_PATTERNS = { - // Apple Find My / AirTag - apple: { - prefixes: ['4C:00'], - mfgData: [0x004C], // Apple company ID - names: ['AirTag', 'Find My'] - }, - // Samsung SmartTag - samsung: { - prefixes: ['58:4D', 'A0:75', 'DC:0C', 'E4:5F'], - mfgData: [0x0075], // Samsung company ID - names: ['SmartTag', 'Galaxy SmartTag'] - }, - // Tile - tile: { - prefixes: ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A', 'D0:03'], - names: ['Tile', 'Tile Pro', 'Tile Mate', 'Tile Slim'] - }, - // Chipolo - chipolo: { - prefixes: ['00:0D'], - names: ['Chipolo', 'CHIPOLO'] - } - }; - - function detectFindMyDevice(device) { - const mac = device.mac.toUpperCase(); - const macPrefix = mac.substring(0, 5); - const name = (device.name || '').toLowerCase(); - - for (const [network, patterns] of Object.entries(FINDMY_PATTERNS)) { - // Check MAC prefix - if (patterns.prefixes && patterns.prefixes.some(p => mac.startsWith(p))) { - return { network: network, type: 'Find My Network' }; - } - // Check name patterns - if (patterns.names && patterns.names.some(n => name.includes(n.toLowerCase()))) { - return { network: network, type: 'Find My Network' }; - } - } - - // Check manufacturer data for Apple continuity - if (device.manufacturer_data) { - const mfgData = device.manufacturer_data; - if (mfgData.includes('4c00') || mfgData.includes('004c')) { - // Check for Find My payload (manufacturer specific data type 0x12) - if (mfgData.includes('12') || mfgData.length > 40) { - return { network: 'apple', type: 'Apple Find My' }; - } - } - } - - return null; - } - - function checkTrackerFollowing(device) { - if (!device.tracker && !detectFindMyDevice(device)) return; - - const mac = device.mac; - const now = Date.now(); - - if (!trackerHistory[mac]) { - trackerHistory[mac] = { - firstSeen: now, - lastSeen: now, - seenCount: 1, - name: device.name || device.mac - }; - } else { - trackerHistory[mac].lastSeen = now; - trackerHistory[mac].seenCount++; - } - - const tracker = trackerHistory[mac]; - const durationMinutes = (now - tracker.firstSeen) / 60000; - - // Alert if tracker has been following for a while - if (durationMinutes >= FOLLOWING_THRESHOLD_MINUTES && tracker.seenCount >= FOLLOWING_MIN_DETECTIONS) { - showTrackerFollowingAlert(mac, tracker); - } - } - - function showTrackerFollowingAlert(mac, tracker) { - const alertDiv = document.getElementById('trackerFollowingAlert'); - if (!alertDiv) return; - - const durationMinutes = Math.floor((Date.now() - tracker.firstSeen) / 60000); - - alertDiv.style.display = 'block'; - alertDiv.innerHTML = ` -

    POSSIBLE TRACKING DETECTED

    -
    -
    Device: ${escapeHtml(tracker.name)}
    -
    MAC: ${escapeHtml(mac)}
    -
    Duration: ${durationMinutes} minutes
    -
    Detections: ${tracker.seenCount}
    -
    - This tracker has been detected near you for an extended period. - If you don't recognize this device, consider your safety. -
    - -
    - `; - - if (!muted) { - // Play warning sound - for (let i = 0; i < 3; i++) { - setTimeout(() => playAlertSound(), i * 300); - } - } - - showNotification('Tracking Alert', `${tracker.name} detected for ${durationMinutes} min`); - } - - function dismissTrackerAlert(mac) { - document.getElementById('trackerFollowingAlert').style.display = 'none'; - // Reset the tracker history for this device - if (trackerHistory[mac]) { - trackerHistory[mac].firstSeen = Date.now(); - trackerHistory[mac].seenCount = 0; - } - } - - // Handle discovered Bluetooth device (called from batched update) - function handleBtDeviceImmediate(device) { - // Skip if new BluetoothMode is handling devices - if (typeof BluetoothMode !== 'undefined') { - return; - } - - const isNew = !btDevices[device.mac]; - - // Check for Find My network - const findMyInfo = detectFindMyDevice(device); - if (findMyInfo) { - device.findmy = findMyInfo; - device.tracker = device.tracker || { name: findMyInfo.type }; - } - - // Merge with existing device data to preserve RSSI if not in update - if (btDevices[device.mac] && !device.rssi && btDevices[device.mac].rssi) { - device.rssi = btDevices[device.mac].rssi; - } - btDevices[device.mac] = device; - - - if (isNew) { - btDeviceCount++; - playAlert(); - pulseSignal(); - } - - // Update selected device panel if this device is selected - if (selectedBtDevice === device.mac) { - updateBtSelectedDevice(device); - } - - // Check for tracker following - checkTrackerFollowing(device); - - // Track in device intelligence - trackDevice({ - protocol: 'Bluetooth', - address: device.mac, - message: device.name, - model: device.manufacturer, - device_type: device.device_type || device.type || 'other' - }); - - // Update visualizations - addBtDeviceToRadar(device); - - // Add device card - addBtDeviceCard(device, isNew); - - // Update device list panel - updateBtDeviceList(); - - // Check for trackers and update tracker list - if (device.tracker || device.findmy) { - updateBtTrackerList(); - } - } - - // Currently selected BT device for signal tracking - let selectedBtDevice = null; - - // Update the Bluetooth device list panel - function updateBtDeviceList() { - const listEl = document.getElementById('btDeviceList'); - const countEl = document.getElementById('btListCount'); - if (!listEl) return; - - const devices = Object.values(btDevices); - countEl.textContent = devices.length; - - if (devices.length === 0) { - listEl.innerHTML = '
    Start scanning to discover devices...
    '; - return; - } - - // Sort by RSSI (strongest first) - devices.sort((a, b) => (b.rssi || -100) - (a.rssi || -100)); - - listEl.innerHTML = devices.map(d => { - const deviceType = d.device_type || d.type || 'device'; - - const rssiColor = d.rssi > -50 ? 'var(--accent-green)' : - d.rssi > -70 ? 'var(--accent-cyan)' : - d.rssi > -85 ? 'var(--accent-orange)' : 'var(--accent-red)'; - - const isSelected = selectedBtDevice === d.mac; - const trackerBadge = d.findmy ? `FindMy` : - d.tracker ? `Tracker` : ''; - - return ` -
    -
    - ${escapeHtml(d.name || 'Unknown')} - ${d.rssi || '--'} dBm -
    -
    - ${escapeHtml(d.mac)} - ${trackerBadge} -
    -
    - `; - }).join(''); - } - - // Select a BT device for details - function selectBtDevice(mac) { - selectedBtDevice = mac; - const device = btDevices[mac]; - if (device) { - document.getElementById('btTargetMac').value = mac; - updateBtSelectedDevice(device); - } - updateBtDeviceList(); // Refresh to show selection - } - - // Update the selected device details panel - function updateBtSelectedDevice(device) { - const panel = document.getElementById('btSelectedDevice'); - if (!panel || !device) return; - - const deviceType = (device.device_type || device.type || 'unknown').toUpperCase(); - - const rssiColor = device.rssi > -50 ? 'var(--accent-green)' : - device.rssi > -70 ? 'var(--accent-cyan)' : - device.rssi > -85 ? 'var(--accent-orange)' : 'var(--accent-red)'; - - const signalBars = Math.max(1, Math.min(5, Math.floor((device.rssi + 100) / 10))); - const barsHtml = Array(5).fill(0).map((_, i) => - `
    ` - ).join(''); - - let trackerInfo = ''; - if (device.findmy) { - trackerInfo = ` -
    -
    ${escapeHtml(device.findmy.type)}
    -
    ${escapeHtml(device.findmy.network)} Network Device
    -
    `; - } else if (device.tracker) { - trackerInfo = ` -
    -
    ${escapeHtml(device.tracker.name)}
    -
    Tracking Device Detected
    -
    `; - } - - panel.innerHTML = ` -
    -
    -
    ${escapeHtml(device.name || 'Unknown Device')}
    -
    ${escapeHtml((device.device_type || device.type || 'unknown').toUpperCase())}
    -
    -
    -
    ${device.rssi || '--'} dBm
    -
    ${barsHtml}
    -
    -
    -
    -
    -
    MAC ADDRESS
    -
    ${escapeHtml(device.mac)}
    -
    -
    -
    MANUFACTURER
    -
    ${escapeHtml(device.manufacturer || 'Unknown')}
    -
    -
    -
    ADDRESS TYPE
    -
    ${escapeHtml(device.address_type || 'Unknown')}
    -
    -
    -
    LAST SEEN
    -
    ${device.last_seen ? new Date(device.last_seen * 1000).toLocaleTimeString() : 'Now'}
    -
    -
    - ${trackerInfo} -
    - - -
    - `; - } - - // Copy text to clipboard helper - function copyToClipboard(text) { - navigator.clipboard.writeText(text).then(() => { - showNotification('Copied', text); - }).catch(() => { - showInfo('Failed to copy to clipboard'); - }); - } - - // Update tracker list panel - function updateBtTrackerList() { - const listEl = document.getElementById('btTrackerList'); - if (!listEl) return; - - const trackers = Object.values(btDevices).filter(d => d.tracker || d.findmy); - - if (trackers.length === 0) { - listEl.innerHTML = '
    Monitoring for AirTags, Tiles, and other trackers...
    '; - return; - } - - listEl.innerHTML = trackers.map(d => { - const type = d.findmy ? d.findmy.type : (d.tracker ? d.tracker.name : 'Unknown'); - const color = d.findmy ? '#007aff' : 'var(--accent-red)'; - - return ` -
    -
    - ${escapeHtml(type)} - ${d.rssi || '--'} dBm -
    -
    ${escapeHtml(d.mac)}
    -
    - `; - }).join(''); - } - - // Add Bluetooth device card to device list panel - function addBtDeviceCard(device, isNew) { - // Skip if new BluetoothMode is handling rendering - if (typeof BluetoothMode !== 'undefined' && BluetoothMode.isScanning()) { - return; - } - - // Add to new device list panel - const deviceList = document.getElementById('btDeviceListContent'); - if (deviceList) { - // Remove placeholder if present - const placeholder = deviceList.querySelector('div[style*="text-align: center"]'); - if (placeholder && placeholder.textContent.includes('Start scanning')) { - placeholder.remove(); - } - - let card = document.getElementById('btcard_' + device.mac.replace(/:/g, '')); - const devType = device.device_type || device.type || 'other'; - - if (!card) { - card = document.createElement('div'); - card.id = 'btcard_' + device.mac.replace(/:/g, ''); - card.className = 'sensor-card bt-device-card' + - (device.findmy ? ' findmy' : '') + - (device.tracker && !device.findmy ? ' tracker' : ''); - card.style.cursor = 'pointer'; - card.onclick = () => selectBtDevice(device.mac); - deviceList.insertBefore(card, deviceList.firstChild); - - // Update device count - const countEl = document.getElementById('btDeviceListCount'); - if (countEl) countEl.textContent = Object.keys(btDevices).length; - } - - const deviceType = devType.toUpperCase(); - - // Handle signal strength - const rssi = device.rssi || -100; - const signalBars = Math.max(0, Math.min(5, Math.floor((rssi + 100) / 15))); - const signalDisplay = rssi > -100 ? `${rssi} dBm` : 'N/A'; - - const findMyBadge = device.findmy - ? `${device.findmy.network.toUpperCase()}` - : ''; - - const trackerBadge = device.tracker && !device.findmy - ? `TRACKER` - : ''; - - card.innerHTML = ` -
    - ${escapeHtml(device.name || 'Unknown')}${findMyBadge}${trackerBadge} - ${deviceType} -
    -
    -
    -
    MAC
    -
    ${escapeHtml(device.mac)}
    -
    -
    -
    Manufacturer
    -
    ${escapeHtml(device.manufacturer || 'Unknown')}
    -
    -
    -
    Signal
    -
    ${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5 - signalBars)}
    -
    -
    -
    - - -
    - `; - } - - // Update statistics panels - updateBtStatsPanels(); - - // Feed to activity timeline if it's a new detection - if (isNew && typeof addTimelineEvent === 'function') { - const normalized = typeof BluetoothTimelineAdapter !== 'undefined' - ? BluetoothTimelineAdapter.normalizeDevice(device) - : { - id: device.mac, - label: device.name || device.mac.substring(0, 8) + '...', - strength: device.rssi ? Math.min(5, Math.max(1, Math.ceil((device.rssi + 100) / 20))) : 3, - duration: 1500, - type: 'bluetooth' - }; - addTimelineEvent('bluetooth', normalized); - } - } - - // Select a Bluetooth device - function selectBtDevice(mac) { - selectedBtDevice = mac; - const device = btDevices[mac]; - if (device) { - updateBtSelectedDevice(device); - } - } - - // Update Bluetooth statistics panels - function updateBtStatsPanels() { - const devices = Object.values(btDevices); - - // Device type counts - let phones = 0, computers = 0, audio = 0, wearables = 0, other = 0; - let strong = 0, medium = 0, weak = 0; - - devices.forEach(d => { - const devType = d.device_type || d.type || 'other'; - if (devType === 'phone') phones++; - else if (devType === 'computer') computers++; - else if (devType === 'audio') audio++; - else if (devType === 'wearable') wearables++; - else other++; - - const rssi = d.rssi || -100; - if (rssi >= -50) strong++; - else if (rssi >= -70) medium++; - else weak++; - }); - - // Update type counts - const phoneEl = document.getElementById('btPhoneCount'); - const compEl = document.getElementById('btComputerCount'); - const audioEl = document.getElementById('btAudioCount'); - const wearEl = document.getElementById('btWearableCount'); - const otherEl = document.getElementById('btOtherCount'); - if (phoneEl) phoneEl.textContent = phones; - if (compEl) compEl.textContent = computers; - if (audioEl) audioEl.textContent = audio; - if (wearEl) wearEl.textContent = wearables; - if (otherEl) otherEl.textContent = other; - - // Update signal distribution - const total = devices.length || 1; - const strongBar = document.getElementById('btSignalStrong'); - const mediumBar = document.getElementById('btSignalMedium'); - const weakBar = document.getElementById('btSignalWeak'); - const strongCount = document.getElementById('btSignalStrongCount'); - const mediumCount = document.getElementById('btSignalMediumCount'); - const weakCount = document.getElementById('btSignalWeakCount'); - - if (strongBar) strongBar.style.width = (strong / total * 100) + '%'; - if (mediumBar) mediumBar.style.width = (medium / total * 100) + '%'; - if (weakBar) weakBar.style.width = (weak / total * 100) + '%'; - if (strongCount) strongCount.textContent = strong; - if (mediumCount) mediumCount.textContent = medium; - if (weakCount) weakCount.textContent = weak; - - // Update FindMy list - updateBtFindMyList(); - } - - // Update FindMy device list - function updateBtFindMyList() { - const listEl = document.getElementById('btFindMyList'); - if (!listEl) return; - - const findMyDevices = Object.values(btDevices).filter(d => d.findmy); - - if (findMyDevices.length === 0) { - listEl.innerHTML = '
    Scanning for FindMy-compatible devices...
    '; - return; - } - - listEl.innerHTML = findMyDevices.map(d => ` -
    - ${escapeHtml(d.name || d.findmy.type)} - ${d.rssi || '--'} dBm -
    - `).join(''); - } - - // Target a Bluetooth device - function btTargetDevice(mac) { - document.getElementById('btTargetMac').value = mac; - showInfo('Targeted: ' + mac); - } - - // Enumerate services for a device - function btEnumServicesFor(mac) { - document.getElementById('btTargetMac').value = mac; - btEnumServices(); - } - - // Enumerate services - function btEnumServices() { - const mac = document.getElementById('btTargetMac').value; - if (!mac) { alert('Enter target MAC'); return; } - - showInfo('Enumerating services for ' + mac + '...'); - - fetch('/bt/enum', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mac: mac }) - }).then(r => r.json()) - .then(data => { - if (data.status === 'success') { - let msg = 'Services for ' + mac + ': '; - if (data.services.length === 0) { - msg += 'None found'; - } else { - msg += data.services.map(s => s.name).join(', '); - } - showInfo(msg); - } else { - showInfo('Error: ' + data.message); - } - }); - } - - // Initialize Bluetooth radar function initBtRadar() { - const canvas = document.getElementById('btRadarCanvas'); - if (!canvas) return; - - btRadarCtx = canvas.getContext('2d'); - canvas.width = 150; - canvas.height = 150; - - if (!btRadarAnimFrame) { - animateBtRadar(); - } + // Radar lifecycle is handled by BluetoothMode. + syncBtRunningState(); } - // Animate Bluetooth radar - function animateBtRadar() { - if (!btRadarCtx) { btRadarAnimFrame = null; return; } - - const canvas = btRadarCtx.canvas; - const cx = canvas.width / 2; - const cy = canvas.height / 2; - const radius = Math.min(cx, cy) - 5; - - btRadarCtx.fillStyle = 'rgba(0, 10, 20, 0.1)'; - btRadarCtx.fillRect(0, 0, canvas.width, canvas.height); - - // Grid circles - btRadarCtx.strokeStyle = 'rgba(138, 43, 226, 0.2)'; - btRadarCtx.lineWidth = 1; - for (let r = radius / 4; r <= radius; r += radius / 4) { - btRadarCtx.beginPath(); - btRadarCtx.arc(cx, cy, r, 0, Math.PI * 2); - btRadarCtx.stroke(); - } - - // Sweep line (purple for BT) - btRadarCtx.strokeStyle = 'rgba(138, 43, 226, 0.8)'; - btRadarCtx.lineWidth = 2; - btRadarCtx.beginPath(); - btRadarCtx.moveTo(cx, cy); - btRadarCtx.lineTo(cx + Math.cos(btRadarAngle) * radius, cy + Math.sin(btRadarAngle) * radius); - btRadarCtx.stroke(); - - // Device blips - btRadarDevices.forEach(dev => { - const age = Date.now() - dev.timestamp; - const alpha = Math.max(0.1, 1 - age / 15000); - const color = dev.isTracker ? '255, 51, 102' : '138, 43, 226'; - - btRadarCtx.fillStyle = `rgba(${color}, ${alpha})`; - btRadarCtx.beginPath(); - btRadarCtx.arc(dev.x, dev.y, dev.isTracker ? 6 : 4, 0, Math.PI * 2); - btRadarCtx.fill(); - }); - - btRadarAngle += 0.025; - if (btRadarAngle > Math.PI * 2) btRadarAngle = 0; - - btRadarAnimFrame = requestAnimationFrame(animateBtRadar); - } - - // Add device to BT radar - function addBtDeviceToRadar(device) { - const canvas = document.getElementById('btRadarCanvas'); - if (!canvas) return; - - const cx = canvas.width / 2; - const cy = canvas.height / 2; - const radius = Math.min(cx, cy) - 10; - - // Random position based on MAC hash - let angle = 0; - for (let i = 0; i < device.mac.length; i++) { - angle += device.mac.charCodeAt(i); - } - angle = (angle % 360) * Math.PI / 180; - const r = radius * (0.3 + Math.random() * 0.6); - - const x = cx + Math.cos(angle) * r; - const y = cy + Math.sin(angle) * r; - - const existing = btRadarDevices.find(d => d.mac === device.mac); - if (existing) { - existing.timestamp = Date.now(); + function resetBtAdapter() { + // Legacy hook retained for old callers. + if (typeof showInfo === 'function') { + showInfo('Bluetooth adapter reset is handled by the Bluetooth mode backend.'); } else { - btRadarDevices.push({ - x, y, - mac: device.mac, - isTracker: !!device.tracker, - timestamp: Date.now() - }); + console.info('Bluetooth adapter reset is handled by the Bluetooth mode backend.'); } - - if (btRadarDevices.length > 50) btRadarDevices.shift(); } // ============================================ @@ -15897,6 +15155,10 @@ + + + + diff --git a/templates/network_monitor.html b/templates/network_monitor.html index fbbe29f..04cdc13 100644 --- a/templates/network_monitor.html +++ b/templates/network_monitor.html @@ -787,7 +787,7 @@ entry.rssiByAgent.forEach((info, agentName) => { const gps = agentGPS.get(agentName); - if (gps && gps.lat && gps.lon) { + if (gps && gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) { observations.push({ agent_name: agentName, agent_lat: gps.lat, @@ -1073,7 +1073,7 @@ const coords = agent.gps_coords; const lat = coords.lat || coords.latitude; const lon = coords.lon || coords.longitude; - if (lat && lon) { + if (lat !== undefined && lat !== null && lon !== undefined && lon !== null) { agentGPS.set(agent.name, { lat, lon }); addLogEntry('gps', `Agent "${agent.name}" GPS: ${lat.toFixed(4)}, ${lon.toFixed(4)}`); } diff --git a/templates/partials/modes/analytics.html b/templates/partials/modes/analytics.html index f80ece2..1775b00 100644 --- a/templates/partials/modes/analytics.html +++ b/templates/partials/modes/analytics.html @@ -135,23 +135,60 @@ -
    -

    - Geofences - +
    +

    + Geofences +

    -
    -
    - -
    -

    - Export Data - +

    +

    + +
    +

    + Target View + +

    +
    +
    + + +
    +
    Search to correlate entities across modes
    +
    +
    No target selected
    +
    +
    +
    + +
    +

    + Session Replay + +

    +
    +
    + + + + + +
    +
    No replay loaded
    +
    +
    Select a recording to replay key events
    +
    +
    +
    + +
    +

    + Export Data +

    diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html index a79286b..97f24e4 100644 --- a/templates/partials/settings-modal.html +++ b/templates/partials/settings-modal.html @@ -291,6 +291,72 @@
    +
    +
    Rule Builder
    +
    +
    + Rule Name + Human-friendly title for this alert +
    + +
    +
    +
    + Mode + Filter to a specific mode or all +
    + +
    +
    +
    + Event Type + Optional event type (for example device_update) +
    + +
    +
    +
    + Match Filter + Optional key/value exact match (for example address + MAC) +
    +
    + + +
    +
    +
    +
    + Severity + Controls priority coloring and notifications +
    + +
    +
    + + + +
    + +
    +
    Quick Rules
    @@ -301,6 +367,13 @@ Use Bluetooth device details to add specific device watchlist alerts.
    + +
    +
    Active Rules
    +
    +
    No rules yet
    +
    +
    diff --git a/utils/sse.py b/utils/sse.py index c796c32..dafe530 100644 --- a/utils/sse.py +++ b/utils/sse.py @@ -1,48 +1,170 @@ -"""Server-Sent Events (SSE) utilities.""" - -from __future__ import annotations - -import json -import queue -import time -from typing import Any, Generator - - -def sse_stream( - data_queue: queue.Queue, - timeout: float = 1.0, - keepalive_interval: float = 30.0, - stop_check: callable = None -) -> Generator[str, None, None]: +"""Server-Sent Events (SSE) utilities.""" + +from __future__ import annotations + +import json +import queue +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Generator + + +@dataclass +class _QueueFanoutChannel: + """Internal fanout state for a source queue.""" + source_queue: queue.Queue + source_timeout: float + subscribers: set[queue.Queue] = field(default_factory=set) + lock: threading.Lock = field(default_factory=threading.Lock) + distributor: threading.Thread | None = None + + +_fanout_channels: dict[str, _QueueFanoutChannel] = {} +_fanout_channels_lock = threading.Lock() + + +def _run_fanout(channel: _QueueFanoutChannel) -> None: + """Drain source queue and fan out each message to all subscribers.""" + while True: + try: + msg = channel.source_queue.get(timeout=channel.source_timeout) + except queue.Empty: + continue + + with channel.lock: + subscribers = tuple(channel.subscribers) + + for subscriber in subscribers: + try: + subscriber.put_nowait(msg) + except queue.Full: + # Drop oldest frame for this subscriber and retry once. + try: + subscriber.get_nowait() + subscriber.put_nowait(msg) + except (queue.Empty, queue.Full): + continue + + +def _ensure_fanout_channel( + channel_key: str, + source_queue: queue.Queue, + source_timeout: float, +) -> _QueueFanoutChannel: + """Get/create a fanout channel and ensure distributor thread is running.""" + with _fanout_channels_lock: + channel = _fanout_channels.get(channel_key) + if channel is None: + channel = _QueueFanoutChannel(source_queue=source_queue, source_timeout=source_timeout) + _fanout_channels[channel_key] = channel + + if channel.distributor is None or not channel.distributor.is_alive(): + channel.distributor = threading.Thread( + target=_run_fanout, + args=(channel,), + daemon=True, + name=f"sse-fanout-{channel_key}", + ) + channel.distributor.start() + + return channel + + +def subscribe_fanout_queue( + source_queue: queue.Queue, + channel_key: str, + source_timeout: float = 1.0, + subscriber_queue_size: int = 500, +) -> tuple[queue.Queue, Callable[[], None]]: + """ + Subscribe a client queue to a shared source queue fanout channel. + + Returns: + tuple: (subscriber_queue, unsubscribe_fn) + """ + channel = _ensure_fanout_channel(channel_key, source_queue, source_timeout) + subscriber = queue.Queue(maxsize=subscriber_queue_size) + + with channel.lock: + channel.subscribers.add(subscriber) + + def _unsubscribe() -> None: + with channel.lock: + channel.subscribers.discard(subscriber) + + return subscriber, _unsubscribe + + +def sse_stream_fanout( + source_queue: queue.Queue, + channel_key: str, + timeout: float = 1.0, + keepalive_interval: float = 30.0, + stop_check: Callable[[], bool] | None = None, + on_message: Callable[[dict[str, Any]], None] | None = None, +) -> Generator[str, None, None]: + """ + Generate an SSE stream from a fanout channel backed by source_queue. + """ + subscriber, unsubscribe = subscribe_fanout_queue( + source_queue=source_queue, + channel_key=channel_key, + source_timeout=timeout, + ) + last_keepalive = time.time() + + try: + while True: + if stop_check and stop_check(): + break + + try: + msg = subscriber.get(timeout=timeout) + last_keepalive = time.time() + if on_message and isinstance(msg, dict): + try: + on_message(msg) + except Exception: + pass + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= keepalive_interval: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + finally: + unsubscribe() + + +def sse_stream( + data_queue: queue.Queue, + timeout: float = 1.0, + keepalive_interval: float = 30.0, + stop_check: Callable[[], bool] | None = None, + channel_key: str | None = None, +) -> Generator[str, None, None]: """ - Generate SSE stream from a queue. - - Args: - data_queue: Queue to read messages from - timeout: Queue get timeout in seconds - keepalive_interval: Seconds between keepalive messages - stop_check: Optional callable that returns True to stop the stream - - Yields: - SSE formatted strings - """ - last_keepalive = time.time() - - while True: - # Check if we should stop - if stop_check and stop_check(): - break - - try: - msg = data_queue.get(timeout=timeout) - last_keepalive = time.time() - yield format_sse(msg) - except queue.Empty: - # Send keepalive if enough time has passed - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now + Generate SSE stream from a queue. + + Args: + data_queue: Queue to read messages from + timeout: Queue get timeout in seconds + keepalive_interval: Seconds between keepalive messages + stop_check: Optional callable that returns True to stop the stream + channel_key: Optional fanout key; defaults to stable queue id + + Yields: + SSE formatted strings + """ + key = channel_key or f"queue:{id(data_queue)}" + yield from sse_stream_fanout( + source_queue=data_queue, + channel_key=key, + timeout=timeout, + keepalive_interval=keepalive_interval, + stop_check=stop_check, + ) def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str: