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 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-05-20 22:01:10 +01:00
parent 5100f55586
commit a3f2fa7b88
48 changed files with 1524 additions and 943 deletions
+31 -30
View File
@@ -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/<int:rule_id>', methods=['PUT', 'PATCH'])
@alerts_bp.route("/rules/<int:rule_id>", 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/<int:rule_id>', methods=['DELETE'])
@alerts_bp.route("/rules/<int:rule_id>", 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
+542 -523
View File
File diff suppressed because it is too large Load Diff