feat: ship platform UX and reliability upgrades

This commit is contained in:
Smittix
2026-02-19 20:46:28 +00:00
parent 694786d4e0
commit 5c47e9f10a
41 changed files with 3373 additions and 1680 deletions
+20 -26
View File
@@ -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')
+1 -1
View File
@@ -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(
+13 -19
View File
@@ -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
+165 -5
View File
@@ -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
+20 -26
View File
@@ -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')
+20 -27
View File
@@ -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
+13 -18
View File
@@ -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
+13 -20
View File
@@ -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'
+16 -23
View File
@@ -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
+39 -50
View File
@@ -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:
+15 -22
View File
@@ -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
+20 -29
View File
@@ -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
+57
View File
@@ -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('/<session_id>/events', methods=['GET'])
def get_recording_events(session_id: str):
"""Return parsed events from a recording for in-app replay."""
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
file_path = Path(rec['file_path'])
try:
resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
offset = max(0, request.args.get('offset', default=0, type=int))
events: list[dict] = []
seen = 0
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
for idx, line in enumerate(fh):
if idx < offset:
continue
if seen >= limit:
break
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
seen += 1
except json.JSONDecodeError:
continue
return jsonify({
'status': 'success',
'recording': {
'id': rec['id'],
'mode': rec['mode'],
'started_at': rec['started_at'],
'stopped_at': rec['stopped_at'],
'event_count': rec['event_count'],
},
'offset': offset,
'limit': limit,
'returned': len(events),
'events': events,
})
+13 -20
View File
@@ -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'
+20 -27
View File
@@ -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')
+19 -26
View File
@@ -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
+13 -20
View File
@@ -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'
+10 -16
View File
@@ -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',
+20 -26
View File
@@ -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')
+35 -50
View File
@@ -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
+435
View File
@@ -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;
}
}
+95 -7
View File
@@ -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;
}
+247 -37
View File
@@ -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 = '<div class="settings-feed-empty">No rules yet</div>';
return;
}
list.innerHTML = rules.map((rule) => {
const enabled = Boolean(rule.enabled);
const mode = rule.mode || 'all';
const eventType = rule.event_type || 'any';
const severity = (rule.severity || 'medium').toUpperCase();
const match = formatMatch(rule.match);
const statusText = enabled ? 'ENABLED' : 'DISABLED';
return `
<div class="settings-feed-item" style="border-left: 2px solid ${enabled ? 'var(--accent-green)' : 'var(--text-dim)'};">
<div class="settings-feed-title" style="display:flex; gap:8px; align-items:center; justify-content:space-between;">
<span>${escapeHtml(rule.name || 'Rule')}</span>
<span style="color: var(--text-dim); font-size: 10px;">${statusText}</span>
</div>
<div class="settings-feed-meta">Mode: ${escapeHtml(mode)} | Event: ${escapeHtml(eventType)} | Severity: ${escapeHtml(severity)}</div>
<div class="settings-feed-meta">Match: ${escapeHtml(match)}</div>
<div style="display:flex; gap:8px; margin-top: 8px;">
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.editRule(${Number(rule.id)})">Edit</button>
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.toggleRule(${Number(rule.id)}, ${enabled ? 'false' : 'true'})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px; border-color: var(--accent-red); color: var(--accent-red);" onclick="AlertCenter.deleteRule(${Number(rule.id)})">Delete</button>
</div>
</div>
`;
}).join('');
}
function formatMatch(match) {
if (!match || typeof match !== 'object' || !Object.keys(match).length) {
return 'none';
}
const [k, v] = Object.entries(match)[0];
return `${k}=${v}`;
}
function loadFeed() {
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,
+6 -6
View File
@@ -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 };
})();
+322
View File
@@ -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();
});
+373
View File
@@ -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();
});
+16 -1
View File
@@ -96,7 +96,10 @@ const RecordingUI = (function() {
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(rec.mode)}${rec.label ? `${escapeHtml(rec.label)}` : ''}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
<div style="display:flex; gap:6px;">
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.openReplay('${rec.id}')">Replay</button>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
</div>
</div>
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? `${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
<div class="settings-feed-meta">Events: ${rec.event_count || 0} ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
@@ -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,
};
})();
+206
View File
@@ -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();
});
+1 -1
View File
@@ -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);
+212
View File
@@ -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();
});
+103 -75
View File
@@ -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 = `
<div class="update-toast-indicator"></div>
<div class="update-toast-content">
<div class="update-toast-header">
@@ -97,11 +98,11 @@ const Updater = {
</span>
<span class="update-toast-title">Update Available</span>
<button class="update-toast-close" onclick="Updater.dismissUpdate()">&times;</button>
</div>
<div class="update-toast-body">
Version <strong>${data.latest_version}</strong> is ready
</div>
<div class="update-toast-actions">
</div>
<div class="update-toast-body">
Version <strong>${latestVersion}</strong> is ready
</div>
<div class="update-toast-actions">
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
View Details
</button>
@@ -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 = {
</div>
<div class="update-modal-body">
<div class="update-version-info">
<div class="update-version-current">
<span class="update-version-label">Current</span>
<span class="update-version-value">v${data.current_version}</span>
</div>
<div class="update-version-current">
<span class="update-version-label">Current</span>
<span class="update-version-value">v${safeCurrentVersion}</span>
</div>
<div class="update-version-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</div>
<div class="update-version-latest">
<span class="update-version-label">Latest</span>
<span class="update-version-value update-version-new">v${data.latest_version}</span>
</div>
</div>
<div class="update-version-latest">
<span class="update-version-label">Latest</span>
<span class="update-version-value update-version-new">v${safeLatestVersion}</span>
</div>
</div>
<div class="update-section">
<div class="update-section-title">Release Notes</div>
@@ -249,11 +253,11 @@ const Updater = {
</div>
<div class="update-result" id="updateResult" style="display: none;"></div>
</div>
<div class="update-modal-footer">
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
View on GitHub
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
</div>
<div class="update-modal-footer">
<a href="${safeReleaseUrl || '#'}" target="_blank" class="update-modal-link" ${!safeReleaseUrl ? 'style="display:none"' : ''}>
View on GitHub
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
@@ -357,14 +361,16 @@ const Updater = {
/**
* Show update result
*/
_showResult(resultEl, success, data, isManual = false) {
if (!resultEl) return;
resultEl.style.display = 'block';
if (success) {
if (data.updated) {
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
_showResult(resultEl, success, data, isManual = false) {
if (!resultEl) return;
resultEl.style.display = 'block';
const safeMessage = this._escape(data.message || data.error || 'An error occurred during the update.');
const safeDetails = data.details ? this._escape(String(data.details).substring(0, 200)) : '';
if (success) {
if (data.updated) {
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
if (data.requirements_changed) {
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
@@ -380,22 +386,22 @@ const Updater = {
</div>
<div class="update-result-text">${message}</div>
`;
} else {
resultEl.className = 'update-result update-result-info';
resultEl.innerHTML = `
} else {
resultEl.className = 'update-result update-result-info';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</div>
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
`;
}
} else {
if (isManual) {
resultEl.className = 'update-result update-result-warning';
</div>
<div class="update-result-text">${this._escape(data.message || 'Already up to date.')}</div>
`;
}
} else {
if (isManual) {
resultEl.className = 'update-result update-result-warning';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -403,14 +409,14 @@ const Updater = {
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="update-result-text">
<strong>Manual update required</strong><br>
${data.message || 'Please download the latest release from GitHub.'}
</div>
`;
} else {
resultEl.className = 'update-result update-result-error';
</div>
<div class="update-result-text">
<strong>Manual update required</strong><br>
${safeMessage || 'Please download the latest release from GitHub.'}
</div>
`;
} else {
resultEl.className = 'update-result update-result-error';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -418,16 +424,16 @@ const Updater = {
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<div class="update-result-text">
<strong>Update failed</strong><br>
${data.message || data.error || 'An error occurred during the update.'}
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
</div>
`;
}
}
},
</div>
<div class="update-result-text">
<strong>Update failed</strong><br>
${safeMessage}
${safeDetails ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + safeDetails + '</code>' : ''}
</div>
`;
}
}
},
/**
* Format release notes (basic markdown to HTML)
@@ -461,11 +467,33 @@ const Updater = {
// Line breaks
.replace(/\n/g, '<br>');
// Wrap list items
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
return '<p>' + html + '</p>';
},
// Wrap list items
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
return '<p>' + html + '</p>';
},
_escape(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
_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
+413 -194
View File
@@ -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 += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
}
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 =>
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
_esc(s.meaning) + ' — ' + _esc(s.callsign || s.icao) + '</div>'
).join('');
} else {
sqSection.style.display = 'none';
}
}
}
function renderSummary(data) {
const counts = data.counts || {};
_setText('analyticsCountAdsb', counts.adsb || 0);
_setText('analyticsCountAis', counts.ais || 0);
_setText('analyticsCountWifi', counts.wifi || 0);
_setText('analyticsCountBt', counts.bluetooth || 0);
_setText('analyticsCountDsc', counts.dsc || 0);
_setText('analyticsCountAcars', counts.acars || 0);
_setText('analyticsCountVdl2', counts.vdl2 || 0);
_setText('analyticsCountAprs', counts.aprs || 0);
_setText('analyticsCountMesh', counts.meshtastic || 0);
const health = data.health || {};
const container = document.getElementById('analyticsHealth');
if (container) {
let html = '';
const modeLabels = {
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
};
for (const [mode, info] of Object.entries(health)) {
if (mode === 'sdr_devices') continue;
const running = info && info.running;
const label = modeLabels[mode] || mode;
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
}
container.innerHTML = html;
}
const squawks = data.squawks || [];
const sqSection = document.getElementById('analyticsSquawkSection');
const sqList = document.getElementById('analyticsSquawkList');
if (sqSection && sqList) {
if (squawks.length > 0) {
sqSection.style.display = '';
sqList.innerHTML = squawks.map(s =>
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
).join('');
} else {
sqSection.style.display = 'none';
}
}
}
function renderSparklines(sparklines) {
const map = {
adsb: 'analyticsSparkAdsb',
@@ -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 = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
}
}
@@ -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 () {
'</div>';
}).join('');
}
function renderAlerts(events) {
const container = document.getElementById('analyticsAlertFeed');
if (!container) return;
if (!events || events.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
return;
}
container.innerHTML = events.slice(0, 20).map(e => {
const sev = e.severity || 'medium';
const title = e.title || e.event_type || 'Alert';
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
return '<div class="analytics-alert-item">' +
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
'<span>' + _esc(title) + '</span>' +
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
'</div>';
}).join('');
}
function renderCorrelations(data) {
const container = document.getElementById('analyticsCorrelations');
if (!container) return;
const pairs = (data && data.correlations) || [];
if (pairs.length === 0) {
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
return;
}
container.innerHTML = pairs.slice(0, 20).map(p => {
const conf = Math.round((p.confidence || 0) * 100);
return '<div class="analytics-correlation-pair">' +
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
'<span style="color:var(--text-dim)">&#8596;</span>' +
'<span>' + _esc(p.bt_mac || '') + '</span>' +
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
'</div>';
}).join('');
}
function renderGeofences(zones) {
const container = document.getElementById('analyticsGeofenceList');
if (!container) return;
if (!zones || zones.length === 0) {
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
return;
}
container.innerHTML = zones.map(z =>
'<div class="geofence-zone-item">' +
'<span class="zone-name">' + _esc(z.name) + '</span>' +
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
'</div>'
).join('');
}
function addGeofence() {
const name = prompt('Zone name:');
if (!name) return;
const lat = parseFloat(prompt('Latitude:', '0'));
const lon = parseFloat(prompt('Longitude:', '0'));
const radius = parseFloat(prompt('Radius (meters):', '1000'));
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
alert('Invalid input');
return;
}
fetch('/analytics/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
})
.then(r => r.json())
.then(() => refresh());
}
function deleteGeofence(id) {
if (!confirm('Delete this geofence zone?')) return;
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(() => refresh());
}
function exportData(mode) {
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
const f = (document.getElementById('exportFormat') || {}).value || 'json';
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
}
// 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 = '<div class="analytics-empty">No recent alerts</div>';
return;
}
container.innerHTML = events.slice(0, 20).map(e => {
const sev = e.severity || 'medium';
const title = e.title || e.event_type || 'Alert';
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
return '<div class="analytics-alert-item">' +
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
'<span>' + _esc(title) + '</span>' +
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
'</div>';
}).join('');
}
function renderCorrelations(data) {
const container = document.getElementById('analyticsCorrelations');
if (!container) return;
const pairs = (data && data.correlations) || [];
if (pairs.length === 0) {
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
return;
}
container.innerHTML = pairs.slice(0, 20).map(p => {
const conf = Math.round((p.confidence || 0) * 100);
return '<div class="analytics-correlation-pair">' +
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
'<span style="color:var(--text-dim)">&#8596;</span>' +
'<span>' + _esc(p.bt_mac || '') + '</span>' +
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
'</div>';
}).join('');
}
function renderGeofences(zones) {
const container = document.getElementById('analyticsGeofenceList');
if (!container) return;
if (!zones || zones.length === 0) {
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
return;
}
container.innerHTML = zones.map(z =>
'<div class="geofence-zone-item">' +
'<span class="zone-name">' + _esc(z.name) + '</span>' +
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
'</div>'
).join('');
}
function addGeofence() {
const name = prompt('Zone name:');
if (!name) return;
const lat = parseFloat(prompt('Latitude:', '0'));
const lon = parseFloat(prompt('Longitude:', '0'));
const radius = parseFloat(prompt('Radius (meters):', '1000'));
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
alert('Invalid input');
return;
}
fetch('/analytics/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
})
.then(r => r.json())
.then(() => refresh());
}
function deleteGeofence(id) {
if (!confirm('Delete this geofence zone?')) return;
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(() => refresh());
}
function exportData(mode) {
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
const f = (document.getElementById('exportFormat') || {}).value || 'json';
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
}
function searchTarget() {
const input = document.getElementById('analyticsTargetQuery');
const summaryEl = document.getElementById('analyticsTargetSummary');
const q = (input && input.value || '').trim();
if (!q) {
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
renderTargetResults([]);
return;
}
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
.then((r) => r.json())
.then((data) => {
const results = data.results || [];
if (summaryEl) {
const modeCounts = data.mode_counts || {};
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
}
renderTargetResults(results);
})
.catch((err) => {
if (summaryEl) summaryEl.textContent = 'Search failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Target View Search', err, { onRetry: searchTarget });
}
});
}
function renderTargetResults(results) {
const container = document.getElementById('analyticsTargetResults');
if (!container) return;
if (!results || !results.length) {
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
return;
}
container.innerHTML = results.map((item) => {
const title = _esc(item.title || item.id || 'Entity');
const subtitle = _esc(item.subtitle || '');
const mode = _esc(item.mode || 'unknown');
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
const lastSeen = _esc(item.last_seen || '');
return '<div class="analytics-target-item">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
'<div class="meta"><span>' + subtitle + '</span>' +
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
(confidence ? '<span>' + confidence + '</span>' : '') +
'</div>' +
'</div>';
}).join('');
}
function loadReplaySessions() {
const select = document.getElementById('analyticsReplaySelect');
if (!select) return;
fetch('/recordings?limit=60')
.then((r) => r.json())
.then((data) => {
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
if (!replaySessions.length) {
select.innerHTML = '<option value="">No recordings</option>';
return;
}
select.innerHTML = replaySessions.map((rec) => {
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
}).join('');
const pendingReplay = localStorage.getItem('analyticsReplaySession');
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
select.value = pendingReplay;
localStorage.removeItem('analyticsReplaySession');
loadReplay();
}
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
}
});
}
function loadReplay() {
pauseReplay();
replayEvents = [];
replayIndex = 0;
const select = document.getElementById('analyticsReplaySelect');
const meta = document.getElementById('analyticsReplayMeta');
const timeline = document.getElementById('analyticsReplayTimeline');
if (!select || !meta || !timeline) return;
const id = select.value;
if (!id) {
meta.textContent = 'Select a recording';
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
return;
}
meta.textContent = 'Loading replay events...';
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
.then((r) => r.json())
.then((data) => {
replayEvents = data.events || [];
replayIndex = 0;
if (!replayEvents.length) {
meta.textContent = 'No events found in selected recording';
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const rec = replaySessions.find((s) => s.id === id);
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
renderReplayWindow();
})
.catch((err) => {
meta.textContent = 'Replay load failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay', err, { onRetry: loadReplay });
}
});
}
function playReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
if (replayTimer) return;
replayTimer = setInterval(() => {
if (replayIndex >= replayEvents.length - 1) {
pauseReplay();
return;
}
replayIndex += 1;
renderReplayWindow();
}, 260);
}
function pauseReplay() {
if (replayTimer) {
clearInterval(replayTimer);
replayTimer = null;
}
}
function stepReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
pauseReplay();
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
renderReplayWindow();
}
function renderReplayWindow() {
const timeline = document.getElementById('analyticsReplayTimeline');
const meta = document.getElementById('analyticsReplayMeta');
if (!timeline || !meta) return;
const total = replayEvents.length;
if (!total) {
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const start = Math.max(0, replayIndex - 15);
const end = Math.min(total, replayIndex + 20);
const windowed = replayEvents.slice(start, end);
timeline.innerHTML = windowed.map((row, i) => {
const absolute = start + i;
const active = absolute === replayIndex;
const eventType = _esc(row.event_type || 'event');
const mode = _esc(row.mode || '--');
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
const detail = summarizeReplayEvent(row.event || {});
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
'</div>';
}).join('');
meta.textContent = `Event ${replayIndex + 1}/${total}`;
}
function summarizeReplayEvent(event) {
if (!event || typeof event !== 'object') return 'No details';
if (event.callsign) return `Callsign ${event.callsign}`;
if (event.icao) return `ICAO ${event.icao}`;
if (event.ssid) return `SSID ${event.ssid}`;
if (event.bssid) return `BSSID ${event.bssid}`;
if (event.address) return `Address ${event.address}`;
if (event.name) return `Name ${event.name}`;
const keys = Object.keys(event);
if (!keys.length) return 'No fields';
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
}
function _setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function _esc(s) {
if (typeof s !== 'string') s = String(s == null ? '' : s);
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -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,
};
})();
+4 -4
View File
@@ -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);
+4 -4
View File
@@ -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';
}
}
+9 -9
View File
@@ -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</code>
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</code>
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</code>
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</code>
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();
+4 -4
View File
@@ -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();
+92 -830
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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)}`);
}
+48 -11
View File
@@ -135,23 +135,60 @@
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Geofences</span>
<span class="collapse-icon">&#9660;</span>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Geofences</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsGeofenceList"></div>
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
+ Add Zone
</button>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Export Data</span>
<span class="collapse-icon">&#9660;</span>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Target View</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-target-toolbar">
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
<button onclick="Analytics.searchTarget()">Search</button>
</div>
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
<div id="analyticsTargetResults">
<div class="analytics-empty">No target selected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Session Replay</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-replay-toolbar">
<select id="analyticsReplaySelect"></select>
<button onclick="Analytics.loadReplay()">Load</button>
<button onclick="Analytics.playReplay()">Play</button>
<button onclick="Analytics.pauseReplay()">Pause</button>
<button onclick="Analytics.stepReplay()">Step</button>
</div>
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
<div id="analyticsReplayTimeline">
<div class="analytics-empty">Select a recording to replay key events</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Export Data</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="export-controls">
+73
View File
@@ -291,6 +291,72 @@
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Rule Builder</div>
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
<div class="settings-label">
<span class="settings-label-text">Rule Name</span>
<span class="settings-label-desc">Human-friendly title for this alert</span>
</div>
<input type="text" id="alertsRuleName" class="settings-input" placeholder="New alert rule" style="width: 220px;">
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Mode</span>
<span class="settings-label-desc">Filter to a specific mode or all</span>
</div>
<select id="alertsRuleMode" class="settings-select" style="width: 220px;">
<option value="">All modes</option>
<option value="pager">Pager</option>
<option value="sensor">433 Sensors</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="acars">ACARS</option>
<option value="vdl2">VDL2</option>
<option value="aprs">APRS</option>
<option value="dsc">DSC</option>
<option value="meshtastic">Meshtastic</option>
</select>
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Event Type</span>
<span class="settings-label-desc">Optional event type (for example <code>device_update</code>)</span>
</div>
<input type="text" id="alertsRuleEventType" class="settings-input" placeholder="Optional" style="width: 220px;">
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Match Filter</span>
<span class="settings-label-desc">Optional key/value exact match (for example <code>address</code> + MAC)</span>
</div>
<div style="display:flex; gap:8px;">
<input type="text" id="alertsRuleMatchKey" class="settings-input" placeholder="key" style="width: 100px;">
<input type="text" id="alertsRuleMatchValue" class="settings-input" placeholder="value" style="width: 112px;">
</div>
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Severity</span>
<span class="settings-label-desc">Controls priority coloring and notifications</span>
</div>
<select id="alertsRuleSeverity" class="settings-select" style="width: 220px;">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;">
<button class="check-assets-btn" onclick="AlertCenter.saveRule()">Save Rule</button>
<button class="check-assets-btn" onclick="AlertCenter.clearRuleForm()">Clear</button>
<button class="check-assets-btn" onclick="AlertCenter.loadRules()">Refresh Rules</button>
</div>
<input type="hidden" id="alertsRuleEditingId" value="">
</div>
<div class="settings-group">
<div class="settings-group-title">Quick Rules</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
@@ -301,6 +367,13 @@
Use Bluetooth device details to add specific device watchlist alerts.
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Active Rules</div>
<div id="alertsRulesList" class="settings-feed">
<div class="settings-feed-empty">No rules yet</div>
</div>
</div>
</div>
<!-- Recording Section -->
+166 -44
View File
@@ -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: