201 lines
7.0 KiB
Python
201 lines
7.0 KiB
Python
"""Data broadcaster for WebSocket connections"""
|
|
|
|
import asyncio
|
|
from datetime import UTC, datetime
|
|
from typing import Dict, Any
|
|
|
|
from app.core.logging import get_logger
|
|
from app.core.time import to_iso8601_utc
|
|
from app.core.websocket.manager import manager
|
|
|
|
|
|
EARTH_UPDATES_CHANNEL = "earth_updates"
|
|
VESSEL_STATE_QUERY_BATCH_SIZE = 1000
|
|
logger = get_logger(__name__, service="websocket")
|
|
|
|
|
|
class DataBroadcaster:
|
|
"""Periodically broadcasts data to connected WebSocket clients"""
|
|
|
|
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"""
|
|
return {
|
|
"total_datasources": 9,
|
|
"active_datasources": 8,
|
|
"tasks_today": 45,
|
|
"success_rate": 97.8,
|
|
"last_updated": to_iso8601_utc(datetime.now(UTC)),
|
|
"alerts": {"critical": 0, "warning": 2, "info": 5},
|
|
}
|
|
|
|
async def broadcast_stats(self, interval: int = 5):
|
|
"""Broadcast dashboard stats periodically"""
|
|
while self.running:
|
|
try:
|
|
stats = await self.get_dashboard_stats()
|
|
await manager.broadcast(
|
|
{
|
|
"type": "data_frame",
|
|
"channel": "dashboard",
|
|
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
|
"payload": {"stats": stats},
|
|
},
|
|
channel="dashboard",
|
|
)
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(interval)
|
|
|
|
async def broadcast_alert(self, alert: Dict[str, Any]):
|
|
"""Broadcast an alert to all connected clients"""
|
|
await manager.broadcast(
|
|
{
|
|
"type": "alert_notification",
|
|
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
|
"data": {"alert": alert},
|
|
}
|
|
)
|
|
|
|
async def broadcast_gpu_update(self, data: Dict[str, Any]):
|
|
"""Broadcast GPU cluster update"""
|
|
await manager.broadcast(
|
|
{
|
|
"type": "data_frame",
|
|
"channel": "gpu_clusters",
|
|
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
|
"payload": data,
|
|
}
|
|
)
|
|
|
|
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",
|
|
"channel": channel,
|
|
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
|
"payload": data,
|
|
},
|
|
channel=channel,
|
|
)
|
|
|
|
async def broadcast_earth_update(self, data: Dict[str, Any]):
|
|
"""Broadcast Earth visualization refresh hints to connected clients."""
|
|
await self.broadcast_custom(EARTH_UPDATES_CHANNEL, data)
|
|
|
|
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 = {}
|
|
try:
|
|
vessels = await self._load_current_vessel_updates(list(pending))
|
|
except Exception:
|
|
# Preserve newer updates that arrived during the failed database read.
|
|
self._pending_vessel_updates = {**pending, **self._pending_vessel_updates}
|
|
raise
|
|
if not vessels:
|
|
return
|
|
vessels = [
|
|
{
|
|
**vessel,
|
|
"action": vessel.get("action", "upsert"),
|
|
"created": pending.get(str(vessel["mmsi"]), {}).get("_created"),
|
|
}
|
|
for vessel in vessels
|
|
]
|
|
await manager.broadcast_vessels(
|
|
{
|
|
"action": "upsert",
|
|
"source": "mixed",
|
|
"created": None,
|
|
"vessels": vessels,
|
|
}
|
|
)
|
|
|
|
async def _load_current_vessel_updates(self, keys: list[str]) -> list[Dict[str, Any]]:
|
|
from app.db.session import async_session_factory
|
|
from app.services.vessel_ais_aggregation import get_current_vessels_by_mmsi
|
|
|
|
mmsis = [int(key) for key in keys if key.isdigit()]
|
|
vessels = []
|
|
async with async_session_factory() as db:
|
|
for offset in range(0, len(mmsis), VESSEL_STATE_QUERY_BATCH_SIZE):
|
|
vessels.extend(await get_current_vessels_by_mmsi(
|
|
db, mmsis[offset:offset + VESSEL_STATE_QUERY_BATCH_SIZE]
|
|
))
|
|
present = {int(vessel["mmsi"]) for vessel in vessels}
|
|
vessels.extend({"mmsi": mmsi, "action": "remove"} for mmsi in mmsis if mmsi not in present)
|
|
return vessels
|
|
|
|
async def broadcast_vessels_periodically(self):
|
|
while self.running:
|
|
try:
|
|
await self.flush_vessel_updates()
|
|
except Exception as exc:
|
|
logger.exception_event(
|
|
"Failed to flush vessel updates",
|
|
event="vessels.broadcast.failed",
|
|
context={"error": str(exc)},
|
|
)
|
|
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(
|
|
{
|
|
"type": "data_frame",
|
|
"channel": "datasource_tasks",
|
|
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
|
"payload": data,
|
|
},
|
|
channel="datasource_tasks",
|
|
)
|
|
|
|
def start(self):
|
|
"""Start all broadcasters"""
|
|
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"""
|
|
self.running = False
|
|
for task in self.tasks.values():
|
|
task.cancel()
|
|
self.tasks.clear()
|
|
self._pending_vessel_updates.clear()
|
|
|
|
|
|
broadcaster = DataBroadcaster()
|