artifacts-dashboard/backend/app/api/ws.py
Paweł Orzech f845647934
Some checks failed
Release / release (push) Has been cancelled
Initial release: Artifacts MMO Dashboard & Automation Platform
Full-stack dashboard for controlling, automating, and analyzing
Artifacts MMO characters via the game's HTTP API.

Backend (FastAPI):
- Async Artifacts API client with rate limiting and retry
- 6 automation strategies (combat, gathering, crafting, trading, task, leveling)
- Automation engine with runner, manager, cooldown tracker, pathfinder
- WebSocket relay (game server -> frontend)
- Game data cache, character snapshots, price history, analytics
- 9 API routers, 7 database tables, 3 Alembic migrations
- 108 unit tests

Frontend (Next.js 15 + shadcn/ui):
- Live character dashboard with HP/XP bars and cooldowns
- Character detail with stats, equipment, inventory, skills, manual actions
- Automation management with live log streaming
- Interactive canvas map with content-type coloring and zoom/pan
- Bank management, Grand Exchange with price charts
- Events, logs, analytics pages with Recharts
- WebSocket auto-reconnect with query cache invalidation
- Settings page, error boundaries, dark theme

Infrastructure:
- Docker Compose (dev + prod)
- GitHub Actions CI/CD
- Documentation (Architecture, Automation, Deployment, API)
2026-03-01 19:46:45 +01:00

113 lines
3.3 KiB
Python

"""WebSocket endpoint for the frontend dashboard.
Provides a ``/ws/live`` WebSocket endpoint that relays events from the
internal :class:`EventBus` to connected browser clients. Multiple
frontend connections are supported simultaneously.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
router = APIRouter()
class ConnectionManager:
"""Manage active frontend WebSocket connections."""
def __init__(self) -> None:
self._connections: list[WebSocket] = []
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self._connections.append(ws)
logger.info(
"Frontend WebSocket connected (total=%d)", len(self._connections)
)
def disconnect(self, ws: WebSocket) -> None:
if ws in self._connections:
self._connections.remove(ws)
logger.info(
"Frontend WebSocket removed (total=%d)", len(self._connections)
)
async def broadcast(self, message: dict) -> None:
"""Send a message to all connected clients.
Silently removes any clients whose connections have broken.
"""
disconnected: list[WebSocket] = []
for ws in self._connections:
try:
await ws.send_json(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self.disconnect(ws)
@property
def connection_count(self) -> int:
return len(self._connections)
# Singleton connection manager -- shared across all WebSocket endpoint
# invocations within the same process.
ws_manager = ConnectionManager()
@router.websocket("/ws/live")
async def websocket_live(ws: WebSocket) -> None:
"""WebSocket endpoint that relays internal events to the frontend.
Once connected the client receives a stream of JSON events from the
:class:`EventBus`. The client may send text frames (reserved for
future command support); they are currently ignored.
"""
await ws_manager.connect(ws)
# Obtain the event bus from application state
event_bus: EventBus = ws.app.state.event_bus
queue = event_bus.subscribe_all()
relay_task: asyncio.Task | None = None
try:
# Background task: relay events from the bus to the client
async def _relay() -> None:
try:
while True:
event = await queue.get()
await ws.send_json(event)
except asyncio.CancelledError:
pass
relay_task = asyncio.create_task(
_relay(), name="ws-relay"
)
# Main loop: keep connection alive by reading client frames
while True:
_data = await ws.receive_text()
# Client messages can be handled here in the future
except WebSocketDisconnect:
logger.info("Frontend WebSocket disconnected")
except Exception:
logger.exception("WebSocket error")
finally:
if relay_task is not None:
relay_task.cancel()
try:
await relay_task
except asyncio.CancelledError:
pass
event_bus.unsubscribe("*", queue)
ws_manager.disconnect(ws)