mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
feat: ship platform UX and reliability upgrades
This commit is contained in:
+20
-26
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
const FirstRunSetup = (function() {
|
||||
'use strict';
|
||||
|
||||
const COMPLETE_KEY = 'intercept.setup.complete.v1';
|
||||
const DEFAULT_MODE_KEY = 'intercept.default_mode';
|
||||
|
||||
let overlayEl = null;
|
||||
let depsStatusEl = null;
|
||||
let locationStatusEl = null;
|
||||
let notifyStatusEl = null;
|
||||
let modeStatusEl = null;
|
||||
let modeSelectEl = null;
|
||||
|
||||
let dependencyReady = null;
|
||||
|
||||
function init() {
|
||||
buildDOM();
|
||||
maybeShow();
|
||||
}
|
||||
|
||||
function maybeShow() {
|
||||
if (localStorage.getItem(COMPLETE_KEY) === 'true') return;
|
||||
|
||||
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||
open();
|
||||
refreshStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const waitTimer = setInterval(() => {
|
||||
attempts += 1;
|
||||
if (localStorage.getItem(COMPLETE_KEY) === 'true') {
|
||||
clearInterval(waitTimer);
|
||||
return;
|
||||
}
|
||||
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||
clearInterval(waitTimer);
|
||||
open();
|
||||
refreshStatuses();
|
||||
}
|
||||
if (attempts > 30) {
|
||||
clearInterval(waitTimer);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function buildDOM() {
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.id = 'firstRunSetupOverlay';
|
||||
overlayEl.className = 'setup-overlay';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'setup-modal';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'setup-header';
|
||||
|
||||
const headingWrap = document.createElement('div');
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'setup-title';
|
||||
title.textContent = 'Quick Setup';
|
||||
headingWrap.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'setup-subtitle';
|
||||
subtitle.textContent = 'Complete these checks once so all modes work reliably.';
|
||||
headingWrap.appendChild(subtitle);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'setup-close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close setup assistant');
|
||||
closeBtn.addEventListener('click', close);
|
||||
|
||||
header.appendChild(headingWrap);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'setup-content';
|
||||
|
||||
const depsStep = createStep(
|
||||
'Dependencies',
|
||||
'Verify required tools are installed for enabled modes.',
|
||||
(statusEl, actionsEl) => {
|
||||
depsStatusEl = statusEl;
|
||||
|
||||
const checkBtn = buildButton('Recheck', () => checkDependencies());
|
||||
const openToolsBtn = buildButton('Open Tools', () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
if (typeof switchSettingsTab === 'function') switchSettingsTab('tools');
|
||||
});
|
||||
actionsEl.appendChild(checkBtn);
|
||||
actionsEl.appendChild(openToolsBtn);
|
||||
}
|
||||
);
|
||||
|
||||
const locationStep = createStep(
|
||||
'Observer Location',
|
||||
'Set latitude/longitude for pass prediction and mapping features.',
|
||||
(statusEl, actionsEl) => {
|
||||
locationStatusEl = statusEl;
|
||||
actionsEl.appendChild(buildButton('Open Location', () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
if (typeof switchSettingsTab === 'function') switchSettingsTab('location');
|
||||
}));
|
||||
actionsEl.appendChild(buildButton('Recheck', refreshStatuses));
|
||||
}
|
||||
);
|
||||
|
||||
const notifyStep = createStep(
|
||||
'Desktop Alerts',
|
||||
'Allow notifications so high-priority alerts are visible when the tab is hidden.',
|
||||
(statusEl, actionsEl) => {
|
||||
notifyStatusEl = statusEl;
|
||||
actionsEl.appendChild(buildButton('Enable Notifications', requestNotifications));
|
||||
}
|
||||
);
|
||||
|
||||
const modeStep = createStep(
|
||||
'Default Start Mode',
|
||||
'Choose which mode should be selected by default.',
|
||||
(statusEl, actionsEl) => {
|
||||
modeStatusEl = statusEl;
|
||||
|
||||
modeSelectEl = document.createElement('select');
|
||||
modeSelectEl.className = 'setup-btn';
|
||||
const modes = [
|
||||
['pager', 'Pager'],
|
||||
['sensor', '433MHz'],
|
||||
['rtlamr', 'Meters'],
|
||||
['listening', 'Listening Post'],
|
||||
['wifi', 'WiFi'],
|
||||
['bluetooth', 'Bluetooth'],
|
||||
['bt_locate', 'BT Locate'],
|
||||
['aprs', 'APRS'],
|
||||
['satellite', 'Satellite'],
|
||||
['sstv', 'ISS SSTV'],
|
||||
['weathersat', 'Weather Sat'],
|
||||
['sstv_general', 'HF SSTV'],
|
||||
['analytics', 'Analytics'],
|
||||
];
|
||||
for (const [value, label] of modes) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
modeSelectEl.appendChild(opt);
|
||||
}
|
||||
|
||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||
if (savedDefaultMode) {
|
||||
modeSelectEl.value = savedDefaultMode;
|
||||
}
|
||||
|
||||
actionsEl.appendChild(modeSelectEl);
|
||||
actionsEl.appendChild(buildButton('Save', () => {
|
||||
const selected = modeSelectEl.value || 'pager';
|
||||
localStorage.setItem(DEFAULT_MODE_KEY, selected);
|
||||
refreshStatuses();
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Default Mode Saved', `New sessions will default to ${selected}.`, 'info');
|
||||
}
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
content.appendChild(depsStep);
|
||||
content.appendChild(locationStep);
|
||||
content.appendChild(notifyStep);
|
||||
content.appendChild(modeStep);
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'setup-footer';
|
||||
|
||||
const note = document.createElement('span');
|
||||
note.className = 'setup-footer-note';
|
||||
note.textContent = 'You can reopen these options anytime in Settings.';
|
||||
|
||||
const footerActions = document.createElement('div');
|
||||
footerActions.style.display = 'inline-flex';
|
||||
footerActions.style.gap = '8px';
|
||||
|
||||
const laterBtn = buildButton('Remind Me Later', close);
|
||||
const completeBtn = buildButton('Mark Setup Complete', completeSetup, true);
|
||||
completeBtn.id = 'setupCompleteBtn';
|
||||
|
||||
footerActions.appendChild(laterBtn);
|
||||
footerActions.appendChild(completeBtn);
|
||||
|
||||
footer.appendChild(note);
|
||||
footer.appendChild(footerActions);
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(content);
|
||||
modal.appendChild(footer);
|
||||
|
||||
overlayEl.appendChild(modal);
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
|
||||
function createStep(title, description, initActions) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'setup-step';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'setup-step-header';
|
||||
|
||||
const titleEl = document.createElement('span');
|
||||
titleEl.className = 'setup-step-title';
|
||||
titleEl.textContent = title;
|
||||
|
||||
const statusEl = document.createElement('span');
|
||||
statusEl.className = 'setup-step-status';
|
||||
statusEl.textContent = 'Pending';
|
||||
|
||||
header.appendChild(titleEl);
|
||||
header.appendChild(statusEl);
|
||||
|
||||
const descEl = document.createElement('p');
|
||||
descEl.className = 'setup-step-desc';
|
||||
descEl.textContent = description;
|
||||
|
||||
const actionsEl = document.createElement('div');
|
||||
actionsEl.className = 'setup-step-actions';
|
||||
|
||||
if (typeof initActions === 'function') {
|
||||
initActions(statusEl, actionsEl);
|
||||
}
|
||||
|
||||
root.appendChild(header);
|
||||
root.appendChild(descEl);
|
||||
root.appendChild(actionsEl);
|
||||
return root;
|
||||
}
|
||||
|
||||
function buildButton(label, onClick, primary) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `setup-btn${primary ? ' primary' : ''}`;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function checkDependencies() {
|
||||
if (depsStatusEl) depsStatusEl.textContent = 'Checking...';
|
||||
try {
|
||||
const response = await fetch('/dependencies');
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') {
|
||||
dependencyReady = false;
|
||||
} else {
|
||||
const modes = Object.values(data.modes || {});
|
||||
dependencyReady = modes.every((modeInfo) => Boolean(modeInfo.ready));
|
||||
}
|
||||
} catch (err) {
|
||||
dependencyReady = false;
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Dependency Check', err, {
|
||||
onRetry: checkDependencies,
|
||||
});
|
||||
}
|
||||
}
|
||||
refreshStatuses();
|
||||
}
|
||||
|
||||
function refreshStatuses() {
|
||||
const hasLocation = hasValidLocation();
|
||||
const notifications = notificationStatus();
|
||||
const hasDefaultMode = Boolean(localStorage.getItem(DEFAULT_MODE_KEY));
|
||||
|
||||
setStatus(locationStatusEl, hasLocation, hasLocation ? 'Configured' : 'Not set');
|
||||
setStatus(notifyStatusEl, notifications.ready, notifications.label);
|
||||
setStatus(modeStatusEl, hasDefaultMode, hasDefaultMode ? localStorage.getItem(DEFAULT_MODE_KEY) : 'Not set');
|
||||
|
||||
if (dependencyReady === null) {
|
||||
checkDependencies();
|
||||
return;
|
||||
}
|
||||
setStatus(depsStatusEl, dependencyReady, dependencyReady ? 'Ready' : 'Missing tools');
|
||||
|
||||
const doneCount = Number(dependencyReady) + Number(hasLocation) + Number(notifications.ready) + Number(hasDefaultMode);
|
||||
const completeBtn = document.getElementById('setupCompleteBtn');
|
||||
if (completeBtn) {
|
||||
completeBtn.textContent = doneCount >= 3 ? 'Mark Setup Complete' : 'Complete Anyway';
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, done, label) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('done', Boolean(done));
|
||||
el.textContent = String(label || (done ? 'Done' : 'Pending'));
|
||||
}
|
||||
|
||||
function hasValidLocation() {
|
||||
const rawLat = localStorage.getItem('observerLat');
|
||||
const rawLon = localStorage.getItem('observerLon');
|
||||
|
||||
if (rawLat === null || rawLon === null || rawLat === '' || rawLon === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lat = Number(rawLat);
|
||||
const lon = Number(rawLon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
function notificationStatus() {
|
||||
if (!('Notification' in window)) {
|
||||
return { ready: true, label: 'Unsupported (optional)' };
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return { ready: true, label: 'Enabled' };
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return { ready: false, label: 'Blocked in browser' };
|
||||
}
|
||||
|
||||
return { ready: false, label: 'Permission needed' };
|
||||
}
|
||||
|
||||
async function requestNotifications() {
|
||||
if (!('Notification' in window)) {
|
||||
refreshStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Notification.requestPermission();
|
||||
} catch (err) {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Notifications', err);
|
||||
}
|
||||
}
|
||||
refreshStatuses();
|
||||
}
|
||||
|
||||
function completeSetup() {
|
||||
localStorage.setItem(COMPLETE_KEY, 'true');
|
||||
close();
|
||||
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Setup Complete', 'You can revisit these options in Settings.', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!overlayEl) return;
|
||||
overlayEl.classList.add('open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlayEl) return;
|
||||
overlayEl.classList.remove('open');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
open,
|
||||
close,
|
||||
refreshStatuses,
|
||||
completeSetup,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
FirstRunSetup.init();
|
||||
});
|
||||
@@ -96,7 +96,10 @@ const RecordingUI = (function() {
|
||||
<div class="settings-feed-item">
|
||||
<div class="settings-feed-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,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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()">×</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
_safeUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Manual trigger for settings panel
|
||||
|
||||
+413
-194
@@ -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)">↔</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)">↔</span>' +
|
||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderGeofences(zones) {
|
||||
const container = document.getElementById('analyticsGeofenceList');
|
||||
if (!container) return;
|
||||
if (!zones || zones.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = zones.map(z =>
|
||||
'<div class="geofence-zone-item">' +
|
||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function addGeofence() {
|
||||
const name = prompt('Zone name:');
|
||||
if (!name) return;
|
||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||
alert('Invalid input');
|
||||
return;
|
||||
}
|
||||
fetch('/analytics/geofences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function deleteGeofence(id) {
|
||||
if (!confirm('Delete this geofence zone?')) return;
|
||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function exportData(mode) {
|
||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||
}
|
||||
|
||||
function searchTarget() {
|
||||
const input = document.getElementById('analyticsTargetQuery');
|
||||
const summaryEl = document.getElementById('analyticsTargetSummary');
|
||||
const q = (input && input.value || '').trim();
|
||||
if (!q) {
|
||||
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
||||
renderTargetResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const results = data.results || [];
|
||||
if (summaryEl) {
|
||||
const modeCounts = data.mode_counts || {};
|
||||
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
||||
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
||||
}
|
||||
renderTargetResults(results);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (summaryEl) summaryEl.textContent = 'Search failed';
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTargetResults(results) {
|
||||
const container = document.getElementById('analyticsTargetResults');
|
||||
if (!container) return;
|
||||
|
||||
if (!results || !results.length) {
|
||||
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = results.map((item) => {
|
||||
const title = _esc(item.title || item.id || 'Entity');
|
||||
const subtitle = _esc(item.subtitle || '');
|
||||
const mode = _esc(item.mode || 'unknown');
|
||||
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
||||
const lastSeen = _esc(item.last_seen || '');
|
||||
return '<div class="analytics-target-item">' +
|
||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
||||
'<div class="meta"><span>' + subtitle + '</span>' +
|
||||
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
||||
(confidence ? '<span>' + confidence + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadReplaySessions() {
|
||||
const select = document.getElementById('analyticsReplaySelect');
|
||||
if (!select) return;
|
||||
|
||||
fetch('/recordings?limit=60')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
||||
|
||||
if (!replaySessions.length) {
|
||||
select.innerHTML = '<option value="">No recordings</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = replaySessions.map((rec) => {
|
||||
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
||||
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
||||
}).join('');
|
||||
|
||||
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
||||
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
||||
select.value = pendingReplay;
|
||||
localStorage.removeItem('analyticsReplaySession');
|
||||
loadReplay();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadReplay() {
|
||||
pauseReplay();
|
||||
replayEvents = [];
|
||||
replayIndex = 0;
|
||||
|
||||
const select = document.getElementById('analyticsReplaySelect');
|
||||
const meta = document.getElementById('analyticsReplayMeta');
|
||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||
if (!select || !meta || !timeline) return;
|
||||
|
||||
const id = select.value;
|
||||
if (!id) {
|
||||
meta.textContent = 'Select a recording';
|
||||
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
meta.textContent = 'Loading replay events...';
|
||||
|
||||
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
replayEvents = data.events || [];
|
||||
replayIndex = 0;
|
||||
if (!replayEvents.length) {
|
||||
meta.textContent = 'No events found in selected recording';
|
||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rec = replaySessions.find((s) => s.id === id);
|
||||
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
||||
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
||||
renderReplayWindow();
|
||||
})
|
||||
.catch((err) => {
|
||||
meta.textContent = 'Replay load failed';
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function playReplay() {
|
||||
if (!replayEvents.length) {
|
||||
loadReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (replayTimer) return;
|
||||
|
||||
replayTimer = setInterval(() => {
|
||||
if (replayIndex >= replayEvents.length - 1) {
|
||||
pauseReplay();
|
||||
return;
|
||||
}
|
||||
replayIndex += 1;
|
||||
renderReplayWindow();
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function pauseReplay() {
|
||||
if (replayTimer) {
|
||||
clearInterval(replayTimer);
|
||||
replayTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stepReplay() {
|
||||
if (!replayEvents.length) {
|
||||
loadReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
pauseReplay();
|
||||
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
||||
renderReplayWindow();
|
||||
}
|
||||
|
||||
function renderReplayWindow() {
|
||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||
const meta = document.getElementById('analyticsReplayMeta');
|
||||
if (!timeline || !meta) return;
|
||||
|
||||
const total = replayEvents.length;
|
||||
if (!total) {
|
||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.max(0, replayIndex - 15);
|
||||
const end = Math.min(total, replayIndex + 20);
|
||||
const windowed = replayEvents.slice(start, end);
|
||||
|
||||
timeline.innerHTML = windowed.map((row, i) => {
|
||||
const absolute = start + i;
|
||||
const active = absolute === replayIndex;
|
||||
const eventType = _esc(row.event_type || 'event');
|
||||
const mode = _esc(row.mode || '--');
|
||||
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
||||
const detail = summarizeReplayEvent(row.event || {});
|
||||
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
||||
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
||||
}
|
||||
|
||||
function summarizeReplayEvent(event) {
|
||||
if (!event || typeof event !== 'object') return 'No details';
|
||||
if (event.callsign) return `Callsign ${event.callsign}`;
|
||||
if (event.icao) return `ICAO ${event.icao}`;
|
||||
if (event.ssid) return `SSID ${event.ssid}`;
|
||||
if (event.bssid) return `BSSID ${event.bssid}`;
|
||||
if (event.address) return `Address ${event.address}`;
|
||||
if (event.name) return `Name ${event.name}`;
|
||||
const keys = Object.keys(event);
|
||||
if (!keys.length) return 'No fields';
|
||||
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
||||
}
|
||||
|
||||
function _setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -135,23 +135,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Geofences</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Geofences</span>
|
||||
<span class="collapse-icon">▼</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">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Target View</span>
|
||||
<span class="collapse-icon">▼</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">▼</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">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="export-controls">
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user