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

@@ -48,7 +48,7 @@ function shouldSuppressApiErrorToast(endpoint, error) {
*/
export const getWsUrl = (taskId) => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
const host = typeof window !== 'development' && typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/logs/${taskId}`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
@@ -59,6 +59,23 @@ export const getWsUrl = (taskId) => {
return url;
};
/**
* Build a WebSocket URL for translation run progress streaming.
* The token is appended as a query param for WebSocket auth.
*/
export const getTranslateRunWsUrl = (runId) => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/translate/run/${runId}`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}
}
return url;
};
function getAuthHeaders(extraHeaders = {}) {
const headers = { 'Content-Type': 'application/json', ...extraHeaders };
if (typeof window !== 'undefined') {

View File

@@ -1,5 +1,14 @@
// #region TranslationRunStore [C:3] [TYPE Store] [SEMANTICS translate, run, progress, polling, store]
// @BRIEF Global store for active translation run progress — survives page navigation.
// #region TranslationRunStore [C:4] [TYPE Store] [SEMANTICS translate, run, progress, websocket, polling, store]
// @BRIEF Global store for active translation run progress — uses WebSocket for real-time
// progress + polling fallback for structured status. Survives page navigation.
// @RELATION DEPENDS_ON -> [EXT:frontend:fetchRunStatus]
// @RELATION DEPENDS_ON -> [EXT:frontend:getWsUrl]
// @UX_STATE idle -> No active run
// @UX_STATE running -> Polling + WebSocket active, progress bar updates in real-time
// @UX_STATE completed/partial/failed/insert_failed/cancelled -> Terminal states
// @UX_FEEDBACK WebSocket provides real-time batch completion events during translate phase
// @UX_FEEDBACK Polling provides structured status updates (insert phase, final counts)
// @UX_RECOVERY WebSocket disconnect -> continues with polling only
// @TYPEDEF {'idle'|'running'|'inserting'|'completed'|'partial'|'failed'|'insert_failed'|'cancelled'} UxState
@@ -22,6 +31,7 @@
import { writable, derived } from 'svelte/store';
import { fetchRunStatus } from '$lib/api/translate.js';
import { getTranslateRunWsUrl } from '$lib/api.js';
const initialState = {
runId: null,
@@ -58,11 +68,12 @@ export const isTranslationFinished = derived(
$store.uxState === 'cancelled'
);
// Internal polling state (not reactive)
// Internal polling + WebSocket state (not reactive)
let _pollingInterval = null;
let _pollCount = 0;
const MAX_POLLS = 300;
let _onCompleteCallback = null;
let _ws = null;
/**
* Clear the onComplete callback without stopping polling.
@@ -74,7 +85,7 @@ export function clearOnCompleteCallback() {
}
/**
* Start polling for a translation run.
* Start WebSocket + polling for a translation run.
* @param {string} runId
* @param {Object} [options]
* @param {string} [options.jobId] - Job ID for navigation
@@ -95,15 +106,85 @@ export function startTranslationRun(runId, options = {}) {
});
_onCompleteCallback = options.onComplete || null;
// WebSocket for real-time progress events (task log stream)
_connectWebSocket(runId);
// Polling for structured status data (fallback + insert phase)
_pollCount = 0;
_pollingInterval = setInterval(pollStatus, 2000);
pollStatus();
}
/**
* Stop polling without clearing state (run may persist).
* Connect WebSocket to /ws/translate/run/{runId} for real-time progress events.
* Receives structured run status JSON each second.
* Falls back silently to polling-only if WebSocket fails.
* @param {string} runId
*/
function _connectWebSocket(runId) {
try {
const wsUrl = getTranslateRunWsUrl(runId);
_ws = new WebSocket(wsUrl);
_ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.error) return;
const s = data.status;
const total = data.total_records || 0;
const done = (data.successful_records || 0) + (data.failed_records || 0) + (data.skipped_records || 0);
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const newUxState = s === 'PENDING' || s === 'RUNNING'
? (data.insert_status === 'started' || data.insert_status === 'pending' || data.insert_status === 'running' ? 'inserting' : 'running')
: s === 'COMPLETED'
? (data.insert_status === 'failed' || data.insert_status === 'timeout' ? 'insert_failed' : (data.failed_records > 0 ? 'partial' : 'completed'))
: s === 'FAILED' ? 'failed'
: s === 'CANCELLED' ? 'cancelled'
: null;
if (newUxState) {
translationRunStore.update(prev => ({
...prev,
uxState: newUxState,
status: data,
totalRecords: total,
successfulRecords: data.successful_records || 0,
failedRecords: data.failed_records || 0,
skippedRecords: data.skipped_records || 0,
progressPct: pct,
insertStatus: data.insert_status || null,
batchCount: data.batch_count || 0,
}));
if (newUxState !== 'running' && newUxState !== 'inserting') {
if (_onCompleteCallback) _onCompleteCallback(data);
}
}
} catch (_e) {
// Ignore parse errors
}
};
_ws.onerror = () => {
_ws = null;
};
_ws.onclose = () => {
_ws = null;
};
} catch (_e) {
_ws = null;
}
}
/**
* Stop WebSocket + polling without clearing state (run may persist).
*/
export function stopTranslationRun() {
if (_ws) {
_ws.onclose = null; // prevent reconnect
_ws.close();
_ws = null;
}
if (_pollingInterval) {
clearInterval(_pollingInterval);
_pollingInterval = null;