From a3f2fa7b8814a3d678cc1367e0afe0c987e00dca Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 20 May 2026 22:01:10 +0100 Subject: [PATCH] fix: resolve two-window hang and sweep UI/theming updates Fix app becoming unresponsive when two browser windows are open: the root cause was HTTP/1.1 connection pool exhaustion (6-connection limit per origin). VoiceAlerts was opening 3 SSE streams per window by default, so two windows produced 8 connections and permanently starved all regular HTTP requests. - voice-alerts.js: default all streams to false (opt-in) to stay within the browser connection limit; existing user preferences in localStorage are preserved - routes/alerts.py: replace direct AlertManager.stream_events() with sse_stream_fanout so both windows receive every alert instead of competing for the same queue - routes/bluetooth_v2.py: same fanout fix via subscribe_fanout_queue, preserving named SSE events (device_update, scan_started, etc.) Also includes accumulated UI/theming changes: accent-cyan CSS variable sweep across mode CSS/JS files, standalone dashboard pages, template updates, satellite TLE data refresh, and tile provider default rename. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 6 +- data/satellites.py | 82 +- routes/alerts.py | 61 +- routes/bluetooth_v2.py | 1065 ++++++++++++----------- static/css/adsb_dashboard.css | 27 +- static/css/adsb_history.css | 25 + static/css/agents.css | 16 +- static/css/ais_dashboard.css | 21 +- static/css/components/device-cards.css | 2 +- static/css/components/proximity-viz.css | 4 +- static/css/components/signal-cards.css | 2 +- static/css/components/ux-platform.css | 36 + static/css/core/components.css | 11 +- static/css/core/layout.css | 24 +- static/css/core/variables.css | 264 +++--- static/css/fonts-local.css | 68 +- static/css/index.css | 107 ++- static/css/modes/bt_locate.css | 8 +- static/css/modes/meteor.css | 23 + static/css/modes/morse.css | 14 + static/css/modes/radiosonde.css | 8 +- static/css/modes/sstv-general.css | 2 +- static/css/modes/sstv.css | 4 +- static/css/modes/subghz.css | 58 +- static/css/modes/system.css | 4 +- static/css/modes/waterfall.css | 136 +++ static/css/modes/weather-satellite.css | 18 +- static/css/satellite_dashboard.css | 61 +- static/css/settings.css | 77 +- static/js/core/first-run-setup.js | 6 +- static/js/core/voice-alerts.js | 13 +- static/js/modes/bt_locate.js | 23 +- static/js/modes/ook.js | 3 + static/js/modes/sstv-general.js | 6 +- static/js/modes/sstv.js | 10 +- static/js/modes/waterfall.js | 12 +- static/js/modes/weather-satellite.js | 6 +- static/js/modes/websdr.js | 21 +- static/js/modes/wefax.js | 3 + static/js/modes/wifi.js | 23 +- templates/adsb_dashboard.html | 18 +- templates/adsb_history.html | 4 +- templates/agents.html | 3 + templates/index.html | 23 +- templates/layout/base.html | 2 +- templates/login.html | 2 + templates/network_monitor.html | 37 +- templates/satellite_dashboard.html | 18 +- 48 files changed, 1524 insertions(+), 943 deletions(-) diff --git a/app.py b/app.py index a5374e2..ea4810b 100644 --- a/app.py +++ b/app.py @@ -209,7 +209,7 @@ def inject_offline_settings(): "enabled": get_setting("offline.enabled", False), "assets_source": assets_source, "fonts_source": fonts_source, - "tile_provider": get_setting("offline.tile_provider", "cartodb_dark_cyan"), + "tile_provider": get_setting("offline.tile_provider", "cartodb_dark_nolabels"), "tile_server_url": get_setting("offline.tile_server_url", ""), } } @@ -440,8 +440,8 @@ def get_sdr_device_status() -> dict[str, str]: @app.before_request def require_login(): # Skip auth entirely when INTERCEPT_DISABLE_AUTH is set - if os.environ.get('INTERCEPT_DISABLE_AUTH', '').lower() in ('1', 'true', 'yes'): - session['logged_in'] = True + if os.environ.get("INTERCEPT_DISABLE_AUTH", "").lower() in ("1", "true", "yes"): + session["logged_in"] = True return None # Routes that don't require login (to avoid infinite redirect loop) diff --git a/data/satellites.py b/data/satellites.py index ffc3681..70d9366 100644 --- a/data/satellites.py +++ b/data/satellites.py @@ -1,32 +1,50 @@ -# TLE data for satellite tracking (updated periodically) -# To update: click "Update TLE" in satellite dashboard or SSTV mode -# Data source: CelesTrak (celestrak.org) -TLE_SATELLITES = { - 'ISS': ('ISS (ZARYA)', - '1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991', - '2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'), - 'NOAA-15': ('NOAA 15', - '1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999', - '2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'), - 'NOAA-18': ('NOAA 18', - '1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996', - '2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668'), - 'NOAA-19': ('NOAA 19', - '1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998', - '2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447'), - 'NOAA-20': ('NOAA 20 (JPSS-1)', - '1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995', - '2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'), - 'NOAA-21': ('NOAA 21 (JPSS-2)', - '1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995', - '2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'), - 'METEOR-M2': ('METEOR-M 2', - '1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990', - '2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'), - 'METEOR-M2-3': ('METEOR-M2 3', - '1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993', - '2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'), - 'METEOR-M2-4': ('METEOR-M2 4', - '1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998', - '2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'), -} +# TLE data for satellite tracking (updated periodically) +# To update: click "Update TLE" in satellite dashboard or SSTV mode +# Data source: CelesTrak (celestrak.org) +TLE_SATELLITES = { + "ISS": ( + "ISS (ZARYA)", + "1 25544U 98067A 26140.52007258 .00005164 00000+0 10084-3 0 9993", + "2 25544 51.6328 77.0641 0007497 79.3410 280.8422 15.49283153567468", + ), + "NOAA-15": ( + "NOAA 15", + "1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999", + "2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049", + ), + "NOAA-18": ( + "NOAA 18", + "1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996", + "2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668", + ), + "NOAA-19": ( + "NOAA 19", + "1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998", + "2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447", + ), + "NOAA-20": ( + "NOAA 20 (JPSS-1)", + "1 43013U 17073A 26140.44110773 .00000055 00000+0 46930-4 0 9994", + "2 43013 98.7764 80.1520 0001265 43.4537 316.6738 14.19505991440534", + ), + "NOAA-21": ( + "NOAA 21 (JPSS-2)", + "1 54234U 22150A 26140.47502274 .00000020 00000+0 29984-4 0 9999", + "2 54234 98.7052 79.7311 0000538 296.4939 63.6182 14.19559760182618", + ), + "METEOR-M2": ( + "METEOR-M 2", + "1 40069U 14037A 26140.48222780 .00000329 00000+0 16961-3 0 9999", + "2 40069 98.5104 117.2052 0006833 111.5029 248.6878 14.21453950615385", + ), + "METEOR-M2-3": ( + "METEOR-M2 3", + "1 57166U 23091A 26140.55562749 -.00000013 00000+0 13331-4 0 9995", + "2 57166 98.6097 196.0965 0002883 242.0522 118.0365 14.24044155150583", + ), + "METEOR-M2-4": ( + "METEOR-M2 4", + "1 59051U 24039A 26140.53898488 .00000003 00000+0 20858-4 0 9993", + "2 59051 98.6996 100.1874 0005955 247.0139 113.0410 14.22426327115336", + ), +} diff --git a/routes/alerts.py b/routes/alerts.py index 82b8c8f..bd0932c 100644 --- a/routes/alerts.py +++ b/routes/alerts.py @@ -2,74 +2,75 @@ from __future__ import annotations -from collections.abc import Generator - from flask import Blueprint, Response, request from utils.alerts import get_alert_manager from utils.responses import api_error, api_success -from utils.sse import format_sse +from utils.sse import sse_stream_fanout -alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts') +alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts") -@alerts_bp.route('/rules', methods=['GET']) +@alerts_bp.route("/rules", methods=["GET"]) def list_rules(): manager = get_alert_manager() - include_disabled = request.args.get('all') in ('1', 'true', 'yes') - return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)}) + include_disabled = request.args.get("all") in ("1", "true", "yes") + return api_success(data={"rules": manager.list_rules(include_disabled=include_disabled)}) -@alerts_bp.route('/rules', methods=['POST']) +@alerts_bp.route("/rules", methods=["POST"]) def create_rule(): data = request.get_json() or {} - if not isinstance(data.get('match', {}), dict): - return api_error('match must be a JSON object', 400) + if not isinstance(data.get("match", {}), dict): + return api_error("match must be a JSON object", 400) manager = get_alert_manager() rule_id = manager.add_rule(data) - return api_success(data={'rule_id': rule_id}) + return api_success(data={"rule_id": rule_id}) -@alerts_bp.route('/rules/', methods=['PUT', 'PATCH']) +@alerts_bp.route("/rules/", methods=["PUT", "PATCH"]) def update_rule(rule_id: int): data = request.get_json() or {} manager = get_alert_manager() ok = manager.update_rule(rule_id, data) if not ok: - return api_error('Rule not found or no changes', 404) + return api_error("Rule not found or no changes", 404) return api_success() -@alerts_bp.route('/rules/', methods=['DELETE']) +@alerts_bp.route("/rules/", methods=["DELETE"]) def delete_rule(rule_id: int): manager = get_alert_manager() ok = manager.delete_rule(rule_id) if not ok: - return api_error('Rule not found', 404) + return api_error("Rule not found", 404) return api_success() -@alerts_bp.route('/events', methods=['GET']) +@alerts_bp.route("/events", methods=["GET"]) def list_events(): manager = get_alert_manager() - limit = request.args.get('limit', default=100, type=int) - mode = request.args.get('mode') - severity = request.args.get('severity') + limit = request.args.get("limit", default=100, type=int) + mode = request.args.get("mode") + severity = request.args.get("severity") events = manager.list_events(limit=limit, mode=mode, severity=severity) - return api_success(data={'events': events}) + return api_success(data={"events": events}) -@alerts_bp.route('/stream', methods=['GET']) +@alerts_bp.route("/stream", methods=["GET"]) def stream_alerts() -> Response: manager = get_alert_manager() - - def generate() -> Generator[str, None, None]: - for event in manager.stream_events(timeout=1.0): - yield format_sse(event) - - 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=manager._queue, + channel_key="alerts", + timeout=1.0, + keepalive_interval=30.0, + ), + mimetype="text/event-stream", + ) + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/bluetooth_v2.py b/routes/bluetooth_v2.py index 5cb3bdc..f725ffe 100644 --- a/routes/bluetooth_v2.py +++ b/routes/bluetooth_v2.py @@ -12,6 +12,7 @@ import csv import io import json import logging +import queue import threading import time from collections.abc import Generator @@ -27,12 +28,12 @@ from utils.bluetooth import ( from utils.database import get_db from utils.event_pipeline import process_event from utils.responses import api_error -from utils.sse import format_sse +from utils.sse import format_sse, subscribe_fanout_queue -logger = logging.getLogger('intercept.bluetooth_v2') +logger = logging.getLogger("intercept.bluetooth_v2") # Blueprint -bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth') +bluetooth_v2_bp = Blueprint("bluetooth_v2", __name__, url_prefix="/api/bluetooth") # Seen-before tracking _bt_seen_cache: set[str] = set() @@ -48,7 +49,7 @@ def init_bt_tables() -> None: """Initialize Bluetooth-specific database tables.""" with get_db() as conn: # Bluetooth baselines - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS bt_baselines ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -56,10 +57,10 @@ def init_bt_tables() -> None: device_count INTEGER DEFAULT 0, is_active BOOLEAN DEFAULT 0 ) - ''') + """) # Baseline device snapshots - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS bt_baseline_devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, baseline_id INTEGER NOT NULL, @@ -73,10 +74,10 @@ def init_bt_tables() -> None: FOREIGN KEY (baseline_id) REFERENCES bt_baselines(id) ON DELETE CASCADE, UNIQUE(baseline_id, device_id) ) - ''') + """) # Observation history for long-term tracking - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS bt_observation_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, @@ -84,69 +85,66 @@ def init_bt_tables() -> None: rssi INTEGER, seen_count INTEGER ) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_bt_obs_device_time ON bt_observation_history(device_id, timestamp) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_bt_baseline_devices_baseline ON bt_baseline_devices(baseline_id) - ''') + """) def get_active_baseline_id() -> int | None: """Get the ID of the active baseline.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT id FROM bt_baselines WHERE is_active = 1 LIMIT 1' - ) + cursor = conn.execute("SELECT id FROM bt_baselines WHERE is_active = 1 LIMIT 1") row = cursor.fetchone() - return row['id'] if row else None + return row["id"] if row else None def get_baseline_device_ids(baseline_id: int) -> set[str]: """Get device IDs from a baseline.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT device_id FROM bt_baseline_devices WHERE baseline_id = ?', - (baseline_id,) - ) - return {row['device_id'] for row in cursor} + cursor = conn.execute("SELECT device_id FROM bt_baseline_devices WHERE baseline_id = ?", (baseline_id,)) + return {row["device_id"] for row in cursor} def save_baseline(name: str, devices: list[BTDeviceAggregate]) -> int: """Save current devices as a new baseline.""" with get_db() as conn: # Deactivate existing baselines - conn.execute('UPDATE bt_baselines SET is_active = 0') + conn.execute("UPDATE bt_baselines SET is_active = 0") # Create new baseline cursor = conn.execute( - 'INSERT INTO bt_baselines (name, device_count, is_active) VALUES (?, ?, 1)', - (name, len(devices)) + "INSERT INTO bt_baselines (name, device_count, is_active) VALUES (?, ?, 1)", (name, len(devices)) ) baseline_id = cursor.lastrowid # Save device snapshots for device in devices: - conn.execute(''' + conn.execute( + """ INSERT INTO bt_baseline_devices (baseline_id, device_id, address, address_type, name, manufacturer_id, manufacturer_name, protocol) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - baseline_id, - device.device_id, - device.address, - device.address_type, - device.name, - device.manufacturer_id, - device.manufacturer_name, - device.protocol, - )) + """, + ( + baseline_id, + device.device_id, + device.address, + device.address_type, + device.name, + device.manufacturer_id, + device.manufacturer_name, + device.protocol, + ), + ) return baseline_id @@ -154,35 +152,38 @@ def save_baseline(name: str, devices: list[BTDeviceAggregate]) -> int: def clear_active_baseline() -> bool: """Clear the active baseline.""" with get_db() as conn: - cursor = conn.execute('UPDATE bt_baselines SET is_active = 0 WHERE is_active = 1') + cursor = conn.execute("UPDATE bt_baselines SET is_active = 0 WHERE is_active = 1") return cursor.rowcount > 0 def get_all_baselines() -> list[dict]: """Get all baselines.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT id, name, created_at, device_count, is_active FROM bt_baselines ORDER BY created_at DESC - ''') + """) return [dict(row) for row in cursor] def save_observation_history(device: BTDeviceAggregate) -> None: """Save device observation to history.""" with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO bt_observation_history (device_id, rssi, seen_count) VALUES (?, ?, ?) - ''', (device.device_id, device.rssi_current, device.seen_count)) + """, + (device.device_id, device.rssi_current, device.seen_count), + ) def load_seen_device_ids() -> set[str]: """Load distinct device IDs from history for seen-before tracking.""" with get_db() as conn: - cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history') - return {row['device_id'] for row in cursor} + cursor = conn.execute("SELECT DISTINCT device_id FROM bt_observation_history") + return {row["device_id"] for row in cursor} # ============================================================================= @@ -190,7 +191,7 @@ def load_seen_device_ids() -> set[str]: # ============================================================================= -@bluetooth_v2_bp.route('/capabilities', methods=['GET']) +@bluetooth_v2_bp.route("/capabilities", methods=["GET"]) def get_capabilities(): """ Get Bluetooth system capabilities. @@ -202,7 +203,7 @@ def get_capabilities(): return jsonify(caps.to_dict()) -@bluetooth_v2_bp.route('/scan/start', methods=['POST']) +@bluetooth_v2_bp.route("/scan/start", methods=["POST"]) def start_scan(): """ Start Bluetooth scanning. @@ -219,16 +220,16 @@ def start_scan(): """ data = request.get_json() or {} - mode = data.get('mode', 'auto') - duration_s = data.get('duration_s') - adapter_id = data.get('adapter_id') - transport = data.get('transport', 'auto') - rssi_threshold = data.get('rssi_threshold', -100) + mode = data.get("mode", "auto") + duration_s = data.get("duration_s") + adapter_id = data.get("adapter_id") + transport = data.get("transport", "auto") + rssi_threshold = data.get("rssi_threshold", -100) # Validate mode - valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth') + valid_modes = ("auto", "dbus", "bleak", "hcitool", "bluetoothctl", "ubertooth") if mode not in valid_modes: - return api_error(f'Invalid mode. Must be one of: {valid_modes}', 400) + return api_error(f"Invalid mode. Must be one of: {valid_modes}", 400) # Get scanner instance scanner = get_bluetooth_scanner(adapter_id) @@ -257,10 +258,7 @@ def start_scan(): # Check if already scanning if scanner.is_scanning: - return jsonify({ - 'status': 'already_scanning', - 'scan_status': scanner.get_status().to_dict() - }) + return jsonify({"status": "already_scanning", "scan_status": scanner.get_status().to_dict()}) # Refresh seen-before cache and reset session set for a new scan with _bt_seen_lock: @@ -285,21 +283,25 @@ def start_scan(): if success: status = scanner.get_status() - return jsonify({ - 'status': 'started', - 'mode': status.mode, - 'backend': status.backend, - 'adapter_id': status.adapter_id, - }) + return jsonify( + { + "status": "started", + "mode": status.mode, + "backend": status.backend, + "adapter_id": status.adapter_id, + } + ) else: status = scanner.get_status() - return jsonify({ - 'status': 'failed', - 'error': status.error or 'Failed to start scan', - }), 500 + return jsonify( + { + "status": "failed", + "error": status.error or "Failed to start scan", + } + ), 500 -@bluetooth_v2_bp.route('/scan/stop', methods=['POST']) +@bluetooth_v2_bp.route("/scan/stop", methods=["POST"]) def stop_scan(): """ Stop Bluetooth scanning. @@ -310,10 +312,10 @@ def stop_scan(): scanner = get_bluetooth_scanner() scanner.stop_scan() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@bluetooth_v2_bp.route('/scan/status', methods=['GET']) +@bluetooth_v2_bp.route("/scan/status", methods=["GET"]) def get_scan_status(): """ Get current scan status. @@ -326,7 +328,7 @@ def get_scan_status(): return jsonify(status.to_dict()) -@bluetooth_v2_bp.route('/devices', methods=['GET']) +@bluetooth_v2_bp.route("/devices", methods=["GET"]) def list_devices(): """ List discovered Bluetooth devices. @@ -345,12 +347,12 @@ def list_devices(): scanner = get_bluetooth_scanner() # Parse query parameters - sort_by = request.args.get('sort', 'last_seen') - sort_desc = request.args.get('order', 'desc').lower() != 'asc' - min_rssi = request.args.get('min_rssi', type=int) - protocol = request.args.get('protocol') - max_age = request.args.get('max_age', 300, type=float) - heuristic_filter = request.args.get('heuristic') + sort_by = request.args.get("sort", "last_seen") + sort_desc = request.args.get("order", "desc").lower() != "asc" + min_rssi = request.args.get("min_rssi", type=int) + protocol = request.args.get("protocol") + max_age = request.args.get("max_age", 300, type=float) + heuristic_filter = request.args.get("heuristic") # Get devices devices = scanner.get_devices( @@ -365,13 +367,15 @@ def list_devices(): if heuristic_filter: devices = [d for d in devices if heuristic_filter in d.heuristic_flags] - return jsonify({ - 'count': len(devices), - 'devices': [d.to_summary_dict() for d in devices], - }) + return jsonify( + { + "count": len(devices), + "devices": [d.to_summary_dict() for d in devices], + } + ) -@bluetooth_v2_bp.route('/devices/', methods=['GET']) +@bluetooth_v2_bp.route("/devices/", methods=["GET"]) def get_device(device_id: str): """ Get detailed information about a specific device. @@ -386,7 +390,7 @@ def get_device(device_id: str): device = scanner.get_device(device_id) if not device: - return api_error('Device not found', 404) + return api_error("Device not found", 404) return jsonify(device.to_dict()) @@ -396,7 +400,7 @@ def get_device(device_id: str): # ============================================================================= -@bluetooth_v2_bp.route('/trackers', methods=['GET']) +@bluetooth_v2_bp.route("/trackers", methods=["GET"]) def list_trackers(): """ List detected tracker devices with enriched tracker data. @@ -415,9 +419,9 @@ def list_trackers(): scanner = get_bluetooth_scanner() # Parse query parameters - min_confidence = request.args.get('min_confidence', 'low') - max_age = request.args.get('max_age', 300, type=float) - include_risk = request.args.get('include_risk', 'true').lower() == 'true' + min_confidence = request.args.get("min_confidence", "low") + max_age = request.args.get("max_age", 300, type=float) + include_risk = request.args.get("include_risk", "true").lower() == "true" # Get all devices devices = scanner.get_devices(max_age_seconds=max_age) @@ -426,58 +430,50 @@ def list_trackers(): trackers = [d for d in devices if d.is_tracker] # Filter by confidence level if specified - confidence_order = {'high': 3, 'medium': 2, 'low': 1, 'none': 0} + confidence_order = {"high": 3, "medium": 2, "low": 1, "none": 0} min_conf_level = confidence_order.get(min_confidence.lower(), 1) - trackers = [ - t for t in trackers - if confidence_order.get(t.tracker_confidence, 0) >= min_conf_level - ] + trackers = [t for t in trackers if confidence_order.get(t.tracker_confidence, 0) >= min_conf_level] # Build response tracker_list = [] for device in trackers: tracker_info = { - 'device_id': device.device_id, - 'device_key': device.device_key, - 'address': device.address, - 'address_type': device.address_type, - 'name': device.name, - + "device_id": device.device_id, + "device_key": device.device_key, + "address": device.address, + "address_type": device.address_type, + "name": device.name, # Tracker detection details - 'tracker': { - 'type': device.tracker_type, - 'name': device.tracker_name, - 'confidence': device.tracker_confidence, - 'confidence_score': round(device.tracker_confidence_score, 2), - 'evidence': device.tracker_evidence, + "tracker": { + "type": device.tracker_type, + "name": device.tracker_name, + "confidence": device.tracker_confidence, + "confidence_score": round(device.tracker_confidence_score, 2), + "evidence": device.tracker_evidence, }, - # Location/proximity - 'rssi_current': device.rssi_current, - 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, - 'proximity_band': device.proximity_band, - 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, - + "rssi_current": device.rssi_current, + "rssi_ema": round(device.rssi_ema, 1) if device.rssi_ema else None, + "proximity_band": device.proximity_band, + "estimated_distance_m": round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, # Timing - 'first_seen': device.first_seen.isoformat(), - 'last_seen': device.last_seen.isoformat(), - 'age_seconds': round(device.age_seconds, 1), - 'seen_count': device.seen_count, - 'duration_seconds': round(device.duration_seconds, 1), - + "first_seen": device.first_seen.isoformat(), + "last_seen": device.last_seen.isoformat(), + "age_seconds": round(device.age_seconds, 1), + "seen_count": device.seen_count, + "duration_seconds": round(device.duration_seconds, 1), # Status - 'is_new': device.is_new, - 'in_baseline': device.in_baseline, - + "is_new": device.is_new, + "in_baseline": device.in_baseline, # Fingerprint for cross-MAC tracking - 'fingerprint_id': device.payload_fingerprint_id, + "fingerprint_id": device.payload_fingerprint_id, } # Include risk analysis if requested if include_risk: - tracker_info['risk_analysis'] = { - 'risk_score': round(device.risk_score, 2), - 'risk_factors': device.risk_factors, + tracker_info["risk_analysis"] = { + "risk_score": round(device.risk_score, 2), + "risk_factors": device.risk_factors, } tracker_list.append(tracker_info) @@ -485,26 +481,28 @@ def list_trackers(): # Sort by risk score (highest first), then confidence tracker_list.sort( key=lambda t: ( - t.get('risk_analysis', {}).get('risk_score', 0), - confidence_order.get(t['tracker']['confidence'], 0) + t.get("risk_analysis", {}).get("risk_score", 0), + confidence_order.get(t["tracker"]["confidence"], 0), ), - reverse=True + reverse=True, ) - return jsonify({ - 'count': len(tracker_list), - 'scan_active': scanner.is_scanning, - 'trackers': tracker_list, - 'summary': { - 'high_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'high'), - 'medium_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'medium'), - 'low_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'low'), - 'high_risk': sum(1 for t in tracker_list if t.get('risk_analysis', {}).get('risk_score', 0) >= 0.5), + return jsonify( + { + "count": len(tracker_list), + "scan_active": scanner.is_scanning, + "trackers": tracker_list, + "summary": { + "high_confidence": sum(1 for t in tracker_list if t["tracker"]["confidence"] == "high"), + "medium_confidence": sum(1 for t in tracker_list if t["tracker"]["confidence"] == "medium"), + "low_confidence": sum(1 for t in tracker_list if t["tracker"]["confidence"] == "low"), + "high_risk": sum(1 for t in tracker_list if t.get("risk_analysis", {}).get("risk_score", 0) >= 0.5), + }, } - }) + ) -@bluetooth_v2_bp.route('/trackers/', methods=['GET']) +@bluetooth_v2_bp.route("/trackers/", methods=["GET"]) def get_tracker_detail(device_id: str): """ Get detailed tracker information for investigation. @@ -526,102 +524,95 @@ def get_tracker_detail(device_id: str): device = scanner.get_device(device_id) if not device: - return api_error('Device not found', 404) + return api_error("Device not found", 404) # Get RSSI history for timeline rssi_history = device.get_rssi_history(max_points=100) # Build comprehensive response - return jsonify({ - 'device_id': device.device_id, - 'device_key': device.device_key, - 'address': device.address, - 'address_type': device.address_type, - 'name': device.name, - 'manufacturer_name': device.manufacturer_name, - 'manufacturer_id': device.manufacturer_id, - - # Tracker detection - 'tracker': { - 'is_tracker': device.is_tracker, - 'type': device.tracker_type, - 'name': device.tracker_name, - 'confidence': device.tracker_confidence, - 'confidence_score': round(device.tracker_confidence_score, 2), - 'evidence': device.tracker_evidence, - }, - - # Risk analysis - 'risk_analysis': { - 'risk_score': round(device.risk_score, 2), - 'risk_factors': device.risk_factors, - 'warning': 'Risk scores are heuristic indicators only. They do NOT prove malicious intent.', - }, - - # Fingerprint (for MAC randomization tracking) - 'fingerprint': { - 'id': device.payload_fingerprint_id, - 'stability': round(device.payload_fingerprint_stability, 2), - 'note': 'Fingerprints help track devices across MAC address changes but are probabilistic.', - }, - - # Signal data - 'signal': { - 'rssi_current': device.rssi_current, - 'rssi_median': round(device.rssi_median, 1) if device.rssi_median else None, - 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, - 'rssi_min': device.rssi_min, - 'rssi_max': device.rssi_max, - 'rssi_variance': round(device.rssi_variance, 2) if device.rssi_variance else None, - 'tx_power': device.tx_power, - }, - - # Proximity - 'proximity': { - 'band': device.proximity_band, - 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, - 'confidence': round(device.distance_confidence, 2), - }, - - # Timeline / sightings - 'timeline': { - 'first_seen': device.first_seen.isoformat(), - 'last_seen': device.last_seen.isoformat(), - 'age_seconds': round(device.age_seconds, 1), - 'duration_seconds': round(device.duration_seconds, 1), - 'seen_count': device.seen_count, - 'seen_rate': round(device.seen_rate, 2), - 'rssi_history': rssi_history, - }, - - # Raw advertisement data for investigation - 'raw_data': { - 'manufacturer_id_hex': f'0x{device.manufacturer_id:04X}' if device.manufacturer_id else None, - 'manufacturer_data_hex': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, - 'service_uuids': device.service_uuids, - 'service_data': {k: v.hex() for k, v in device.service_data.items()}, - 'appearance': device.appearance, - }, - - # Heuristics - 'heuristics': { - 'is_new': device.is_new, - 'is_persistent': device.is_persistent, - 'is_beacon_like': device.is_beacon_like, - 'is_strong_stable': device.is_strong_stable, - 'has_random_address': device.has_random_address, - 'is_randomized_mac': device.is_randomized_mac, - }, - - # Baseline status - 'baseline': { - 'in_baseline': device.in_baseline, - 'baseline_id': device.baseline_id, - }, - }) + return jsonify( + { + "device_id": device.device_id, + "device_key": device.device_key, + "address": device.address, + "address_type": device.address_type, + "name": device.name, + "manufacturer_name": device.manufacturer_name, + "manufacturer_id": device.manufacturer_id, + # Tracker detection + "tracker": { + "is_tracker": device.is_tracker, + "type": device.tracker_type, + "name": device.tracker_name, + "confidence": device.tracker_confidence, + "confidence_score": round(device.tracker_confidence_score, 2), + "evidence": device.tracker_evidence, + }, + # Risk analysis + "risk_analysis": { + "risk_score": round(device.risk_score, 2), + "risk_factors": device.risk_factors, + "warning": "Risk scores are heuristic indicators only. They do NOT prove malicious intent.", + }, + # Fingerprint (for MAC randomization tracking) + "fingerprint": { + "id": device.payload_fingerprint_id, + "stability": round(device.payload_fingerprint_stability, 2), + "note": "Fingerprints help track devices across MAC address changes but are probabilistic.", + }, + # Signal data + "signal": { + "rssi_current": device.rssi_current, + "rssi_median": round(device.rssi_median, 1) if device.rssi_median else None, + "rssi_ema": round(device.rssi_ema, 1) if device.rssi_ema else None, + "rssi_min": device.rssi_min, + "rssi_max": device.rssi_max, + "rssi_variance": round(device.rssi_variance, 2) if device.rssi_variance else None, + "tx_power": device.tx_power, + }, + # Proximity + "proximity": { + "band": device.proximity_band, + "estimated_distance_m": round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + "confidence": round(device.distance_confidence, 2), + }, + # Timeline / sightings + "timeline": { + "first_seen": device.first_seen.isoformat(), + "last_seen": device.last_seen.isoformat(), + "age_seconds": round(device.age_seconds, 1), + "duration_seconds": round(device.duration_seconds, 1), + "seen_count": device.seen_count, + "seen_rate": round(device.seen_rate, 2), + "rssi_history": rssi_history, + }, + # Raw advertisement data for investigation + "raw_data": { + "manufacturer_id_hex": f"0x{device.manufacturer_id:04X}" if device.manufacturer_id else None, + "manufacturer_data_hex": device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, + "service_uuids": device.service_uuids, + "service_data": {k: v.hex() for k, v in device.service_data.items()}, + "appearance": device.appearance, + }, + # Heuristics + "heuristics": { + "is_new": device.is_new, + "is_persistent": device.is_persistent, + "is_beacon_like": device.is_beacon_like, + "is_strong_stable": device.is_strong_stable, + "has_random_address": device.has_random_address, + "is_randomized_mac": device.is_randomized_mac, + }, + # Baseline status + "baseline": { + "in_baseline": device.in_baseline, + "baseline_id": device.baseline_id, + }, + } + ) -@bluetooth_v2_bp.route('/diagnostics', methods=['GET']) +@bluetooth_v2_bp.route("/diagnostics", methods=["GET"]) def get_diagnostics(): """ Get Bluetooth system diagnostics for troubleshooting. @@ -642,83 +633,75 @@ def get_diagnostics(): caps = check_capabilities() diagnostics = { - 'system': { - 'is_root': os.geteuid() == 0 if hasattr(os, 'geteuid') else False, - 'platform': os.uname().sysname if hasattr(os, 'uname') else 'unknown', + "system": { + "is_root": os.geteuid() == 0 if hasattr(os, "geteuid") else False, + "platform": os.uname().sysname if hasattr(os, "uname") else "unknown", }, - - 'bluez': { - 'has_bluez': caps.has_bluez, - 'version': caps.bluez_version, - 'has_dbus': caps.has_dbus, + "bluez": { + "has_bluez": caps.has_bluez, + "version": caps.bluez_version, + "has_dbus": caps.has_dbus, }, - - 'adapters': { - 'count': len(caps.adapters), - 'default': caps.default_adapter, - 'list': caps.adapters, + "adapters": { + "count": len(caps.adapters), + "default": caps.default_adapter, + "list": caps.adapters, }, - - 'permissions': { - 'has_bluetooth_permission': caps.has_bluetooth_permission, - 'is_soft_blocked': caps.is_soft_blocked, - 'is_hard_blocked': caps.is_hard_blocked, + "permissions": { + "has_bluetooth_permission": caps.has_bluetooth_permission, + "is_soft_blocked": caps.is_soft_blocked, + "is_hard_blocked": caps.is_hard_blocked, }, - - 'backends': { - 'recommended': caps.recommended_backend, - 'available': { - 'dbus': caps.has_dbus and caps.has_bluez, - 'bleak': caps.has_bleak, - 'hcitool': caps.has_hcitool, - 'bluetoothctl': caps.has_bluetoothctl, - 'btmgmt': caps.has_btmgmt, + "backends": { + "recommended": caps.recommended_backend, + "available": { + "dbus": caps.has_dbus and caps.has_bluez, + "bleak": caps.has_bleak, + "hcitool": caps.has_hcitool, + "bluetoothctl": caps.has_bluetoothctl, + "btmgmt": caps.has_btmgmt, }, }, - - 'can_scan': caps.can_scan, - 'issues': caps.issues, - - 'recommendations': [], + "can_scan": caps.can_scan, + "issues": caps.issues, + "recommendations": [], } # Add recommendations based on issues if not caps.can_scan: - diagnostics['recommendations'].append( - 'No scanning backends available. Install BlueZ or ensure Bluetooth adapter is present.' + diagnostics["recommendations"].append( + "No scanning backends available. Install BlueZ or ensure Bluetooth adapter is present." ) if caps.is_soft_blocked: - diagnostics['recommendations'].append( - 'Bluetooth is soft-blocked. Run: sudo rfkill unblock bluetooth' - ) + diagnostics["recommendations"].append("Bluetooth is soft-blocked. Run: sudo rfkill unblock bluetooth") if caps.is_hard_blocked: - diagnostics['recommendations'].append( - 'Bluetooth is hard-blocked (hardware switch). Enable Bluetooth on your device.' + diagnostics["recommendations"].append( + "Bluetooth is hard-blocked (hardware switch). Enable Bluetooth on your device." ) - if not caps.has_bluetooth_permission and not diagnostics['system']['is_root']: - diagnostics['recommendations'].append( - 'May need elevated permissions for BLE scanning. Try running with sudo or add user to bluetooth group.' + if not caps.has_bluetooth_permission and not diagnostics["system"]["is_root"]: + diagnostics["recommendations"].append( + "May need elevated permissions for BLE scanning. Try running with sudo or add user to bluetooth group." ) if caps.has_dbus and caps.has_bluez and len(caps.adapters) == 0: - diagnostics['recommendations'].append( - 'BlueZ is available but no adapters found. Check if Bluetooth adapter is connected and enabled.' + diagnostics["recommendations"].append( + "BlueZ is available but no adapters found. Check if Bluetooth adapter is connected and enabled." ) # Check for btmon availability (useful for debugging) try: - result = subprocess.run(['which', 'btmon'], capture_output=True, timeout=2) - diagnostics['backends']['available']['btmon'] = result.returncode == 0 + result = subprocess.run(["which", "btmon"], capture_output=True, timeout=2) + diagnostics["backends"]["available"]["btmon"] = result.returncode == 0 except Exception: - diagnostics['backends']['available']['btmon'] = False + diagnostics["backends"]["available"]["btmon"] = False return jsonify(diagnostics) -@bluetooth_v2_bp.route('/baseline/set', methods=['POST']) +@bluetooth_v2_bp.route("/baseline/set", methods=["POST"]) def set_baseline(): """ Set current devices as baseline. @@ -730,7 +713,7 @@ def set_baseline(): JSON with baseline info. """ data = request.get_json() or {} - name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}') + name = data.get("name", f"Baseline {datetime.now().strftime('%Y-%m-%d %H:%M')}") scanner = get_bluetooth_scanner() @@ -744,15 +727,17 @@ def set_baseline(): # Update scanner's in-memory baseline device_count = scanner.set_baseline() - return jsonify({ - 'status': 'success', - 'baseline_id': baseline_id, - 'name': name, - 'device_count': device_count, - }) + return jsonify( + { + "status": "success", + "baseline_id": baseline_id, + "name": name, + "device_count": device_count, + } + ) -@bluetooth_v2_bp.route('/baseline/clear', methods=['POST']) +@bluetooth_v2_bp.route("/baseline/clear", methods=["POST"]) def clear_baseline(): """ Clear the active baseline. @@ -769,12 +754,14 @@ def clear_baseline(): # Clear in scanner scanner.clear_baseline() - return jsonify({ - 'status': 'cleared' if cleared else 'no_baseline', - }) + return jsonify( + { + "status": "cleared" if cleared else "no_baseline", + } + ) -@bluetooth_v2_bp.route('/baseline/list', methods=['GET']) +@bluetooth_v2_bp.route("/baseline/list", methods=["GET"]) def list_baselines(): """ List all saved baselines. @@ -784,13 +771,15 @@ def list_baselines(): """ init_bt_tables() baselines = get_all_baselines() - return jsonify({ - 'count': len(baselines), - 'baselines': baselines, - }) + return jsonify( + { + "count": len(baselines), + "baselines": baselines, + } + ) -@bluetooth_v2_bp.route('/export', methods=['GET']) +@bluetooth_v2_bp.route("/export", methods=["GET"]) def export_devices(): """ Export devices in CSV or JSON format. @@ -801,118 +790,142 @@ def export_devices(): Returns: CSV or JSON file download. """ - export_format = request.args.get('format', 'json').lower() + export_format = request.args.get("format", "json").lower() scanner = get_bluetooth_scanner() devices = scanner.get_devices() - if export_format == 'csv': + if export_format == "csv": output = io.StringIO() writer = csv.writer(output) # Header - writer.writerow([ - 'device_id', 'address', 'address_type', 'protocol', 'name', - 'manufacturer_name', 'rssi_current', 'rssi_median', 'range_band', - 'first_seen', 'last_seen', 'seen_count', 'heuristic_flags', - 'in_baseline' - ]) + writer.writerow( + [ + "device_id", + "address", + "address_type", + "protocol", + "name", + "manufacturer_name", + "rssi_current", + "rssi_median", + "range_band", + "first_seen", + "last_seen", + "seen_count", + "heuristic_flags", + "in_baseline", + ] + ) # Data rows for device in devices: - writer.writerow([ - device.device_id, - device.address, - device.address_type, - device.protocol, - device.name or '', - device.manufacturer_name or '', - device.rssi_current or '', - round(device.rssi_median, 1) if device.rssi_median else '', - device.range_band, - device.first_seen.isoformat(), - device.last_seen.isoformat(), - device.seen_count, - ','.join(device.heuristic_flags), - 'yes' if device.in_baseline else 'no', - ]) + writer.writerow( + [ + device.device_id, + device.address, + device.address_type, + device.protocol, + device.name or "", + device.manufacturer_name or "", + device.rssi_current or "", + round(device.rssi_median, 1) if device.rssi_median else "", + device.range_band, + device.first_seen.isoformat(), + device.last_seen.isoformat(), + device.seen_count, + ",".join(device.heuristic_flags), + "yes" if device.in_baseline else "no", + ] + ) output.seek(0) return Response( output.getvalue(), - mimetype='text/csv', + mimetype="text/csv", headers={ - 'Content-Disposition': f'attachment; filename=bluetooth_devices_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' - } + "Content-Disposition": f"attachment; filename=bluetooth_devices_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + }, ) else: # JSON data = { - 'exported_at': datetime.now().isoformat(), - 'device_count': len(devices), - 'devices': [d.to_dict() for d in devices], + "exported_at": datetime.now().isoformat(), + "device_count": len(devices), + "devices": [d.to_dict() for d in devices], } return Response( json.dumps(data, indent=2), - mimetype='application/json', + mimetype="application/json", headers={ - 'Content-Disposition': f'attachment; filename=bluetooth_devices_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json' - } + "Content-Disposition": f"attachment; filename=bluetooth_devices_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + }, ) -@bluetooth_v2_bp.route('/stream', methods=['GET']) +@bluetooth_v2_bp.route("/stream", methods=["GET"]) def stream_events(): - """ - SSE event stream for real-time device updates. - - Returns: - Server-Sent Events stream. - """ + """SSE event stream for real-time device updates.""" scanner = get_bluetooth_scanner() def map_event_type(event: dict) -> tuple[str, dict]: - """Map internal event types to SSE event names.""" - event_type = event.get('type', 'unknown') - - if event_type == 'device': - # Device update - send the device data - return 'device_update', event.get('device', event) - elif event_type == 'status': - status = event.get('status', '') - if status == 'started': - return 'scan_started', event - elif status == 'stopped': - return 'scan_stopped', event - return 'status', event - elif event_type == 'error': - return 'error', event - elif event_type == 'baseline': - return 'baseline', event - elif event_type == 'ping': - return 'ping', {} + event_type = event.get("type", "unknown") + if event_type == "device": + return "device_update", event.get("device", event) + elif event_type == "status": + status = event.get("status", "") + if status == "started": + return "scan_started", event + elif status == "stopped": + return "scan_stopped", event + return "status", event + elif event_type == "error": + return "error", event + elif event_type == "baseline": + return "baseline", event + elif event_type == "ping": + return "ping", {} else: return event_type, event + subscriber, unsubscribe = subscribe_fanout_queue( + source_queue=scanner._event_queue, + channel_key="bluetooth_v2", + source_timeout=1.0, + ) + def event_generator() -> Generator[str, None, None]: - """Generate SSE events from scanner.""" - for event in scanner.stream_events(timeout=1.0): - event_name, event_data = map_event_type(event) - with contextlib.suppress(Exception): - process_event('bluetooth', event_data, event_name) - yield format_sse(event_data, event=event_name) + last_keepalive = time.time() + yield format_sse({"type": "keepalive"}) + try: + while True: + try: + event = subscriber.get(timeout=1.0) + last_keepalive = time.time() + event_name, event_data = map_event_type(event) + with contextlib.suppress(Exception): + process_event("bluetooth", event_data, event_name) + yield format_sse(event_data, event=event_name) + except queue.Empty: + now = time.time() + if now - last_keepalive >= 30.0: + yield format_sse({"type": "keepalive"}) + last_keepalive = now + finally: + unsubscribe() return Response( event_generator(), - mimetype='text/event-stream', + mimetype="text/event-stream", headers={ - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', - } + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, ) -@bluetooth_v2_bp.route('/clear', methods=['POST']) +@bluetooth_v2_bp.route("/clear", methods=["POST"]) def clear_devices(): """ Clear all tracked devices (does not affect baseline). @@ -923,10 +936,10 @@ def clear_devices(): scanner = get_bluetooth_scanner() scanner.clear_devices() - return jsonify({'status': 'cleared'}) + return jsonify({"status": "cleared"}) -@bluetooth_v2_bp.route('/prune', methods=['POST']) +@bluetooth_v2_bp.route("/prune", methods=["POST"]) def prune_stale(): """ Prune stale devices. @@ -938,15 +951,17 @@ def prune_stale(): JSON with count of pruned devices. """ data = request.get_json() or {} - max_age = data.get('max_age', 300) + max_age = data.get("max_age", 300) scanner = get_bluetooth_scanner() pruned = scanner.prune_stale(max_age_seconds=max_age) - return jsonify({ - 'status': 'success', - 'pruned_count': pruned, - }) + return jsonify( + { + "status": "success", + "pruned_count": pruned, + } + ) # ============================================================================= @@ -967,14 +982,15 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: List of device dictionaries in TSCM format. """ import logging - logger = logging.getLogger('intercept.bluetooth_v2') + + logger = logging.getLogger("intercept.bluetooth_v2") scanner = get_bluetooth_scanner() # Start scan if not running if not scanner.is_scanning: logger.info(f"TSCM snapshot: Scanner not running, starting scan for {duration}s") - scanner.start_scan(mode='auto', duration_s=duration) + scanner.start_scan(mode="auto", duration_s=duration) time.sleep(duration + 1) else: logger.info("TSCM snapshot: Scanner already running, getting current devices") @@ -986,71 +1002,68 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: tscm_devices = [] for device in devices: manufacturer_name = device.manufacturer_name - if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'): + if (not manufacturer_name) or str(manufacturer_name).lower().startswith("unknown"): if device.address and not device.is_randomized_mac: try: from data.oui import get_manufacturer + oui_vendor = get_manufacturer(device.address) - if oui_vendor and oui_vendor != 'Unknown': + if oui_vendor and oui_vendor != "Unknown": manufacturer_name = oui_vendor except Exception: pass device_data = { - 'mac': device.address, - 'address_type': device.address_type, - 'device_key': device.device_key, - 'name': device.name or 'Unknown', - 'rssi': device.rssi_current or -100, - 'rssi_median': device.rssi_median, - 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, - 'type': _classify_device_type(device), - 'manufacturer': manufacturer_name, - 'manufacturer_id': device.manufacturer_id, - 'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, - 'protocol': device.protocol, - 'first_seen': device.first_seen.isoformat(), - 'last_seen': device.last_seen.isoformat(), - 'seen_count': device.seen_count, - 'range_band': device.range_band, - 'proximity_band': device.proximity_band, - 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, - 'distance_confidence': round(device.distance_confidence, 2), - 'is_randomized_mac': device.is_randomized_mac, - 'threat_tags': device.threat_tags, - 'heuristics': { - 'is_new': device.is_new, - 'is_persistent': device.is_persistent, - 'is_beacon_like': device.is_beacon_like, - 'is_strong_stable': device.is_strong_stable, - 'has_random_address': device.has_random_address, + "mac": device.address, + "address_type": device.address_type, + "device_key": device.device_key, + "name": device.name or "Unknown", + "rssi": device.rssi_current or -100, + "rssi_median": device.rssi_median, + "rssi_ema": round(device.rssi_ema, 1) if device.rssi_ema else None, + "type": _classify_device_type(device), + "manufacturer": manufacturer_name, + "manufacturer_id": device.manufacturer_id, + "manufacturer_data": device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, + "protocol": device.protocol, + "first_seen": device.first_seen.isoformat(), + "last_seen": device.last_seen.isoformat(), + "seen_count": device.seen_count, + "range_band": device.range_band, + "proximity_band": device.proximity_band, + "estimated_distance_m": round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + "distance_confidence": round(device.distance_confidence, 2), + "is_randomized_mac": device.is_randomized_mac, + "threat_tags": device.threat_tags, + "heuristics": { + "is_new": device.is_new, + "is_persistent": device.is_persistent, + "is_beacon_like": device.is_beacon_like, + "is_strong_stable": device.is_strong_stable, + "has_random_address": device.has_random_address, }, - 'in_baseline': device.in_baseline, - + "in_baseline": device.in_baseline, # Tracker detection data (v2) - 'tracker': { - 'is_tracker': device.is_tracker, - 'type': device.tracker_type, - 'name': device.tracker_name, - 'confidence': device.tracker_confidence, - 'confidence_score': round(device.tracker_confidence_score, 2), - 'evidence': device.tracker_evidence, + "tracker": { + "is_tracker": device.is_tracker, + "type": device.tracker_type, + "name": device.tracker_name, + "confidence": device.tracker_confidence, + "confidence_score": round(device.tracker_confidence_score, 2), + "evidence": device.tracker_evidence, }, - # Risk analysis (v2) - 'risk_analysis': { - 'risk_score': round(device.risk_score, 2), - 'risk_factors': device.risk_factors, + "risk_analysis": { + "risk_score": round(device.risk_score, 2), + "risk_factors": device.risk_factors, }, - # Fingerprint for cross-MAC tracking (v2) - 'fingerprint': { - 'id': device.payload_fingerprint_id, - 'stability': round(device.payload_fingerprint_stability, 2), + "fingerprint": { + "id": device.payload_fingerprint_id, + "stability": round(device.payload_fingerprint_stability, 2), }, - # Service UUIDs for analysis - 'service_uuids': device.service_uuids, + "service_uuids": device.service_uuids, } tscm_devices.append(device_data) @@ -1063,7 +1076,7 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: # ============================================================================= -@bluetooth_v2_bp.route('/proximity/snapshot', methods=['GET']) +@bluetooth_v2_bp.route("/proximity/snapshot", methods=["GET"]) def get_proximity_snapshot(): """ Get proximity snapshot for radar visualization. @@ -1079,8 +1092,8 @@ def get_proximity_snapshot(): JSON with proximity data for all active devices. """ scanner = get_bluetooth_scanner() - max_age = request.args.get('max_age', 60, type=float) - min_confidence = request.args.get('min_confidence', 0.0, type=float) + max_age = request.args.get("max_age", 60, type=float) + min_confidence = request.args.get("min_confidence", 0.0, type=float) devices = scanner.get_devices(max_age_seconds=max_age) @@ -1090,47 +1103,49 @@ def get_proximity_snapshot(): # Build proximity snapshot snapshot = { - 'timestamp': datetime.now().isoformat(), - 'device_count': len(devices), - 'zone_counts': { - 'immediate': 0, - 'near': 0, - 'far': 0, - 'unknown': 0, + "timestamp": datetime.now().isoformat(), + "device_count": len(devices), + "zone_counts": { + "immediate": 0, + "near": 0, + "far": 0, + "unknown": 0, }, - 'devices': [], + "devices": [], } for device in devices: # Count by zone - band = device.proximity_band or 'unknown' - if band in snapshot['zone_counts']: - snapshot['zone_counts'][band] += 1 + band = device.proximity_band or "unknown" + if band in snapshot["zone_counts"]: + snapshot["zone_counts"][band] += 1 else: - snapshot['zone_counts']['unknown'] += 1 + snapshot["zone_counts"]["unknown"] += 1 - snapshot['devices'].append({ - 'device_key': device.device_key, - 'device_id': device.device_id, - 'name': device.name, - 'address': device.address, - 'rssi_current': device.rssi_current, - 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, - 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, - 'proximity_band': device.proximity_band, - 'distance_confidence': round(device.distance_confidence, 2), - 'is_new': device.is_new, - 'is_randomized_mac': device.is_randomized_mac, - 'in_baseline': device.in_baseline, - 'heuristic_flags': device.heuristic_flags, - 'last_seen': device.last_seen.isoformat(), - 'age_seconds': round(device.age_seconds, 1), - }) + snapshot["devices"].append( + { + "device_key": device.device_key, + "device_id": device.device_id, + "name": device.name, + "address": device.address, + "rssi_current": device.rssi_current, + "rssi_ema": round(device.rssi_ema, 1) if device.rssi_ema else None, + "estimated_distance_m": round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + "proximity_band": device.proximity_band, + "distance_confidence": round(device.distance_confidence, 2), + "is_new": device.is_new, + "is_randomized_mac": device.is_randomized_mac, + "in_baseline": device.in_baseline, + "heuristic_flags": device.heuristic_flags, + "last_seen": device.last_seen.isoformat(), + "age_seconds": round(device.age_seconds, 1), + } + ) return jsonify(snapshot) -@bluetooth_v2_bp.route('/heatmap/data', methods=['GET']) +@bluetooth_v2_bp.route("/heatmap/data", methods=["GET"]) def get_heatmap_data(): """ Get heatmap data for timeline visualization. @@ -1148,14 +1163,14 @@ def get_heatmap_data(): """ scanner = get_bluetooth_scanner() - top_n = request.args.get('top_n', 20, type=int) - window_minutes = request.args.get('window_minutes', 10, type=int) - bucket_seconds = request.args.get('bucket_seconds', 10, type=int) - sort_by = request.args.get('sort_by', 'recency') + top_n = request.args.get("top_n", 20, type=int) + window_minutes = request.args.get("window_minutes", 10, type=int) + bucket_seconds = request.args.get("bucket_seconds", 10, type=int) + sort_by = request.args.get("sort_by", "recency") # Validate sort_by - if sort_by not in ('recency', 'strength', 'activity'): - sort_by = 'recency' + if sort_by not in ("recency", "strength", "activity"): + sort_by = "recency" # Get heatmap data from aggregator heatmap_data = scanner._aggregator.get_heatmap_data( @@ -1168,7 +1183,7 @@ def get_heatmap_data(): return jsonify(heatmap_data) -@bluetooth_v2_bp.route('/devices//timeseries', methods=['GET']) +@bluetooth_v2_bp.route("/devices//timeseries", methods=["GET"]) def get_device_timeseries(device_key: str): """ Get timeseries data for a specific device. @@ -1185,11 +1200,12 @@ def get_device_timeseries(device_key: str): """ scanner = get_bluetooth_scanner() - window_minutes = request.args.get('window_minutes', 30, type=int) - bucket_seconds = request.args.get('bucket_seconds', 10, type=int) + window_minutes = request.args.get("window_minutes", 30, type=int) + bucket_seconds = request.args.get("bucket_seconds", 10, type=int) # URL decode device key from urllib.parse import unquote + device_key = unquote(device_key) # Get device info @@ -1203,114 +1219,117 @@ def get_device_timeseries(device_key: str): ) result = { - 'device_key': device_key, - 'window_minutes': window_minutes, - 'bucket_seconds': bucket_seconds, - 'observation_count': len(timeseries), - 'timeseries': timeseries, + "device_key": device_key, + "window_minutes": window_minutes, + "bucket_seconds": bucket_seconds, + "observation_count": len(timeseries), + "timeseries": timeseries, } if device: - result.update({ - 'name': device.name, - 'address': device.address, - 'rssi_current': device.rssi_current, - 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, - 'proximity_band': device.proximity_band, - 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, - }) + result.update( + { + "name": device.name, + "address": device.address, + "rssi_current": device.rssi_current, + "rssi_ema": round(device.rssi_ema, 1) if device.rssi_ema else None, + "proximity_band": device.proximity_band, + "estimated_distance_m": round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + } + ) return jsonify(result) def _classify_device_type(device: BTDeviceAggregate) -> str: """Classify device type from available data.""" - name_lower = (device.name or '').lower() - manufacturer_lower = (device.manufacturer_name or '').lower() + name_lower = (device.name or "").lower() + manufacturer_lower = (device.manufacturer_name or "").lower() service_uuids = device.service_uuids or [] - if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'): + if (not manufacturer_lower) or manufacturer_lower.startswith("unknown"): if device.address and not device.is_randomized_mac: try: from data.oui import get_manufacturer + oui_vendor = get_manufacturer(device.address) - if oui_vendor and oui_vendor != 'Unknown': + if oui_vendor and oui_vendor != "Unknown": manufacturer_lower = oui_vendor.lower() except Exception: pass def normalize_uuid(uuid: str) -> str: if not uuid: - return '' + return "" value = str(uuid).lower().strip() - if value.startswith('0x'): + if value.startswith("0x"): value = value[2:] # Bluetooth Base UUID normalization (16-bit UUIDs) - if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8: + if value.endswith("-0000-1000-8000-00805f9b34fb") and len(value) >= 8: return value[4:8] if len(value) == 4: return value return value # Check by name patterns - if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']): - return 'audio' - if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']): - return 'wearable' - if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']): - return 'phone' - if any(x in name_lower for x in ['macbook', 'laptop', 'thinkpad', 'surface']): - return 'computer' - if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']): - return 'peripheral' - if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']): - return 'tracker' - if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']): - return 'speaker' - if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']): - return 'media' + if any(x in name_lower for x in ["airpods", "headphone", "earbuds", "buds", "beats"]): + return "audio" + if any(x in name_lower for x in ["watch", "band", "fitbit", "garmin"]): + return "wearable" + if any(x in name_lower for x in ["iphone", "pixel", "galaxy", "phone"]): + return "phone" + if any(x in name_lower for x in ["macbook", "laptop", "thinkpad", "surface"]): + return "computer" + if any(x in name_lower for x in ["mouse", "keyboard", "trackpad"]): + return "peripheral" + if any(x in name_lower for x in ["tile", "airtag", "smarttag", "chipolo"]): + return "tracker" + if any(x in name_lower for x in ["speaker", "sonos", "echo", "home"]): + return "speaker" + if any(x in name_lower for x in ["tv", "chromecast", "roku", "firestick"]): + return "media" # Tracker signals (metadata or Find My service) - if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None): - return 'tracker' + if getattr(device, "is_tracker", False) or getattr(device, "tracker_type", None): + return "tracker" normalized_uuids = {normalize_uuid(u) for u in service_uuids if u} - if 'fd6f' in normalized_uuids: - return 'tracker' + if "fd6f" in normalized_uuids: + return "tracker" # Service UUIDs (GATT / classic) - audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'} - wearable_uuids = {'180d', '1814', '1816'} - hid_uuids = {'1812'} - beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'} + audio_uuids = {"110b", "110a", "111e", "111f", "1108", "1203"} + wearable_uuids = {"180d", "1814", "1816"} + hid_uuids = {"1812"} + beacon_uuids = {"feaa", "feab", "feb1", "febe"} if normalized_uuids & audio_uuids: - return 'audio' + return "audio" if normalized_uuids & hid_uuids: - return 'peripheral' + return "peripheral" if normalized_uuids & wearable_uuids: - return 'wearable' + return "wearable" if normalized_uuids & beacon_uuids: - return 'beacon' + return "beacon" # Check by manufacturer - if 'apple' in manufacturer_lower: - return 'apple_device' - if 'samsung' in manufacturer_lower: - return 'samsung_device' + if "apple" in manufacturer_lower: + return "apple_device" + if "samsung" in manufacturer_lower: + return "samsung_device" # Check by class of device if device.major_class: major = device.major_class.lower() - if 'audio' in major: - return 'audio' - if 'phone' in major: - return 'phone' - if 'computer' in major: - return 'computer' - if 'peripheral' in major: - return 'peripheral' - if 'wearable' in major: - return 'wearable' + if "audio" in major: + return "audio" + if "phone" in major: + return "phone" + if "computer" in major: + return "computer" + if "peripheral" in major: + return "peripheral" + if "wearable" in major: + return "wearable" - return 'unknown' + return "unknown" diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 92080a7..78ad31e 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -1031,7 +1031,7 @@ body { width: 100%; object-fit: cover; border-radius: 6px; - border: 1px solid rgba(0, 212, 255, 0.3); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.3); } .selected-callsign { @@ -2227,6 +2227,10 @@ body { color: var(--text-inverse); } +html[data-ui-tier="enhanced"] .strip-btn.primary { + background: linear-gradient(135deg, rgba(46, 125, 138, 0.85) 0%, rgba(20, 88, 100, 0.80) 100%); +} + .strip-btn.primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(var(--accent-cyan-rgb), 0.3); @@ -2462,19 +2466,18 @@ body { } /* ============================================ - ENHANCED TIER — amber military console + ENHANCED TIER — signals teal console ============================================ */ html[data-ui-tier="enhanced"] { - --bg-dark: #080600; - --bg-panel: #0c0a04; - --bg-card: #0e0b05; - --radar-bg: #0c0a04; - --radar-cyan: #c89628; - --border-glow: rgba(200, 150, 40, 0.25); - --border-color: rgba(200, 150, 40, 0.2); - --grid-line: rgba(200, 150, 40, 0.07); - --accent-cyan: #c89628; - --accent-green: #c89628; + --bg-dark: #000000; + --bg-panel: #020404; + --bg-card: #020404; + --radar-bg: #020404; + --radar-cyan: #2e7d8a; + --border-glow: rgba(46, 125, 138, 0.20); + --border-color: rgba(46, 125, 138, 0.18); + --grid-line: rgba(46, 125, 138, 0.07); + --accent-cyan: #2e7d8a; } /* ============================================ diff --git a/static/css/adsb_history.css b/static/css/adsb_history.css index 02f29ec..dd51101 100644 --- a/static/css/adsb_history.css +++ b/static/css/adsb_history.css @@ -721,3 +721,28 @@ body { grid-template-columns: 1fr; } } + +html[data-ui-tier="enhanced"] { + --accent-cyan: #2e7d8a; + --accent-cyan-rgb: 46, 125, 138; + --border-color: rgba(46, 125, 138, 0.18); + --border-glow: rgba(46, 125, 138, 0.20); + --grid-line: rgba(46, 125, 138, 0.07); + --bg-dark: #000000; + --bg-panel: #020404; + --bg-card: #020404; +} + +html[data-ui-tier="enhanced"] body { + background: #000000; +} + +html[data-ui-tier="enhanced"] .session-strip { + background: linear-gradient(120deg, rgba(4, 8, 8, 0.95), rgba(6, 10, 10, 0.95)); +} + +html[data-ui-tier="lean"] { + --border-color: #2a2a2a; + --border-glow: transparent; + --grid-line: transparent; +} diff --git a/static/css/agents.css b/static/css/agents.css index 9b5ea47..8a79e80 100644 --- a/static/css/agents.css +++ b/static/css/agents.css @@ -10,15 +10,15 @@ align-items: center; gap: 8px; padding: 6px 12px; - background: rgba(0, 212, 255, 0.1); - border: 1px solid rgba(0, 212, 255, 0.3); + background: rgba(var(--accent-cyan-rgb), 0.1); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.3); border-radius: 20px; cursor: pointer; transition: all 0.2s; } .agent-indicator:hover { - background: rgba(0, 212, 255, 0.2); + background: rgba(var(--accent-cyan-rgb), 0.2); border-color: var(--accent-cyan); } @@ -49,7 +49,7 @@ .agent-indicator-count { font-size: 10px; padding: 2px 6px; - background: rgba(0, 212, 255, 0.2); + background: rgba(var(--accent-cyan-rgb), 0.2); border-radius: 10px; color: var(--accent-cyan); } @@ -123,11 +123,11 @@ } .agent-selector-item:hover { - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); } .agent-selector-item.selected { - background: rgba(0, 212, 255, 0.15); + background: rgba(var(--accent-cyan-rgb), 0.15); border-left: 3px solid var(--accent-cyan); } @@ -188,7 +188,7 @@ gap: 4px; padding: 2px 8px; font-size: 10px; - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); color: var(--accent-cyan); border-radius: 10px; font-family: var(--font-mono); @@ -201,7 +201,7 @@ } .agent-badge.agent-remote { - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); color: var(--accent-cyan); } diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css index f6fe444..c867aee 100644 --- a/static/css/ais_dashboard.css +++ b/static/css/ais_dashboard.css @@ -1375,19 +1375,18 @@ body { } /* ============================================ - ENHANCED TIER — amber military console + ENHANCED TIER — signals teal console ============================================ */ html[data-ui-tier="enhanced"] { - --bg-dark: #080600; - --bg-panel: #0c0a04; - --bg-card: #0e0b05; - --radar-bg: #0c0a04; - --radar-cyan: #c89628; - --border-glow: rgba(200, 150, 40, 0.25); - --border-color: rgba(200, 150, 40, 0.2); - --grid-line: rgba(200, 150, 40, 0.07); - --accent-cyan: #c89628; - --accent-green: #c89628; + --bg-dark: #000000; + --bg-panel: #020404; + --bg-card: #020404; + --radar-bg: #020404; + --radar-cyan: #2e7d8a; + --border-glow: rgba(46, 125, 138, 0.20); + --border-color: rgba(46, 125, 138, 0.18); + --grid-line: rgba(46, 125, 138, 0.07); + --accent-cyan: #2e7d8a; } /* ============================================ diff --git a/static/css/components/device-cards.css b/static/css/components/device-cards.css index 69350b0..7ee2f6f 100644 --- a/static/css/components/device-cards.css +++ b/static/css/components/device-cards.css @@ -38,7 +38,7 @@ .device-card:hover { border-color: var(--accent-cyan, #00d4ff); - box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2); + box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.2); } .device-card:active { diff --git a/static/css/components/proximity-viz.css b/static/css/components/proximity-viz.css index 264dc3b..e163fd0 100644 --- a/static/css/components/proximity-viz.css +++ b/static/css/components/proximity-viz.css @@ -162,8 +162,8 @@ } .heatmap-row.selected { - background: rgba(0, 212, 255, 0.1); - outline: 1px solid rgba(0, 212, 255, 0.3); + background: rgba(var(--accent-cyan-rgb), 0.1); + outline: 1px solid rgba(var(--accent-cyan-rgb), 0.3); } .heatmap-header { diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css index 77e5322..b652617 100644 --- a/static/css/components/signal-cards.css +++ b/static/css/components/signal-cards.css @@ -1645,7 +1645,7 @@ .signal-card.signal-card-clickable:hover { border-color: var(--accent-cyan, #00d4ff); - box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2); + box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.2); } .signal-card.signal-card-clickable:active { diff --git a/static/css/components/ux-platform.css b/static/css/components/ux-platform.css index 8be3033..5eb8936 100644 --- a/static/css/components/ux-platform.css +++ b/static/css/components/ux-platform.css @@ -406,6 +406,42 @@ color: var(--text-primary, #e6edf5); } +/* ---- Enhanced tier overrides ---- */ +html[data-ui-tier="enhanced"] .run-state-strip { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.96), rgba(2, 4, 4, 0.97)); + border-color: rgba(46, 125, 138, 0.28); +} + +html[data-ui-tier="enhanced"] .run-state-chip { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.82), rgba(2, 4, 4, 0.84)); + border-color: rgba(46, 125, 138, 0.22); +} + +html[data-ui-tier="enhanced"] .run-state-chip.active { + border-color: rgba(46, 125, 138, 0.60); + box-shadow: inset 0 0 0 1px rgba(46, 125, 138, 0.16); +} + +html[data-ui-tier="enhanced"] .run-state-chip .dot { + background: rgba(46, 125, 138, 0.40); + box-shadow: none; +} + +html[data-ui-tier="enhanced"] .run-state-chip.running .dot { + background: var(--accent-green, #38c180); + box-shadow: 0 0 0 4px rgba(56, 193, 128, 0.16), 0 0 12px rgba(56, 193, 128, 0.35); +} + +html[data-ui-tier="enhanced"] .run-state-btn { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.9), rgba(2, 4, 4, 0.92)); + border-color: rgba(46, 125, 138, 0.40); +} + +html[data-ui-tier="enhanced"] .run-state-btn:hover { + background: rgba(46, 125, 138, 0.12); + border-color: rgba(46, 125, 138, 0.65); +} + /* ---- Light theme overrides ---- */ [data-theme="light"] .run-state-chip { background: linear-gradient(180deg, rgba(233, 238, 245, 0.9), rgba(225, 232, 242, 0.92)); diff --git a/static/css/core/components.css b/static/css/core/components.css index 0023666..a310fd5 100644 --- a/static/css/core/components.css +++ b/static/css/core/components.css @@ -1206,12 +1206,12 @@ textarea:focus { } /* ============================================ - ENHANCED TIER — amber left-border card accents + ENHANCED TIER — signals teal card accents ============================================ */ [data-ui-tier="enhanced"] .stat-card, [data-ui-tier="enhanced"] .data-card { border-left: 2px solid var(--accent-cyan); - background: rgba(200, 150, 40, 0.03); + background: rgba(46, 125, 138, 0.03); } [data-ui-tier="enhanced"] .stat-value, @@ -1226,5 +1226,10 @@ textarea:focus { letter-spacing: 2px; text-transform: uppercase; font-size: var(--text-xs); - color: rgba(200, 150, 40, 0.5); + color: rgba(46, 125, 138, 0.55); +} + +html[data-ui-tier="enhanced"] .card-header, +html[data-ui-tier="enhanced"] .panel-header { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.88) 0%, rgba(2, 4, 4, 0.9) 100%); } diff --git a/static/css/core/layout.css b/static/css/core/layout.css index a5d12c4..f240660 100644 --- a/static/css/core/layout.css +++ b/static/css/core/layout.css @@ -1188,10 +1188,10 @@ a.nav-dashboard-btn:hover { } /* ============================================ - ENHANCED TIER — amber console nav framing + ENHANCED TIER — signals teal console nav framing ============================================ */ [data-ui-tier="enhanced"] .mode-nav { - background: linear-gradient(180deg, rgba(14, 11, 3, 0.95), rgba(8, 6, 0, 0.92)); + background: linear-gradient(180deg, rgba(4, 8, 8, 0.95), rgba(2, 4, 4, 0.92)); } [data-ui-tier="enhanced"] .mode-nav::after, @@ -1201,16 +1201,16 @@ a.nav-dashboard-btn:hover { } [data-ui-tier="enhanced"] .mode-nav-btn.active { - background: rgba(200, 150, 40, 0.08); + background: rgba(46, 125, 138, 0.08); color: var(--accent-cyan); border-left: 2px solid var(--accent-cyan); - box-shadow: -2px 0 8px rgba(200, 150, 40, 0.15); + box-shadow: -2px 0 8px rgba(46, 125, 138, 0.15); padding-left: 12px; } [data-ui-tier="enhanced"] .nav-clock .utc-time { color: var(--accent-cyan); - text-shadow: 0 0 8px rgba(200, 150, 40, 0.3); + text-shadow: 0 0 8px rgba(46, 125, 138, 0.28); font-weight: 700; } @@ -1236,14 +1236,14 @@ a.nav-dashboard-btn:hover { /* Enhanced tier toggle button styling */ [data-ui-tier="enhanced"] .nav-tier-btn { - background: rgba(200, 150, 40, 0.10); - border-color: rgba(200, 150, 40, 0.4); - color: #c89628; - box-shadow: 0 0 8px rgba(200, 150, 40, 0.08); - text-shadow: 0 0 6px rgba(200, 150, 40, 0.3); + background: rgba(46, 125, 138, 0.10); + border-color: rgba(46, 125, 138, 0.38); + color: #2e7d8a; + box-shadow: 0 0 8px rgba(46, 125, 138, 0.07); + text-shadow: 0 0 6px rgba(46, 125, 138, 0.25); } [data-ui-tier="enhanced"] .nav-tier-btn:hover { - background: rgba(200, 150, 40, 0.16); - border-color: rgba(200, 150, 40, 0.6); + background: rgba(46, 125, 138, 0.16); + border-color: rgba(46, 125, 138, 0.55); } diff --git a/static/css/core/variables.css b/static/css/core/variables.css index 884eaa5..d912c6d 100644 --- a/static/css/core/variables.css +++ b/static/css/core/variables.css @@ -29,6 +29,7 @@ /* Accent colors */ --accent-cyan: #4aa3ff; --accent-cyan-rgb: 74, 163, 255; + --primary-color: var(--accent-cyan); --accent-cyan-dim: rgba(var(--accent-cyan-rgb), 0.16); --accent-cyan-hover: #6bb3ff; --accent-cyan-glow: rgba(var(--accent-cyan-rgb), 0.12); @@ -108,7 +109,7 @@ /* ============================================ TYPOGRAPHY ============================================ */ - --font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-sans: 'Inter', 'Roboto Condensed', 'Helvetica Neue', Arial, sans-serif; --font-mono: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', Consolas, monospace; /* Font sizes */ @@ -178,85 +179,6 @@ --content-max-width: 1400px; } -/* ============================================ - LIGHT THEME OVERRIDES - ============================================ */ -[data-theme="light"] { - --bg-primary: #f4f7fb; - --bg-secondary: #e9eef5; - --bg-tertiary: #dde5f0; - --bg-card: #ffffff; - --bg-elevated: #f1f4f9; - --bg-overlay: rgba(244, 247, 251, 0.92); - --surface-glass: rgba(255, 255, 255, 0.84); - --surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%); - --ambient-top-left: rgba(31, 95, 168, 0.1); - --ambient-top-right: rgba(31, 138, 87, 0.06); - --ambient-bottom: rgba(181, 134, 58, 0.05); - - /* Background aliases for components */ - --bg-dark: var(--bg-primary); - --bg-panel: var(--bg-secondary); - - --accent-cyan: #1f5fa8; - --accent-cyan-rgb: 31, 95, 168; - --accent-cyan-dim: rgba(31, 95, 168, 0.12); - --accent-cyan-hover: #2c73bf; - --accent-green: #1f8a57; - --accent-green-hover: #167a4a; - --accent-green-dim: rgba(31, 138, 87, 0.12); - --accent-red: #c74444; - --accent-red-hover: #b33a3a; - --accent-red-dim: rgba(199, 68, 68, 0.12); - --accent-orange: #b5863a; - --accent-orange-dim: rgba(181, 134, 58, 0.12); - --accent-amber: #b5863a; - --accent-amber-dim: rgba(181, 134, 58, 0.12); - --accent-yellow: #9a8420; - --accent-purple: #6b5ba8; - - /* Status colors - light theme */ - --status-online: #1f8a57; - --status-warning: #b5863a; - --status-error: #c74444; - --status-offline: #6b7c93; - --status-info: #1f5fa8; - - /* Severity colors */ - --severity-critical: #c74444; - --severity-high: #b5863a; - --severity-medium: #9a8420; - --severity-low: #1f8a57; - - /* Data visualization neon replacements */ - --neon-green: #1a8a50; - --neon-yellow: #9a8420; - --neon-orange: #b5863a; - --neon-red: #c74444; - - --text-primary: #122034; - --text-secondary: #3a4a5f; - --text-dim: #566a7f; - --text-muted: #7a8a9e; - --text-inverse: #f4f7fb; - - --border-color: #d1d9e6; - --border-light: #c1ccdb; - --border-glow: rgba(31, 95, 168, 0.12); - - --grid-line: rgba(31, 95, 168, 0.14); - --grid-dot: rgba(12, 18, 24, 0.06); - --noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E"); - - --accent-cyan-glow: rgba(31, 95, 168, 0.08); - --scanline: none; - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15); - --shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1); -} - /* ============================================ REDUCED MOTION ============================================ */ @@ -304,40 +226,166 @@ html[data-ui-tier="lean"] { } /* ============================================ - ENHANCED TIER — amber military console + ENHANCED TIER — signals teal console ============================================ */ html[data-ui-tier="enhanced"] { - --bg-primary: #080600; - --bg-secondary: #0c0a04; - --bg-tertiary: #100d06; - --bg-card: #0e0b05; - --bg-elevated: #141008; - --bg-overlay: rgba(8, 6, 0, 0.82); - --surface-glass: rgba(14, 11, 5, 0.82); - --surface-panel-gradient: linear-gradient(160deg, rgba(20, 16, 8, 0.94) 0%, rgba(12, 10, 4, 0.96) 100%); + --bg-primary: #000000; + --bg-secondary: #020404; + --bg-tertiary: #040808; + --bg-card: #020404; + --bg-elevated: #060a0a; + --bg-overlay: rgba(0, 0, 0, 0.88); + --surface-glass: rgba(2, 4, 4, 0.90); + --surface-panel-gradient: linear-gradient(160deg, rgba(6, 10, 10, 0.96) 0%, rgba(2, 4, 4, 0.98) 100%); - --accent-cyan: #c89628; - --accent-cyan-rgb: 200, 150, 40; - --accent-cyan-dim: rgba(200, 150, 40, 0.14); - --accent-cyan-hover: #e0aa30; - --accent-cyan-glow: rgba(200, 150, 40, 0.10); + --accent-cyan: #2e7d8a; + --accent-cyan-rgb: 46, 125, 138; + --accent-cyan-dim: rgba(46, 125, 138, 0.14); + --accent-cyan-hover: #3a9aaa; + --accent-cyan-glow: rgba(46, 125, 138, 0.09); - --accent-green: #c89628; - --accent-green-hover: #e0aa30; - --accent-green-dim: rgba(200, 150, 40, 0.14); + /* red/green intentionally unchanged — semantic status only */ - /* red is intentionally unchanged — critical alerts only */ + --ambient-top-left: rgba(46, 125, 138, 0.07); + --ambient-top-right: rgba(46, 125, 138, 0.04); + --ambient-bottom: rgba(46, 125, 138, 0.03); - --ambient-top-left: rgba(200, 150, 40, 0.08); - --ambient-top-right: rgba(200, 150, 40, 0.05); - --ambient-bottom: rgba(200, 150, 40, 0.04); + --grid-line: rgba(46, 125, 138, 0.07); + --border-color: rgba(46, 125, 138, 0.18); + --border-light: rgba(46, 125, 138, 0.28); + --border-glow: rgba(46, 125, 138, 0.20); + --border-focus: #2e7d8a; - --grid-line: rgba(200, 150, 40, 0.07); - --border-color: rgba(200, 150, 40, 0.2); - --border-light: rgba(200, 150, 40, 0.3); - --border-glow: rgba(200, 150, 40, 0.25); - --border-focus: #c89628; + --status-info: #2e7d8a; - --status-online: #c89628; - --status-info: #c89628; + --font-sans: 'Inter', 'Roboto Condensed', 'Helvetica Neue', Arial, sans-serif; +} + +/* ============================================ + LIGHT THEME OVERRIDES + Placed after tier blocks so html[data-theme="light"] + (specificity 0,1,1) beats both tier selectors when active. + ============================================ */ +html[data-theme="light"] { + --bg-primary: #f4f7fb; + --bg-secondary: #e9eef5; + --bg-tertiary: #dde5f0; + --bg-card: #ffffff; + --bg-elevated: #f1f4f9; + --bg-overlay: rgba(244, 247, 251, 0.92); + --surface-glass: rgba(255, 255, 255, 0.84); + --surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%); + --ambient-top-left: rgba(31, 95, 168, 0.1); + --ambient-top-right: rgba(31, 138, 87, 0.06); + --ambient-bottom: rgba(181, 134, 58, 0.05); + + --bg-dark: var(--bg-primary); + --bg-panel: var(--bg-secondary); + + --accent-cyan: #1f5fa8; + --accent-cyan-rgb: 31, 95, 168; + --accent-cyan-dim: rgba(31, 95, 168, 0.12); + --accent-cyan-hover: #2c73bf; + --accent-green: #1f8a57; + --accent-green-hover: #167a4a; + --accent-green-dim: rgba(31, 138, 87, 0.12); + --accent-red: #c74444; + --accent-red-hover: #b33a3a; + --accent-red-dim: rgba(199, 68, 68, 0.12); + --accent-orange: #b5863a; + --accent-orange-dim: rgba(181, 134, 58, 0.12); + --accent-amber: #b5863a; + --accent-amber-dim: rgba(181, 134, 58, 0.12); + --accent-yellow: #9a8420; + --accent-purple: #6b5ba8; + + --status-online: #1f8a57; + --status-warning: #b5863a; + --status-error: #c74444; + --status-offline: #6b7c93; + --status-info: #1f5fa8; + + --severity-critical: #c74444; + --severity-high: #b5863a; + --severity-medium: #9a8420; + --severity-low: #1f8a57; + + --neon-green: #1a8a50; + --neon-yellow: #9a8420; + --neon-orange: #b5863a; + --neon-red: #c74444; + + --text-primary: #122034; + --text-secondary: #3a4a5f; + --text-dim: #566a7f; + --text-muted: #7a8a9e; + --text-inverse: #f4f7fb; + + --border-color: #d1d9e6; + --border-light: #c1ccdb; + --border-glow: rgba(31, 95, 168, 0.12); + + --grid-line: rgba(31, 95, 168, 0.14); + --grid-dot: rgba(12, 18, 24, 0.06); + --noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E"); + + --accent-cyan-glow: rgba(31, 95, 168, 0.08); + --scanline: none; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15); + --shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1); +} + +/* Lean tier + light: specificity (0,2,1) beats lean-only (0,1,1) */ +html[data-ui-tier="lean"][data-theme="light"] { + --bg-primary: #f4f7fb; + --bg-secondary: #e9eef5; + --bg-tertiary: #dde5f0; + --bg-card: #ffffff; + --bg-elevated: #f1f4f9; + --bg-dark: #f4f7fb; + --bg-panel: #e9eef5; + --bg-overlay: rgba(244, 247, 251, 0.92); + --surface-glass: rgba(255, 255, 255, 0.84); + --surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%); + --accent-cyan: #1f5fa8; + --accent-cyan-rgb: 31, 95, 168; + --accent-cyan-dim: rgba(31, 95, 168, 0.12); + --accent-green: #1f8a57; + --text-primary: #122034; + --text-secondary: #3a4a5f; + --text-dim: #566a7f; + --text-muted: #7a8a9e; + --border-color: #d1d9e6; + --border-light: #c1ccdb; + --border-glow: rgba(31, 95, 168, 0.12); + --grid-line: rgba(31, 95, 168, 0.14); + --ambient-top-left: rgba(31, 95, 168, 0.1); + --ambient-top-right: rgba(31, 138, 87, 0.06); + --ambient-bottom: rgba(181, 134, 58, 0.05); +} + +/* Enhanced tier + light: cool whites with signals teal accents */ +html[data-ui-tier="enhanced"][data-theme="light"] { + --bg-primary: #f4f7fb; + --bg-secondary: #e9eef5; + --bg-tertiary: #dde5f0; + --bg-card: #ffffff; + --bg-elevated: #f1f4f9; + --bg-dark: #f4f7fb; + --bg-panel: #e9eef5; + --bg-overlay: rgba(244, 247, 251, 0.92); + --surface-glass: rgba(255, 255, 255, 0.84); + --text-primary: #0a1a1e; + --text-secondary: #1c3a42; + --text-dim: #3a5a62; + --border-color: rgba(30, 100, 112, 0.28); + --grid-line: rgba(30, 100, 112, 0.12); + --accent-cyan: #1e6470; + --accent-cyan-rgb: 30, 100, 112; + --accent-cyan-dim: rgba(30, 100, 112, 0.12); + --accent-cyan-hover: #25808e; + --accent-cyan-glow: rgba(30, 100, 112, 0.08); } diff --git a/static/css/fonts-local.css b/static/css/fonts-local.css index 668a3bd..8a2b0e7 100644 --- a/static/css/fonts-local.css +++ b/static/css/fonts-local.css @@ -1,6 +1,6 @@ /* Local font declarations for offline mode */ -/* Roboto Condensed - variable font, one file covers all weights */ +/* Roboto Condensed - variable font, one file covers all weights */ @font-face { font-family: 'Roboto Condensed'; font-style: normal; @@ -18,3 +18,69 @@ src: url('/static/vendor/fonts/RobotoCondensed-LatinExt.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } + +/* Inter - used by enhanced tier */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2'); +} + +/* JetBrains Mono - used by enhanced tier and all --font-mono elements */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2'); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2'); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2'); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2'); +} diff --git a/static/css/index.css b/static/css/index.css index a14aecd..8394530 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -262,10 +262,10 @@ body { @keyframes logoPulse { 0%, 100% { - filter: drop-shadow(0 0 15px rgba(0, 212, 255, 0.3)); + filter: drop-shadow(0 0 15px rgba(var(--accent-cyan-rgb), 0.3)); } 50% { - filter: drop-shadow(0 0 30px rgba(0, 212, 255, 0.6)); + filter: drop-shadow(0 0 30px rgba(var(--accent-cyan-rgb), 0.6)); } } @@ -304,7 +304,7 @@ body { color: var(--text-primary); letter-spacing: 0.2em; margin: 0; - text-shadow: 0 0 20px rgba(0, 212, 255, 0.3); + text-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.3); white-space: nowrap; } @@ -453,7 +453,7 @@ body { background: var(--bg-elevated); border-color: var(--accent-cyan); transform: translateY(-2px); - box-shadow: 0 4px 20px rgba(0, 212, 255, 0.15); + box-shadow: 0 4px 20px rgba(var(--accent-cyan-rgb), 0.15); } .mode-card:hover .mode-icon { @@ -2529,7 +2529,7 @@ header h1 .tagline { border: 1px solid var(--accent-cyan); border-radius: 4px; overflow: hidden; - box-shadow: 0 0 20px rgba(0, 212, 255, 0.2); + box-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.2); } #aircraftMap { @@ -2863,7 +2863,7 @@ header h1 .tagline { border-radius: 8px; padding: 15px; margin-bottom: 15px; - box-shadow: 0 0 20px rgba(0, 212, 255, 0.1); + box-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.1); } .countdown-satellite-name { @@ -5879,7 +5879,7 @@ body::before { max-width: 550px; padding: 30px; text-align: center; - box-shadow: 0 0 50px rgba(0, 212, 255, 0.3); + box-shadow: 0 0 50px rgba(var(--accent-cyan-rgb), 0.3); pointer-events: auto; position: relative; z-index: 100000; @@ -5939,7 +5939,7 @@ body::before { .disclaimer-modal .accept-btn:hover { background: #fff; - box-shadow: 0 0 20px rgba(0, 212, 255, 0.5); + box-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.5); } .disclaimer-hidden { @@ -6190,7 +6190,7 @@ body::before { /* Map Clustering */ .marker-cluster { - background: rgba(0, 212, 255, 0.6); + background: rgba(var(--accent-cyan-rgb), 0.6); border-radius: 50%; display: flex; align-items: center; @@ -7045,7 +7045,7 @@ body::before { .radio-module-box.scanner-main { background: linear-gradient(180deg, var(--bg-secondary) 0%, rgba(0,20,30,0.95) 100%); border: 1px solid var(--accent-cyan-dim); - box-shadow: 0 0 20px rgba(0, 212, 255, 0.1), inset 0 0 40px rgba(0, 0, 0, 0.3); + box-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.1), inset 0 0 40px rgba(0, 0, 0, 0.3); } .radio-module-box.scanner-main::before { @@ -7224,7 +7224,7 @@ body::before { } .radio-module-box table tbody tr:hover { - background: rgba(0, 212, 255, 0.05); + background: rgba(var(--accent-cyan-rgb), 0.05); } /* Log Content Compact */ @@ -7257,14 +7257,14 @@ body::before { .radio-mode-btn:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); - background: rgba(0, 212, 255, 0.05); + background: rgba(var(--accent-cyan-rgb), 0.05); } .radio-mode-btn.active { - background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(0, 255, 136, 0.1)); + background: linear-gradient(135deg, rgba(var(--accent-cyan-rgb), 0.2), rgba(0, 255, 136, 0.1)); border-color: var(--accent-cyan); color: var(--accent-cyan); - box-shadow: 0 0 20px rgba(0, 212, 255, 0.2), inset 0 0 20px rgba(0, 212, 255, 0.05); + box-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.2), inset 0 0 20px rgba(var(--accent-cyan-rgb), 0.05); } /* Listening Mode Panels */ @@ -7292,7 +7292,7 @@ body::before { .preset-freq-btn:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); } .preset-freq-btn:active { @@ -7652,6 +7652,11 @@ body[data-mode="tscm"] { background-size: unset; } +html[data-ui-tier="lean"][data-theme="light"] body { + background-color: var(--bg-primary); + background-image: none; +} + [data-ui-tier="lean"] *, [data-ui-tier="lean"] *::before, [data-ui-tier="lean"] *::after { @@ -7677,9 +7682,75 @@ body[data-mode="tscm"] { /* ============================================ - ENHANCED TIER — visual edge/glow overrides + ENHANCED TIER — panel surface overrides + Replaces hardcoded cool-dark navy gradients + with warm near-black amber equivalents. ============================================ */ html[data-ui-tier="enhanced"] { - --visual-edge-cyan: rgba(200, 150, 40, 0.34); - --visual-glow-cyan: 0 0 24px rgba(200, 150, 40, 0.16); + --visual-edge-cyan: rgba(46, 125, 138, 0.30); + --visual-glow-cyan: 0 0 24px rgba(46, 125, 138, 0.12); + --visual-surface-soft: linear-gradient(180deg, rgba(6, 10, 10, 0.9) 0%, rgba(3, 5, 5, 0.95) 100%); + --visual-surface-panel: linear-gradient(160deg, rgba(6, 10, 10, 0.95) 0%, rgba(2, 4, 4, 0.96) 100%); +} + +html[data-ui-tier="enhanced"] .mode-nav { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.96) 0%, rgba(2, 4, 4, 0.98) 100%); +} + +html[data-ui-tier="enhanced"] .mode-nav-dropdown-menu { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.98) 0%, rgba(2, 4, 4, 0.99) 100%); + border-color: rgba(46, 125, 138, 0.20); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(46, 125, 138, 0.09); +} + +html[data-ui-tier="enhanced"] .run-state-strip { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.96) 0%, rgba(2, 4, 4, 0.97) 100%); +} + +html[data-ui-tier="enhanced"] .section h3 { + background: linear-gradient(180deg, rgba(6, 10, 10, 1) 0%, rgba(4, 8, 8, 1) 100%); +} + +html[data-ui-tier="enhanced"] .section h3::after { + background: rgba(2, 4, 4, 0.9); +} + +html[data-ui-tier="enhanced"] .form-group input, +html[data-ui-tier="enhanced"] .form-group select { + background: rgba(2, 4, 4, 0.72); +} + +html[data-ui-tier="enhanced"] .preset-btn, +html[data-ui-tier="enhanced"] .control-btn, +html[data-ui-tier="enhanced"] .clear-btn { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.88) 0%, rgba(2, 4, 4, 0.9) 100%); +} + +html[data-ui-tier="enhanced"] .output-panel { + background: linear-gradient(180deg, rgba(2, 4, 4, 0.98) 0%, rgba(1, 2, 2, 0.99) 100%); +} + +html[data-ui-tier="enhanced"] .output-header { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.95) 0%, rgba(2, 4, 4, 0.98) 100%); +} + +html[data-ui-tier="enhanced"] .output-content { + background: linear-gradient(180deg, rgba(2, 4, 4, 0.6) 0%, rgba(2, 4, 4, 0.9) 100%); +} + +html[data-ui-tier="enhanced"] .stats > div { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.8) 0%, rgba(2, 4, 4, 0.82) 100%); +} + +html[data-ui-tier="enhanced"] .message { + background: linear-gradient(180deg, rgba(4, 8, 8, 0.8) 0%, rgba(2, 4, 4, 0.82) 100%); +} + +html[data-ui-tier="enhanced"] .status-bar { + background: linear-gradient(180deg, rgba(3, 6, 6, 0.96) 0%, rgba(2, 4, 4, 0.97) 100%); +} + +html[data-ui-tier="enhanced"] .status-indicator, +html[data-ui-tier="enhanced"] .control-group { + background: linear-gradient(180deg, rgba(3, 6, 6, 0.78) 0%, rgba(2, 4, 4, 0.8) 100%); } diff --git a/static/css/modes/bt_locate.css b/static/css/modes/bt_locate.css index f040892..91b0251 100644 --- a/static/css/modes/bt_locate.css +++ b/static/css/modes/bt_locate.css @@ -284,7 +284,7 @@ #btLocateMap { position: absolute; inset: 0; - background: #1a1a2e; + background: var(--bg-primary, #07090e); } .btl-map-overlay-controls { @@ -442,8 +442,8 @@ text-transform: uppercase; letter-spacing: 0.5px; color: var(--accent-cyan, #00d4ff); - background: rgba(0, 212, 255, 0.1); - border: 1px solid rgba(0, 212, 255, 0.3); + background: rgba(var(--accent-cyan-rgb), 0.1); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.3); border-radius: 4px; cursor: pointer; transition: all 0.2s; @@ -452,7 +452,7 @@ } .btl-detect-irk-btn:hover { - background: rgba(0, 212, 255, 0.2); + background: rgba(var(--accent-cyan-rgb), 0.2); border-color: var(--accent-cyan, #00d4ff); } diff --git a/static/css/modes/meteor.css b/static/css/modes/meteor.css index 5cccc0b..6e6f741 100644 --- a/static/css/modes/meteor.css +++ b/static/css/modes/meteor.css @@ -471,3 +471,26 @@ height: 40px; } } + +html[data-ui-tier="enhanced"] .meteor-visuals-container { + --ms-border: rgba(46, 125, 138, 0.22); + --ms-surface: linear-gradient(180deg, rgba(2, 6, 6, 0.97) 0%, rgba(1, 3, 3, 0.98) 100%); + --ms-accent: #2e7d8a; + --ms-accent-dim: rgba(46, 125, 138, 0.12); + background: radial-gradient(circle at 14% -18%, rgba(46, 125, 138, 0.10) 0%, rgba(46, 125, 138, 0) 38%), + radial-gradient(circle at 86% -26%, rgba(46, 125, 138, 0.07) 0%, rgba(46, 125, 138, 0) 36%), + #000202; +} + +html[data-ui-tier="enhanced"] .ms-headline, +html[data-ui-tier="enhanced"] .ms-events-panel { + background: rgba(2, 6, 6, 0.9); +} + +html[data-ui-tier="enhanced"] .ms-events-table th { + background: rgba(2, 6, 6, 0.95); +} + +html[data-ui-tier="enhanced"] .ms-events-table tr:hover td { + background: rgba(46, 125, 138, 0.04); +} diff --git a/static/css/modes/morse.css b/static/css/modes/morse.css index 2ea11fc..187c202 100644 --- a/static/css/modes/morse.css +++ b/static/css/modes/morse.css @@ -199,3 +199,17 @@ align-items: stretch; } } + +html[data-ui-tier="enhanced"] .morse-raw-panel { + border-color: rgba(46, 125, 138, 0.18); + background: rgba(1, 4, 4, 0.9); +} + +html[data-ui-tier="enhanced"] .morse-raw-text { + color: rgba(70, 180, 200, 0.90); +} + +html[data-ui-tier="enhanced"] .morse-metrics-panel span { + border-color: rgba(46, 125, 138, 0.18); + background: rgba(1, 4, 4, 0.88); +} diff --git a/static/css/modes/radiosonde.css b/static/css/modes/radiosonde.css index a27222e..56cd1d0 100644 --- a/static/css/modes/radiosonde.css +++ b/static/css/modes/radiosonde.css @@ -160,14 +160,14 @@ flex-direction: column; align-items: center; padding: 4px 10px; - background: rgba(0, 229, 255, 0.05); - border: 1px solid rgba(0, 229, 255, 0.15); + background: rgba(var(--accent-cyan-rgb), 0.05); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.15); border-radius: 4px; min-width: 55px; } .radiosonde-strip .strip-stat:hover { - background: rgba(0, 229, 255, 0.1); - border-color: rgba(0, 229, 255, 0.3); + background: rgba(var(--accent-cyan-rgb), 0.1); + border-color: rgba(var(--accent-cyan-rgb), 0.3); } .radiosonde-strip .strip-value { font-family: var(--font-mono); diff --git a/static/css/modes/sstv-general.css b/static/css/modes/sstv-general.css index 67447e7..baeae0d 100644 --- a/static/css/modes/sstv-general.css +++ b/static/css/modes/sstv-general.css @@ -342,7 +342,7 @@ .sstv-general-image-card:hover { border-color: var(--accent-cyan); transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); + box-shadow: 0 4px 12px rgba(var(--accent-cyan-rgb), 0.2); } .sstv-general-image-card-inner { diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css index 3c8b6bd..6d6df81 100644 --- a/static/css/modes/sstv.css +++ b/static/css/modes/sstv.css @@ -401,7 +401,7 @@ .sstv-image-card:hover { border-color: var(--accent-cyan); transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); + box-shadow: 0 4px 12px rgba(var(--accent-cyan-rgb), 0.2); } .sstv-image-card-inner { @@ -688,7 +688,7 @@ font-weight: 700; color: var(--accent-cyan); letter-spacing: 2px; - text-shadow: 0 0 20px rgba(0, 212, 255, 0.3); + text-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.3); } .sstv-countdown-value.imminent { diff --git a/static/css/modes/subghz.css b/static/css/modes/subghz.css index b2ee27d..fb3973d 100644 --- a/static/css/modes/subghz.css +++ b/static/css/modes/subghz.css @@ -300,7 +300,7 @@ } .subghz-btn.active { - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); border-color: var(--accent-cyan, var(--accent-cyan)); color: var(--accent-cyan, var(--accent-cyan)); } @@ -384,9 +384,9 @@ } .subghz-capture-card.selected { - border-color: rgba(0, 212, 255, 0.85); - box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.3); - background: rgba(0, 212, 255, 0.06); + border-color: rgba(var(--accent-cyan-rgb), 0.85); + box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.3); + background: rgba(var(--accent-cyan-rgb), 0.06); } .subghz-capture-header { @@ -455,9 +455,9 @@ } .subghz-capture-tag.auto { - border-color: rgba(0, 212, 255, 0.55); + border-color: rgba(var(--accent-cyan-rgb), 0.55); color: var(--accent-cyan); - background: rgba(0, 212, 255, 0.12); + background: rgba(var(--accent-cyan-rgb), 0.12); } .subghz-capture-tag.hint { @@ -536,13 +536,13 @@ } .subghz-capture-actions button.select-btn { - border-color: rgba(0, 212, 255, 0.5); + border-color: rgba(var(--accent-cyan-rgb), 0.5); color: var(--accent-cyan); } .subghz-capture-actions button.select-btn.selected { - border-color: rgba(0, 212, 255, 0.9); - background: rgba(0, 212, 255, 0.18); + border-color: rgba(var(--accent-cyan-rgb), 0.9); + background: rgba(var(--accent-cyan-rgb), 0.18); color: #7beeff; } @@ -781,14 +781,14 @@ height: 26px; border: 1px solid var(--border-color, #2a3040); border-radius: 4px; - background: linear-gradient(90deg, rgba(0, 212, 255, 0.07), rgba(255, 170, 0, 0.07)); + background: linear-gradient(90deg, rgba(var(--accent-cyan-rgb), 0.07), rgba(255, 170, 0, 0.07)); margin-bottom: 8px; overflow: hidden; } .subghz-tx-burst-timeline.dragging { - border-color: rgba(0, 212, 255, 0.65); - box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.25) inset; + border-color: rgba(var(--accent-cyan-rgb), 0.65); + box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.25) inset; } .subghz-tx-burst-selection { @@ -796,8 +796,8 @@ top: 3px; bottom: 3px; border-radius: 3px; - border: 1px solid rgba(0, 212, 255, 0.95); - background: rgba(0, 212, 255, 0.22); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.95); + background: rgba(var(--accent-cyan-rgb), 0.22); pointer-events: none; display: none; z-index: 2; @@ -823,8 +823,8 @@ } .subghz-tx-burst-marker:hover { - background: rgba(0, 212, 255, 0.85); - border-color: rgba(0, 212, 255, 1); + background: rgba(var(--accent-cyan-rgb), 0.85); + border-color: rgba(var(--accent-cyan-rgb), 1); } .subghz-tx-burst-list { @@ -861,7 +861,7 @@ .subghz-tx-burst-item button { padding: 2px 8px; - border: 1px solid rgba(0, 212, 255, 0.5); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.5); border-radius: 3px; background: transparent; color: var(--accent-cyan); @@ -871,7 +871,7 @@ } .subghz-tx-burst-item button:hover { - background: rgba(0, 212, 255, 0.12); + background: rgba(var(--accent-cyan-rgb), 0.12); } .subghz-tx-modal-actions { @@ -901,13 +901,13 @@ } .subghz-tx-trim-btn { - background: rgba(0, 212, 255, 0.14); + background: rgba(var(--accent-cyan-rgb), 0.14); color: var(--accent-cyan); - border-color: rgba(0, 212, 255, 0.55) !important; + border-color: rgba(var(--accent-cyan-rgb), 0.55) !important; } .subghz-tx-trim-btn:hover { - background: rgba(0, 212, 255, 0.26); + background: rgba(var(--accent-cyan-rgb), 0.26); } .subghz-tx-cancel-btn { @@ -1043,7 +1043,7 @@ } .subghz-action-btn.decode:hover { - background: rgba(0, 212, 255, 0.12); + background: rgba(var(--accent-cyan-rgb), 0.12); border-color: var(--accent-cyan, var(--accent-cyan)); color: var(--accent-cyan, var(--accent-cyan)); } @@ -1275,7 +1275,7 @@ .subghz-phase-step.active { color: var(--accent-cyan, var(--accent-cyan)); - text-shadow: 0 0 6px rgba(0, 212, 255, 0.3); + text-shadow: 0 0 6px rgba(var(--accent-cyan-rgb), 0.3); } .subghz-phase-step.completed { @@ -1328,9 +1328,9 @@ } .subghz-burst-indicator.recent { - border-color: rgba(0, 212, 255, 0.45); + border-color: rgba(var(--accent-cyan-rgb), 0.45); color: var(--accent-cyan); - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); } .subghz-burst-indicator.recent .subghz-burst-dot { @@ -1445,8 +1445,8 @@ transform: translateY(0); } -.subghz-hub-card--cyan { border-color: rgba(0, 212, 255, 0.2); } -.subghz-hub-card--cyan:hover { border-color: var(--accent-cyan, var(--accent-cyan)); background: rgba(0, 212, 255, 0.05); } +.subghz-hub-card--cyan { border-color: rgba(var(--accent-cyan-rgb), 0.2); } +.subghz-hub-card--cyan:hover { border-color: var(--accent-cyan, var(--accent-cyan)); background: rgba(var(--accent-cyan-rgb), 0.05); } .subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, var(--accent-cyan)); } .subghz-hub-card--green { border-color: rgba(0, 255, 136, 0.2); } @@ -1736,8 +1736,8 @@ .subghz-rx-burst-pill.recent { color: var(--accent-cyan); - border-color: rgba(0, 212, 255, 0.65); - background: rgba(0, 212, 255, 0.12); + border-color: rgba(var(--accent-cyan-rgb), 0.65); + background: rgba(var(--accent-cyan-rgb), 0.12); } .subghz-rx-level-label { diff --git a/static/css/modes/system.css b/static/css/modes/system.css index 60b66e4..bc8ba32 100644 --- a/static/css/modes/system.css +++ b/static/css/modes/system.css @@ -17,7 +17,7 @@ text-transform: uppercase; letter-spacing: 0.12em; color: var(--accent-cyan, #00d4ff); - border-bottom: 1px solid rgba(0, 212, 255, 0.2); + border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.2); padding-bottom: 6px; margin-top: 8px; } @@ -221,7 +221,7 @@ fill: none; stroke: var(--accent-cyan, #00d4ff); stroke-width: 1.5; - filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4)); + filter: drop-shadow(0 0 2px rgba(var(--accent-cyan-rgb), 0.4)); } .sys-sparkline-area { diff --git a/static/css/modes/waterfall.css b/static/css/modes/waterfall.css index 6ee6b28..385dee3 100644 --- a/static/css/modes/waterfall.css +++ b/static/css/modes/waterfall.css @@ -1180,3 +1180,139 @@ font-family: var(--font-mono, monospace); font-size: 9px; } + +/* ---- Enhanced tier: replace blue palette with signals teal ---- */ + +html[data-ui-tier="enhanced"] .wf-container { + --wf-border: rgba(46, 125, 138, 0.22); + --wf-surface: linear-gradient(180deg, rgba(2, 6, 6, 0.97) 0%, rgba(1, 3, 3, 0.98) 100%); + background: radial-gradient(circle at 14% -18%, rgba(46, 125, 138, 0.08) 0%, rgba(46, 125, 138, 0) 38%), + radial-gradient(circle at 86% -26%, rgba(46, 125, 138, 0.05) 0%, rgba(46, 125, 138, 0) 36%), + #000202; +} + +html[data-ui-tier="enhanced"] .wf-headline { + background: rgba(2, 6, 6, 0.86); +} + +html[data-ui-tier="enhanced"] .wf-headline-tag { + color: rgba(70, 185, 200, 0.90); +} + +html[data-ui-tier="enhanced"] .wf-rx-vfo { + border-color: rgba(46, 125, 138, 0.25); + background: linear-gradient(180deg, rgba(3, 8, 8, 0.92) 0%, rgba(1, 4, 4, 0.95) 100%); +} + +html[data-ui-tier="enhanced"] .wf-rx-vfo-status { + color: rgba(65, 175, 192, 0.88); +} + +html[data-ui-tier="enhanced"] .wf-rx-vfo-readout { + color: rgba(80, 190, 205, 0.92); +} + +html[data-ui-tier="enhanced"] #wfRxFreqReadout { + text-shadow: 0 0 16px rgba(46, 125, 138, 0.25); +} + +html[data-ui-tier="enhanced"] .wf-rx-modebank { + border-color: rgba(46, 125, 138, 0.22); + background: rgba(1, 4, 4, 0.86); +} + +html[data-ui-tier="enhanced"] .wf-mode-btn { + border-color: rgba(46, 125, 138, 0.24); + background: linear-gradient(180deg, rgba(4, 8, 8, 0.95) 0%, rgba(2, 5, 5, 0.95) 100%); + color: rgba(65, 175, 192, 0.90); +} + +html[data-ui-tier="enhanced"] .wf-mode-btn:hover { + border-color: rgba(46, 125, 138, 0.48); +} + +html[data-ui-tier="enhanced"] .wf-mode-btn.is-active, +html[data-ui-tier="enhanced"] .wf-mode-btn.active { + border-color: rgba(46, 125, 138, 0.62); + background: linear-gradient(180deg, rgba(6, 16, 18, 0.92) 0%, rgba(4, 12, 14, 0.95) 100%); + color: rgba(100, 210, 222, 0.96); + box-shadow: 0 0 14px rgba(46, 125, 138, 0.22); +} + +html[data-ui-tier="enhanced"] .wf-rx-levels, +html[data-ui-tier="enhanced"] .wf-rx-meter-wrap, +html[data-ui-tier="enhanced"] .wf-rx-actions { + border-color: rgba(46, 125, 138, 0.20); + background: rgba(1, 4, 4, 0.85); +} + +html[data-ui-tier="enhanced"] .wf-monitor-select { + border-color: rgba(46, 125, 138, 0.25); + background: rgba(1, 3, 3, 0.8); +} + +html[data-ui-tier="enhanced"] .wf-rx-smeter-fill { + box-shadow: 0 0 10px rgba(46, 125, 138, 0.22); +} + +html[data-ui-tier="enhanced"] .wf-monitor-btn-secondary { + border-color: rgba(46, 125, 138, 0.45); + background: linear-gradient(180deg, rgba(4, 12, 14, 0.95) 0%, rgba(2, 8, 10, 0.95) 100%); + color: rgba(80, 190, 205, 0.90); +} + +html[data-ui-tier="enhanced"] .wf-freq-bar { + background: rgba(2, 6, 6, 0.78); +} + +html[data-ui-tier="enhanced"] .wf-spectrum-canvas-wrap { + background: radial-gradient(circle at 50% -120%, rgba(46, 125, 138, 0.06) 0%, rgba(46, 125, 138, 0) 65%); +} + +html[data-ui-tier="enhanced"] .wf-band-strip { + background: linear-gradient(180deg, rgba(2, 6, 6, 0.96) 0%, rgba(1, 3, 3, 0.98) 100%); +} + +html[data-ui-tier="enhanced"] .wf-band-block { + border-color: rgba(46, 125, 138, 0.42); + color: rgba(80, 190, 205, 0.92); +} + +html[data-ui-tier="enhanced"] .wf-band-edge { + color: rgba(65, 175, 192, 0.88); +} + +html[data-ui-tier="enhanced"] .wf-band-marker::before { + background: rgba(46, 125, 138, 0.58); + box-shadow: 0 0 5px rgba(46, 125, 138, 0.30); +} + +html[data-ui-tier="enhanced"] .wf-band-marker-label { + border-color: rgba(46, 125, 138, 0.48); + background: rgba(2, 5, 5, 0.95); + color: rgba(80, 190, 205, 0.90); +} + +html[data-ui-tier="enhanced"] .wf-tune-line { + background: rgba(46, 125, 138, 0.72); +} + +html[data-ui-tier="enhanced"] .wf-freq-axis { + background: rgba(2, 6, 6, 0.86); +} + +html[data-ui-tier="enhanced"] .wf-side .section.wf-side-hero { + background: linear-gradient(180deg, rgba(3, 8, 8, 0.95) 0%, rgba(1, 4, 4, 0.97) 100%); + border-color: rgba(46, 125, 138, 0.30); + box-shadow: 0 8px 24px rgba(0, 8, 10, 0.30), inset 0 0 0 1px rgba(255, 255, 255, 0.03); +} + +html[data-ui-tier="enhanced"] .wf-side-chip { + color: rgba(65, 175, 192, 0.88); + border-color: rgba(46, 125, 138, 0.32); + background: rgba(6, 16, 18, 0.30); +} + +html[data-ui-tier="enhanced"] .wf-side-stat { + border-color: rgba(46, 125, 138, 0.20); +} diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index 00b078d..59cbd23 100644 --- a/static/css/modes/weather-satellite.css +++ b/static/css/modes/weather-satellite.css @@ -308,7 +308,7 @@ opacity: 1; } -.wxsat-timeline-pass.apt { background: rgba(0, 212, 255, 0.6); } +.wxsat-timeline-pass.apt { background: rgba(var(--accent-cyan-rgb), 0.6); } .wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 0.6); } .wxsat-timeline-pass.scheduled { border: 1px solid var(--accent-yellow); } @@ -585,7 +585,7 @@ } .wxsat-pass-mode.apt { - background: rgba(0, 212, 255, 0.15); + background: rgba(var(--accent-cyan-rgb), 0.15); color: var(--accent-cyan); } @@ -626,7 +626,7 @@ } .wxsat-pass-quality.good { - background: rgba(0, 212, 255, 0.15); + background: rgba(var(--accent-cyan-rgb), 0.15); color: var(--accent-cyan); } @@ -759,16 +759,16 @@ .wxsat-map-tooltip { background: rgba(5, 15, 32, 0.92); - border: 1px solid rgba(102, 229, 255, 0.65); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.65); border-radius: 4px; - color: #8fe8ff; + color: var(--accent-cyan); box-shadow: 0 0 12px rgba(0, 210, 255, 0.24); font-size: 10px; letter-spacing: 0.25px; } .wxsat-map-tooltip.leaflet-tooltip-top:before { - border-top-color: rgba(102, 229, 255, 0.65); + border-top-color: rgba(var(--accent-cyan-rgb), 0.65); } /* ===== Image Gallery Panel ===== */ @@ -1221,8 +1221,8 @@ .wxsat-phase-step.completed { color: var(--accent-cyan, #00d4ff); - border-color: rgba(0, 212, 255, 0.3); - background: rgba(0, 212, 255, 0.05); + border-color: rgba(var(--accent-cyan-rgb), 0.3); + background: rgba(var(--accent-cyan-rgb), 0.05); opacity: 0.7; } @@ -1267,7 +1267,7 @@ .wxsat-console-filter.active { border-color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff); - background: rgba(0, 212, 255, 0.08); + background: rgba(var(--accent-cyan-rgb), 0.08); } .wxsat-console-actions { diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css index 9a6e3cc..b7bad42 100644 --- a/static/css/satellite_dashboard.css +++ b/static/css/satellite_dashboard.css @@ -157,7 +157,7 @@ body { .stat-badge { background: var(--bg-card); - border: 1px solid rgba(0, 212, 255, 0.3); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.3); border-radius: 4px; padding: 4px 10px; font-family: var(--font-mono); @@ -309,7 +309,7 @@ body { /* Panels */ .panel { background: var(--bg-panel); - border: 1px solid rgba(0, 212, 255, 0.2); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.2); overflow: hidden; position: relative; } @@ -326,8 +326,8 @@ body { .panel-header { padding: 10px 15px; - background: rgba(0, 212, 255, 0.05); - border-bottom: 1px solid rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.05); + border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.1); font-family: 'Orbitron', monospace; font-size: 11px; font-weight: 500; @@ -955,7 +955,7 @@ body { .packet-entry { padding: 7px 10px; - border-bottom: 1px solid rgba(0, 212, 255, 0.08); + border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.08); font-size: 10px; font-family: var(--font-mono); word-break: break-word; @@ -1010,7 +1010,7 @@ body { .packet-modal { position: fixed; inset: 0; - z-index: 2000; + z-index: 5000; display: flex; align-items: center; justify-content: center; @@ -1140,7 +1140,7 @@ body { gap: 10px; padding: 10px; background: var(--bg-panel); - border-bottom: 1px solid rgba(0, 212, 255, 0.2); + border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.2); } .satellite-selector label { @@ -1153,7 +1153,7 @@ body { .satellite-selector select { flex: 1; - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); border: 1px solid var(--accent-cyan); border-radius: 4px; padding: 8px 12px; @@ -1166,12 +1166,12 @@ body { .satellite-selector select:focus { outline: none; - box-shadow: 0 0 15px rgba(0, 212, 255, 0.3); + box-shadow: 0 0 15px rgba(var(--accent-cyan-rgb), 0.3); } #satRefreshBtn { background: transparent; - border: 1px solid rgba(0, 212, 255, 0.4); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.4); border-radius: 4px; color: var(--text-secondary); cursor: pointer; @@ -1199,7 +1199,7 @@ body { /* Countdown panel */ .countdown-panel { flex-shrink: 0; - background: linear-gradient(135deg, rgba(0, 212, 255, 0.1) 0%, rgba(0, 255, 136, 0.05) 100%); + background: linear-gradient(135deg, rgba(var(--accent-cyan-rgb), 0.1) 0%, rgba(0, 255, 136, 0.05) 100%); } .countdown-display { @@ -1232,7 +1232,7 @@ body { .countdown-block { background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(0, 212, 255, 0.2); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.2); border-radius: 4px; padding: 8px 4px; text-align: center; @@ -1359,7 +1359,7 @@ body { .pass-item { background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(0, 212, 255, 0.15); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.15); border-radius: 4px; padding: 8px 10px; margin-bottom: 6px; @@ -1369,13 +1369,13 @@ body { .pass-item:hover { border-color: var(--accent-cyan); - background: rgba(0, 212, 255, 0.05); + background: rgba(var(--accent-cyan-rgb), 0.05); } .pass-item.active { border-color: var(--accent-cyan); - box-shadow: 0 0 15px rgba(0, 212, 255, 0.2); - background: rgba(0, 212, 255, 0.1); + box-shadow: 0 0 15px rgba(var(--accent-cyan-rgb), 0.2); + background: rgba(var(--accent-cyan-rgb), 0.1); } .pass-item-header { @@ -1407,7 +1407,7 @@ body { } .pass-quality.good { - background: rgba(0, 212, 255, 0.2); + background: rgba(var(--accent-cyan-rgb), 0.2); color: var(--accent-cyan); } @@ -1434,7 +1434,7 @@ body { gap: 20px; padding: 10px 20px; background: var(--bg-panel); - border-top: 1px solid rgba(0, 212, 255, 0.3); + border-top: 1px solid rgba(var(--accent-cyan-rgb), 0.3); } .control-group { @@ -1455,7 +1455,7 @@ body { width: 90px; padding: 6px 8px; background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(0, 212, 255, 0.3); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.3); border-radius: 4px; color: var(--accent-cyan); font-family: var(--font-mono); @@ -1465,13 +1465,13 @@ body { .control-group input:focus { outline: none; border-color: var(--accent-cyan); - box-shadow: 0 0 10px rgba(0, 212, 255, 0.2); + box-shadow: 0 0 10px rgba(var(--accent-cyan-rgb), 0.2); } .btn { padding: 8px 16px; border: 1px solid var(--accent-cyan); - background: rgba(0, 212, 255, 0.1); + background: rgba(var(--accent-cyan-rgb), 0.1); color: var(--accent-cyan); font-family: 'Orbitron', monospace; font-size: 11px; @@ -1486,7 +1486,7 @@ body { .btn:hover { background: var(--accent-cyan); color: var(--bg-dark); - box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); + box-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.3); } .btn.primary { @@ -1780,17 +1780,16 @@ body.embedded .controls-bar { } /* ============================================ - ENHANCED TIER — amber military console + ENHANCED TIER — signals teal console ============================================ */ html[data-ui-tier="enhanced"] { - --bg-dark: #080600; - --bg-panel: #0c0a04; - --bg-card: #0e0b05; - --border-glow: rgba(200, 150, 40, 0.25); - --border-color: rgba(200, 150, 40, 0.2); - --grid-line: rgba(200, 150, 40, 0.07); - --accent-cyan: #c89628; - --accent-green: #c89628; + --bg-dark: #000000; + --bg-panel: #020404; + --bg-card: #020404; + --border-glow: rgba(46, 125, 138, 0.20); + --border-color: rgba(46, 125, 138, 0.18); + --grid-line: rgba(46, 125, 138, 0.07); + --accent-cyan: #2e7d8a; } /* ============================================ diff --git a/static/css/settings.css b/static/css/settings.css index ee62849..0593b64 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -266,7 +266,7 @@ } .toggle-switch input:focus + .toggle-slider { - box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3); + box-shadow: 0 0 0 2px rgba(var(--accent-cyan-rgb), 0.3); } /* Select Dropdown */ @@ -461,8 +461,8 @@ /* Info Callout */ .settings-info { - background: rgba(0, 212, 255, 0.1); - border: 1px solid rgba(0, 212, 255, 0.2); + background: rgba(var(--accent-cyan-rgb), 0.1); + border: 1px solid rgba(var(--accent-cyan-rgb), 0.2); border-radius: 6px; padding: 12px; margin-top: 16px; @@ -474,25 +474,32 @@ color: var(--accent-cyan, #00d4ff); } -/* Map tile variants */ -.tile-layer-cyan { +/* Map tile variants — teal tint, skipped in lean mode */ +html:not([data-ui-tier="lean"]) .tile-layer-cyan { filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05); } -/* Global Leaflet map theme: cyber overlay */ +/* Lean mode: suppress every map filter unconditionally */ +html[data-ui-tier="lean"] .leaflet-tile-pane, +html[data-ui-tier="lean"] .leaflet-tile, +html[data-ui-tier="lean"] .tile-layer-cyan { + filter: none !important; +} + +/* Global Leaflet map theme: cyber overlay — default and enhanced only, not lean */ .leaflet-container.map-theme-cyber { position: relative; background: #020813; isolation: isolate; } -.leaflet-container.map-theme-cyber .leaflet-tile-pane { +html:not([data-ui-tier="lean"]) .leaflet-container.map-theme-cyber .leaflet-tile-pane { filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08); opacity: 1; } -/* Hard global fallback: enforce cyber tint on all Leaflet tile images */ -html.map-cyber-enabled .leaflet-container .leaflet-tile { +/* Hard global fallback: enforce cyber tint on all Leaflet tile images — not lean */ +html:not([data-ui-tier="lean"]).map-cyber-enabled .leaflet-container .leaflet-tile { filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08) !important; } @@ -527,6 +534,58 @@ html.map-cyber-enabled .leaflet-container::after { background-size: 52px 52px, 52px 52px; } +/* Lean tier: no overlays, no filters — original tile colors */ +html[data-ui-tier="lean"] .leaflet-container::before, +html[data-ui-tier="lean"] .leaflet-container::after { + background: none !important; + background-image: none !important; +} + +/* Enhanced tier: signals teal map tint overrides — global (all Leaflet maps) */ +html[data-ui-tier="enhanced"] .leaflet-container { + background: #000000; +} + +html[data-ui-tier="enhanced"] .leaflet-container::before { + background: none !important; +} + +html[data-ui-tier="enhanced"] .leaflet-tile-pane { + filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08); +} + +/* Narrower overrides for cyber-specific classes (same filter, kept for !important precedence) */ +html[data-ui-tier="enhanced"] .leaflet-container.map-theme-cyber { + background: #000000; +} + +html[data-ui-tier="enhanced"] .tile-layer-cyan { + filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08); +} + +html[data-ui-tier="enhanced"] .leaflet-container.map-theme-cyber .leaflet-tile-pane { + filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08); +} + +html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container { + background: #000000; +} + +html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container .leaflet-tile { + filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08) !important; +} + +html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container::before { + background: none; +} + +html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container::after { + background-image: + linear-gradient(rgba(46, 125, 138, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(46, 125, 138, 0.1) 1px, transparent 1px); + background-size: 52px 52px, 52px 52px; +} + /* Responsive */ @media (max-width: 1023px) { .settings-tabs { diff --git a/static/js/core/first-run-setup.js b/static/js/core/first-run-setup.js index ee1b027..2bfac43 100644 --- a/static/js/core/first-run-setup.js +++ b/static/js/core/first-run-setup.js @@ -216,9 +216,9 @@ const FirstRunSetup = (function() { el.innerHTML = '
INTERCEPT● LIVE
ADS-B ........... 247
TSCM ............ 3 ⚠
'; }); - const enhancedBtn = makeTierBtn('enhanced', 'Enhanced', 'Amber military console — for desktop or laptop.', (el) => { - el.style.cssText += 'background:#080600;border:1px solid rgba(200,150,40,0.3);display:flex;flex-direction:column;justify-content:center;padding:6px;gap:3px;'; - el.innerHTML = '
INTERCEPT14:27Z
247 ADS-B
'; + const enhancedBtn = makeTierBtn('enhanced', 'Enhanced', 'Signals teal console — for desktop or laptop.', (el) => { + el.style.cssText += 'background:#000202;border:1px solid rgba(46,125,138,0.3);display:flex;flex-direction:column;justify-content:center;padding:6px;gap:3px;'; + el.innerHTML = '
INTERCEPT14:27Z
247 ADS-B
'; }); btnWrap.appendChild(leanBtn); diff --git a/static/js/core/voice-alerts.js b/static/js/core/voice-alerts.js index 13410c2..ee5a743 100644 --- a/static/js/core/voice-alerts.js +++ b/static/js/core/voice-alerts.js @@ -16,17 +16,18 @@ const VoiceAlerts = (function () { const PITCH_MIN = 0.5; const PITCH_MAX = 2.0; - // Default config + // Default config — streams are opt-in to avoid saturating the browser's + // HTTP/1.1 per-origin connection limit (6) when multiple tabs are open. let _config = { rate: 1.1, pitch: 0.9, voiceName: '', streams: { - pager: true, - tscm: true, - bluetooth: true, - adsb_military: true, - squawks: true, + pager: false, + tscm: false, + bluetooth: false, + adsb_military: false, + squawks: false, }, }; diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index ee5f521..6b86fec 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -197,30 +197,11 @@ const BtLocate = (function() { // Init map const mapEl = document.getElementById('btLocateMap'); - if (mapEl && typeof L !== 'undefined') { - map = L.map('btLocateMap', { + if (mapEl && typeof L !== 'undefined' && typeof MapUtils !== 'undefined') { + map = MapUtils.init('btLocateMap', { center: [0, 0], zoom: 2, - zoomControl: true, }); - let tileLayer = null; - // Use tile provider from user settings - if (typeof Settings !== 'undefined' && Settings.createTileLayer) { - tileLayer = Settings.createTileLayer(); - tileLayer.addTo(map); - Settings.registerMap(map); - } else { - tileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - maxZoom: 19, - attribution: '© OSM © CARTO' - }); - tileLayer.addTo(map); - } - if (tileLayer && typeof tileLayer.on === 'function') { - tileLayer.on('load', () => { - scheduleMapStabilization(8); - }); - } ensureHeatLayer(); syncMovementLayer(); syncHeatLayer(); diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index 38f36da..deeb191 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -43,6 +43,9 @@ var OokMode = (function () { } function destroy() { + if (state.running) { + stop(); + } disconnectSSE(); } diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index 61add49..6a28217 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -874,7 +874,11 @@ const SSTVGeneral = (function() { * Destroy — close SSE stream and stop scope animation for clean mode switching. */ function destroy() { - stopStream(); + if (isRunning) { + stop().catch(() => {}); + } else { + stopStream(); + } } // Public API diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 1183754..71f8167 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -1428,9 +1428,13 @@ const SSTV = (function() { * Destroy — close SSE stream and clear ISS tracking/countdown timers for clean mode switching. */ function destroy() { - if (eventSource) { - eventSource.close(); - eventSource = null; + if (isRunning) { + stop().catch(() => {}); + } else { + if (eventSource) { + eventSource.close(); + eventSource = null; + } } stopIssTracking(); stopCountdown(); diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index df30094..03ee8f6 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -3014,10 +3014,14 @@ const Waterfall = (function () { } // Backend stop is fire-and-forget; UI is already updated above. + const _audioStopCtrl = new AbortController(); + const _audioStopTid = setTimeout(() => _audioStopCtrl.abort(), 3000); try { - await fetch('/receiver/audio/stop', { method: 'POST' }); + await fetch('/receiver/audio/stop', { method: 'POST', signal: _audioStopCtrl.signal }); } catch (_) { // Ignore backend stop errors + } finally { + clearTimeout(_audioStopTid); } if (resumeWaterfall && _active) { @@ -3222,10 +3226,14 @@ const Waterfall = (function () { if (_es) { _closeSseStream(); + const _wfStopCtrl = new AbortController(); + const _wfStopTid = setTimeout(() => _wfStopCtrl.abort(), 3000); try { - await fetch('/receiver/waterfall/stop', { method: 'POST' }); + await fetch('/receiver/waterfall/stop', { method: 'POST', signal: _wfStopCtrl.signal }); } catch (_) { // Ignore fallback stop errors. + } finally { + clearTimeout(_wfStopTid); } } diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 71afc87..c96c18b 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -2344,7 +2344,11 @@ const WeatherSat = (function() { clearInterval(countdownInterval); countdownInterval = null; } - stopStream(); + if (isRunning) { + stop().catch(() => {}); + } else { + stopStream(); + } } /** diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index 57eed30..f5a37be 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -260,7 +260,10 @@ function initWebsdrGlobe(mapEl) { if (typeof window.Globe !== 'function' || !isWebglSupported()) return false; mapEl.innerHTML = ''; - mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)'; + const _wsdrTier = document.documentElement.getAttribute('data-ui-tier') || 'enhanced'; + mapEl.style.background = _wsdrTier === 'enhanced' + ? 'radial-gradient(circle at 30% 20%, rgba(4, 18, 22, 0.92), rgba(2, 8, 10, 0.96) 58%, rgba(0, 2, 2, 0.99) 100%)' + : 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)'; mapEl.style.cursor = 'grab'; const _wsdrAccent = getComputedStyle(document.documentElement).getPropertyValue('--accent-cyan').trim() || '#3bb9ff'; @@ -296,6 +299,8 @@ function initWebsdrGlobe(mapEl) { ensureWebsdrGlobePopup(mapEl); resizeWebsdrGlobe(); + // Grid layout may not have settled on the first rAF; re-sync after one frame. + requestAnimationFrame(() => resizeWebsdrGlobe()); return true; } @@ -475,8 +480,10 @@ function ensureWebsdrGlobePopup(mapEl) { websdrGlobePopup.style.maxWidth = '260px'; websdrGlobePopup.style.padding = '10px'; websdrGlobePopup.style.borderRadius = '8px'; - websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)'; - websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)'; + const _wsdrPopupRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-cyan-rgb').trim() || '0, 212, 255'; + const _wsdrPopupTier = document.documentElement.getAttribute('data-ui-tier') || 'enhanced'; + websdrGlobePopup.style.border = `1px solid rgba(${_wsdrPopupRgb}, 0.35)`; + websdrGlobePopup.style.background = _wsdrPopupTier === 'enhanced' ? 'rgba(2, 8, 10, 0.94)' : 'rgba(5, 13, 20, 0.92)'; websdrGlobePopup.style.backdropFilter = 'blur(4px)'; websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)'; websdrGlobePopup.style.color = 'var(--text-primary)'; @@ -574,8 +581,9 @@ function renderReceiverList(receivers) { container.innerHTML = receivers.slice(0, 50).map((rx, idx) => { const selected = idx === websdrSelectedReceiverIndex; - const baseBg = selected ? 'rgba(0,212,255,0.14)' : 'transparent'; - const hoverBg = selected ? 'rgba(0,212,255,0.18)' : 'rgba(0,212,255,0.05)'; + const _wsdrRxRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-cyan-rgb').trim() || '0, 212, 255'; + const baseBg = selected ? `rgba(${_wsdrRxRgb},0.14)` : 'transparent'; + const hoverBg = selected ? `rgba(${_wsdrRxRgb},0.18)` : `rgba(${_wsdrRxRgb},0.05)`; return `
{ const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0]; const freqKhz = primaryFreq?.freq_khz || 0; return `
+ onmouseover="this.style.background='rgba(${_wsdrSpyRgb},0.05)'" onmouseout="this.style.background='transparent'">
${escapeHtmlWebsdr(s.name)} ${escapeHtmlWebsdr(s.nickname || '')} diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js index 86b710b..12a80a7 100644 --- a/static/js/modes/wefax.js +++ b/static/js/modes/wefax.js @@ -58,6 +58,9 @@ var WeFax = (function () { function destroy() { closeImage(); + if (state.running) { + stop(); + } disconnectSSE(); stopScope(); stopCountdownTimer(); diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 8b50095..a831879 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -155,25 +155,22 @@ const WiFiMode = (function() { // ========================================================================== function init() { - console.log('[WiFiMode] Initializing...'); + // Capabilities and one-time component setup only on first call. + // Subsequent visits refresh scan state and re-render without redundant fetches. + const firstInit = capabilities === null; - // Cache DOM elements cacheDOM(); - // Check capabilities - checkCapabilities(); + if (firstInit) { + checkCapabilities(); + initScanModeTabs(); + initNetworkFilters(); + initSortControls(); + initHeatmap(); + } - // Initialize components - initScanModeTabs(); - initNetworkFilters(); - initSortControls(); - initHeatmap(); scheduleRender({ table: true, stats: true, radar: true }); - - // Check if already scanning checkScanStatus(); - - console.log('[WiFiMode] Initialized'); } // DOM element cache diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index fc63015..67d6c8b 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -2094,13 +2094,25 @@ ACARS: ${r.statistics.acarsMessages} messages`; const existing = document.getElementById('aircraftDbBanner'); if (existing) existing.remove(); + const _dbTier = document.documentElement.getAttribute('data-ui-tier') || 'enhanced'; + const _dbBg = _dbTier === 'enhanced' + ? (type === 'not_installed' ? 'rgba(4, 22, 26, 0.97)' : 'rgba(4, 22, 26, 0.97)') + : (type === 'not_installed' ? 'rgba(59, 130, 246, 0.95)' : 'rgba(34, 197, 94, 0.95)'); + const _dbBorder = _dbTier === 'enhanced' + ? (type === 'not_installed' ? '1px solid rgba(46, 125, 138, 0.45)' : '1px solid rgba(46, 125, 138, 0.45)') + : 'none'; + const _dbBtnColor = _dbTier === 'enhanced' + ? (type === 'not_installed' ? '#2e7d8a' : '#38c180') + : (type === 'not_installed' ? '#3b82f6' : '#22c55e'); + const banner = document.createElement('div'); banner.id = 'aircraftDbBanner'; banner.style.cssText = ` position: fixed; top: 70px; right: 20px; - background: ${type === 'not_installed' ? 'rgba(59, 130, 246, 0.95)' : 'rgba(34, 197, 94, 0.95)'}; + background: ${_dbBg}; + border: ${_dbBorder}; color: white; padding: 12px 16px; border-radius: 8px; @@ -2115,14 +2127,14 @@ ACARS: ${r.statistics.acarsMessages} messages`; banner.innerHTML = `
Aircraft Database Not Installed
Download to see aircraft types, registrations, and model info.
- + `; } else { banner.innerHTML = `
Database Update Available
New version: ${version || 'latest'}
- + `; } diff --git a/templates/adsb_history.html b/templates/adsb_history.html index 4b22231..573b5ea 100644 --- a/templates/adsb_history.html +++ b/templates/adsb_history.html @@ -1,14 +1,16 @@ + ADS-B History // INTERCEPT {% if offline_settings.fonts_source == 'local' %} {% else %} - + {% endif %} + diff --git a/templates/agents.html b/templates/agents.html index 9f74783..1aae04e 100644 --- a/templates/agents.html +++ b/templates/agents.html @@ -5,7 +5,10 @@ iNTERCEPT // Remote Agents + + + diff --git a/templates/index.html b/templates/index.html index e161b0e..4598757 100644 --- a/templates/index.html +++ b/templates/index.html @@ -42,7 +42,7 @@ {% if offline_settings.fonts_source == 'local' %} {% else %} - + {% endif %} {% if offline_settings.assets_source == 'local' %} @@ -1478,7 +1478,7 @@