feat(translate): add WebSocket endpoint for real-time run progress

Backend:
- Add /ws/translate/run/{run_id} WebSocket endpoint in app.py
- Streams structured run status every second (total_records,
  successful_records, failed_records, progressPct, batch_count, etc.)
- Auto-detects terminal states (COMPLETED, FAILED, CANCELLED) and closes
- Authenticated via JWT/API key token query param

Frontend:
- Add getTranslateRunWsUrl() URL builder in api.js
- Update translationRunStore to use WebSocket instead of log stream
- WebSocket receives structured status JSON, updates store reactively
- Falls back to polling silently if WebSocket fails
- Keep polling as fallback for insert phase
This commit is contained in:
2026-05-29 16:42:59 +03:00
parent 1aceba465b
commit db7e5e3863
3 changed files with 153 additions and 6 deletions

View File

@@ -624,6 +624,55 @@ async def dataset_websocket_endpoint(websocket: WebSocket, env_id: str):
task_manager.unsubscribe_dataset_events(env_id, queue)
logger.reflect("Released dataset event subscription", extra={"env_id": env_id})
# #endregion dataset_websocket_endpoint
# #region translate_run_websocket [C:3] [TYPE Function]
# @BRIEF WebSocket endpoint for translation run progress — streams structured status updates.
# @PRE run_id must be a valid translation run ID. WebSocket authenticated via `token` query param.
# @POST Streams run status JSON every second until terminal state or disconnect.
# @SIDE_EFFECT Queries DB each tick for current run status via TranslationOrchestrator.
# @UX_STATE Streaming -> Terminal (completed/failed/cancelled) -> Close
# @UX_FEEDBACK Client receives {status, total_records, successful_records, failed_records, progressPct, ...}
@app.websocket("/ws/translate/run/{run_id}")
async def translate_run_websocket(websocket: WebSocket, run_id: str):
seed_trace_id()
if not await _authenticate_websocket(websocket, "ws/translate/run"):
await websocket.close(code=4001, reason="Authentication required")
return
await websocket.accept()
logger.reason("Accepted translate run WebSocket", extra={"run_id": run_id})
try:
config_manager = get_config_manager()
while True:
try:
from sqlalchemy.orm import Session
from .core.database import SessionLocal
from .plugins.translate.orchestrator_aggregator import TranslationResultAggregator
from .core.event_log import EventLog
db = SessionLocal()
try:
event_log = EventLog(db)
aggregator = TranslationResultAggregator(db, event_log)
status = aggregator.get_run_status(run_id)
total = status.get("total_records", 0) or 0
done = (status.get("successful_records", 0) or 0) + (status.get("failed_records", 0) or 0) + (status.get("skipped_records", 0) or 0)
progress_pct = round((done / total) * 100) if total > 0 else 0
status["progressPct"] = progress_pct
await websocket.send_json(status)
if status.get("status") in ("COMPLETED", "FAILED", "CANCELLED"):
await asyncio.sleep(2)
break
finally:
db.close()
except Exception as tick_err:
logger.explore("Translate run WS tick error", extra={"run_id": run_id, "error": str(tick_err)})
await websocket.send_json({"error": str(tick_err)})
break
await asyncio.sleep(1)
except WebSocketDisconnect:
logger.reason("Translate run WS disconnected", extra={"run_id": run_id})
except Exception as exc:
logger.explore("Translate run WS error", extra={"run_id": run_id, "error": str(exc)})
logger.reflect("Translate run WS closed", extra={"run_id": run_id})
# #endregion translate_run_websocket
# #region StaticFiles [C:1] [TYPE Mount] [SEMANTICS static, frontend, spa]
# @BRIEF Mounts the frontend build directory to serve static assets.
frontend_path = project_root / "frontend" / "build"