release: bump version to 0.52.0

This commit is contained in:
linkong
2026-05-12 17:15:02 +08:00
parent b15d097b9c
commit b87cb310fd
70 changed files with 5589 additions and 2187 deletions

View File

@@ -2,7 +2,7 @@
import asyncio
from datetime import UTC, datetime
from typing import Dict, Any, Optional
from typing import Dict, Any
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
@@ -15,6 +15,8 @@ class DataBroadcaster:
def __init__(self):
self.running = False
self.tasks: Dict[str, asyncio.Task] = {}
self._pending_vessel_updates: Dict[str, Dict[str, Any]] = {}
self._vessel_flush_interval = 1.0
async def get_dashboard_stats(self) -> Dict[str, Any]:
"""Get dashboard statistics"""
@@ -68,6 +70,9 @@ class DataBroadcaster:
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
"""Broadcast custom data to a specific channel"""
if channel == "vessels":
self.enqueue_vessel_update(data)
return
await manager.broadcast(
{
"type": "data_frame",
@@ -78,6 +83,58 @@ class DataBroadcaster:
channel=channel,
)
def enqueue_vessel_update(self, data: Dict[str, Any]):
vessels = data.get("vessels") if isinstance(data, dict) else None
if not isinstance(vessels, list):
return
source = data.get("source")
action = data.get("action") or "upsert"
created = data.get("created")
for vessel in vessels:
if not isinstance(vessel, dict):
continue
mmsi = vessel.get("mmsi")
if mmsi in (None, ""):
continue
self._pending_vessel_updates[str(mmsi)] = {
**vessel,
"_source": source,
"_action": action,
"_created": created,
}
async def flush_vessel_updates(self):
if not self._pending_vessel_updates:
return
pending = self._pending_vessel_updates
self._pending_vessel_updates = {}
vessels = []
for item in pending.values():
vessel = dict(item)
source = vessel.pop("_source", None)
action = vessel.pop("_action", "upsert")
created = vessel.pop("_created", None)
vessel["source"] = source
vessel["action"] = action
vessel["created"] = created
vessels.append(vessel)
await manager.broadcast_vessels(
{
"action": "upsert",
"source": "mixed",
"created": None,
"vessels": vessels,
}
)
async def broadcast_vessels_periodically(self):
while self.running:
try:
await self.flush_vessel_updates()
except Exception:
pass
await asyncio.sleep(self._vessel_flush_interval)
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
"""Broadcast datasource task progress updates to connected clients."""
await manager.broadcast(
@@ -95,6 +152,7 @@ class DataBroadcaster:
if not self.running:
self.running = True
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically())
def stop(self):
"""Stop all broadcasters"""
@@ -102,6 +160,7 @@ class DataBroadcaster:
for task in self.tasks.values():
task.cancel()
self.tasks.clear()
self._pending_vessel_updates.clear()
broadcaster = DataBroadcaster()

View File

@@ -1,11 +1,16 @@
"""WebSocket Connection Manager"""
from typing import Dict, Set, Optional
from datetime import UTC, datetime
from typing import Any, Dict, Set, Optional
from fastapi import WebSocket
import redis.asyncio as redis
from app.core.config import settings
MAX_VESSEL_SUBSCRIPTION_LIMIT = 5000
MAX_VESSEL_WS_MESSAGE_ITEMS = 1000
MAX_VESSEL_BBOX_AREA = 2500.0
class ConnectionManager:
"""Manages WebSocket connections"""
@@ -14,6 +19,7 @@ class ConnectionManager:
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {}
self.redis_client: Optional[redis.Redis] = None
async def connect(self, websocket: WebSocket, user_id: str):
@@ -72,6 +78,50 @@ class ConnectionManager:
channels = list(self.websocket_channels.get(websocket, set()))
if channels:
self.unsubscribe(websocket, channels)
self.vessel_subscriptions.pop(websocket, None)
def subscribe_vessels(self, websocket: WebSocket, config: dict[str, Any]) -> dict[str, Any]:
subscription = self._normalize_vessel_subscription(config)
self.channel_subscriptions.setdefault("vessels", set()).add(websocket)
self.websocket_channels.setdefault(websocket, set()).add("vessels")
self.vessel_subscriptions[websocket] = subscription
return subscription
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
bbox = config.get("bbox")
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
try:
lon_min, lat_min, lon_max, lat_max = [float(value) for value in bbox]
except (TypeError, ValueError) as exc:
raise ValueError("bbox values must be numbers") from exc
if lat_min > lat_max:
lat_min, lat_max = lat_max, lat_min
if lon_min > lon_max:
lon_min, lon_max = lon_max, lon_min
if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
raise ValueError("bbox longitude values must be between -180 and 180")
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
raise ValueError("bbox latitude values must be between -90 and 90")
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
zoom = int(config.get("zoom") or 1)
if zoom < 1 or zoom > 20:
raise ValueError("zoom must be between 1 and 20")
limit = min(max(int(config.get("limit") or 1000), 1), MAX_VESSEL_SUBSCRIPTION_LIMIT)
vessel_types = {
str(item).strip().lower()
for item in str(config.get("type") or "").split(",")
if str(item).strip()
}
return {
"bbox": (lon_min, lat_min, lon_max, lat_max),
"zoom": zoom,
"limit": limit,
"type": vessel_types,
"last_sent_at": None,
}
async def send_personal_message(self, message: dict, user_id: str):
if user_id in self.active_connections:
@@ -92,6 +142,58 @@ class ConnectionManager:
except Exception:
self.unsubscribe_all(connection)
async def broadcast_vessels(self, data: dict[str, Any]):
vessels = data.get("vessels") if isinstance(data, dict) else None
if not isinstance(vessels, list) or not vessels:
return
for connection, subscription in list(self.vessel_subscriptions.items()):
matched = [
vessel
for vessel in vessels
if self._vessel_matches_subscription(vessel, subscription)
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
if not matched:
continue
subscription["last_sent_at"] = datetime.now(UTC)
message = {
"type": "data_frame",
"channel": "vessels",
"timestamp": subscription["last_sent_at"].isoformat(),
"payload": {
**data,
"vessels": matched,
"subscription": {
"bbox": list(subscription["bbox"]),
"zoom": subscription["zoom"],
"limit": subscription["limit"],
},
},
}
try:
await connection.send_json(message)
except Exception:
self.unsubscribe_all(connection)
def _vessel_matches_subscription(
self,
vessel: dict[str, Any],
subscription: dict[str, Any],
) -> bool:
try:
lon = float(vessel.get("lon"))
lat = float(vessel.get("lat"))
except (TypeError, ValueError):
return False
lon_min, lat_min, lon_max, lat_max = subscription["bbox"]
if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max):
return False
requested_types = subscription.get("type") or set()
if not requested_types:
return True
type_name = str(vessel.get("vessel_type_name") or "").lower()
return any(requested_type in type_name for requested_type in requested_types)
async def close_all(self):
for user_id in self.active_connections:
for connection in self.active_connections[user_id]:
@@ -99,6 +201,7 @@ class ConnectionManager:
self.active_connections.clear()
self.channel_subscriptions.clear()
self.websocket_channels.clear()
self.vessel_subscriptions.clear()
manager = ConnectionManager()