Add real-time WebSocket task progress with multi-job drawer

Replace 5s HTTP polling with WebSocket-based real-time updates for task
progress. Celery workers publish progress to Redis pub/sub channels;
a FastAPI WebSocket endpoint subscribes and forwards to the browser.
Polling is kept as a 30s fallback when WebSocket is unavailable.

The task progress drawer now supports multiple concurrent jobs with a
tab bar for switching between scrape and POI distance tasks.

Backend:
- Add services/task_progress_publisher.py (Redis pub/sub bridge)
- Add api/ws_routes.py (WebSocket endpoint with JWT auth)
- Publish progress from listing_tasks and poi_tasks
- Publish REVOKED via pub/sub on cancel/clear to fix stuck UI

Frontend:
- Add useTaskWebSocket hook with reconnection and keepalive
- Add TaskState and WS message types
- TaskIndicator: WS-driven updates with polling fallback
- TaskProgressDrawer: multi-job tabs, POI phase timeline
- Guard against WS overwriting local cancel state
This commit is contained in:
Viktor Barzin 2026-02-09 21:31:45 +00:00
parent 73d19e29d5
commit 8559c4b461
No known key found for this signature in database
GPG key ID: 0EB088298288D958
11 changed files with 774 additions and 72 deletions

View file

@ -10,6 +10,7 @@ from models.listing import ListingType
from repositories.listing_repository import ListingRepository
from repositories.poi_repository import POIRepository
from services.poi_distance_calculator import calculate_poi_distances
from services.task_progress_publisher import publish_task_progress
logger = logging.getLogger(__name__)
@ -55,6 +56,11 @@ def calculate_poi_distances_task(
"progress": 0,
"message": "Starting distance calculation...",
})
publish_task_progress(self.request.id, "PROGRESS", {
"phase": "starting",
"progress": 0,
"message": "Starting distance calculation...",
})
listing_repo = ListingRepository(engine)
poi_repo = POIRepository(engine)
@ -62,19 +68,23 @@ def calculate_poi_distances_task(
poi = poi_repo.get_poi_by_id(poi_id)
if poi is None:
celery_logger.error(f"POI {poi_id} not found")
return {"error": f"POI {poi_id} not found", "distances_computed": 0}
error_result = {"error": f"POI {poi_id} not found", "distances_computed": 0}
publish_task_progress(self.request.id, "FAILURE", error_result)
return error_result
lt = ListingType(listing_type)
def on_progress(completed: int, total: int, message: str) -> None:
progress = round(completed / total, 2) if total > 0 else 0
self.update_state(state="PROGRESS", meta={
meta = {
"phase": "computing",
"progress": progress,
"processed": completed,
"total": total,
"message": message,
})
}
self.update_state(state="PROGRESS", meta=meta)
publish_task_progress(self.request.id, "PROGRESS", meta)
try:
total = asyncio.run(
@ -96,9 +106,12 @@ def calculate_poi_distances_task(
celery_logger.info(f"POI distance calculation complete: {total} distances computed")
return {
result = {
"phase": "completed",
"progress": 1,
"distances_computed": total,
"message": f"Computed {total} distances for POI '{poi.name}'",
}
publish_task_progress(self.request.id, "SUCCESS", result)
return result