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:
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user