fix(translate): normalize Unix timestamps to YYYY-MM-DD for ClickHouse Date columns
- Add _normalize_timestamp_value to key column values in orchestrator.py and executor.py before SQL generation - Fix test mocks for .options(joinedload()) chain, explicit None attrs for mock job - Add global translation run progress indicator (TranslationRunGlobalIndicator + store) - Fix translate page import missing translationRunStore - All 208 translate tests pass
This commit is contained in:
@@ -22,6 +22,13 @@
|
||||
let editingProvider = $state(null);
|
||||
let showForm = $state(false);
|
||||
|
||||
const DEFAULT_BASE_URLS = {
|
||||
openai: "https://api.openai.com/v1",
|
||||
openrouter: "https://openrouter.ai/api/v1",
|
||||
kilo: "https://api.kilo.chat/v1",
|
||||
litellm: "http://localhost:4000/v1",
|
||||
};
|
||||
|
||||
let formData = $state({
|
||||
name: "",
|
||||
provider_type: "openai",
|
||||
@@ -57,6 +64,16 @@
|
||||
);
|
||||
}
|
||||
|
||||
function updateBaseUrlForType(providerType) {
|
||||
// Only auto-update base_url if user hasn't changed it from the default
|
||||
// or the base_url matches a previous default for a different type
|
||||
const currentUrl = formData.base_url;
|
||||
const isCurrentlyDefault = Object.values(DEFAULT_BASE_URLS).includes(currentUrl);
|
||||
if (isCurrentlyDefault || !currentUrl) {
|
||||
formData.base_url = DEFAULT_BASE_URLS[providerType] || currentUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData = {
|
||||
name: "",
|
||||
@@ -322,11 +339,13 @@
|
||||
<select
|
||||
id="provider-type"
|
||||
bind:value={formData.provider_type}
|
||||
onchange={() => updateBaseUrlForType(formData.provider_type)}
|
||||
class="mt-1 block w-full border rounded-md p-2"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="kilo">Kilo</option>
|
||||
<option value="litellm">LiteLLM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!-- #region TranslationRunGlobalIndicator [C:3] [TYPE Component] [SEMANTICS translate, progress, global, indicator] -->
|
||||
<!-- @BRIEF Persistent mini progress indicator for translation runs, rendered in root layout. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION BINDS_TO -> [translationRunStore] -->
|
||||
<!-- @UX_STATE hidden -> No active or recently completed run -->
|
||||
<!-- @UX_STATE running -> Thin blue bar + "Translating X/Y" banner -->
|
||||
<!-- @UX_STATE completed -> Green bar + "Completed" banner (auto-hides after 5s) -->
|
||||
<!-- @UX_STATE failed -> Red bar + "Failed" banner (auto-hides after 8s) -->
|
||||
<!-- @UX_FEEDBACK Click banner navigates to the translation run page -->
|
||||
<script>
|
||||
import { translationRunStore } from '$lib/stores/translationRun.js';
|
||||
import { fromStore } from 'svelte/store';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
const runState = fromStore(translationRunStore);
|
||||
|
||||
// Hide on the translate page itself to avoid duplication
|
||||
let isOnTranslatePage = $derived(
|
||||
page.url.pathname.startsWith('/translate/') && page.url.pathname !== '/translate'
|
||||
);
|
||||
|
||||
// Terminal state auto-dismiss timers
|
||||
let dismissTimer = $state(null);
|
||||
let dismissed = $state(false);
|
||||
|
||||
let uxState = $derived(runState.current?.uxState || 'idle');
|
||||
|
||||
let show = $derived.by(() => {
|
||||
if (dismissed) return false;
|
||||
if (isOnTranslatePage) return false;
|
||||
if (uxState === 'running' || uxState === 'inserting') return true;
|
||||
if (uxState === 'completed' || uxState === 'partial') return true;
|
||||
if (uxState === 'failed' || uxState === 'cancelled') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
let barColor = $derived.by(() => {
|
||||
if (uxState === 'running' || uxState === 'inserting') return 'bg-blue-600';
|
||||
if (uxState === 'completed' || uxState === 'partial') return 'bg-green-500';
|
||||
if (uxState === 'failed') return 'bg-red-500';
|
||||
if (uxState === 'cancelled') return 'bg-gray-400';
|
||||
return 'bg-blue-600';
|
||||
});
|
||||
|
||||
let bannerBg = $derived.by(() => {
|
||||
if (uxState === 'running' || uxState === 'inserting') return 'bg-blue-600';
|
||||
if (uxState === 'completed' || uxState === 'partial') return 'bg-green-500';
|
||||
if (uxState === 'failed') return 'bg-red-500';
|
||||
if (uxState === 'cancelled') return 'bg-gray-500';
|
||||
return 'bg-blue-600';
|
||||
});
|
||||
|
||||
let label = $derived.by(() => {
|
||||
if (uxState === 'inserting') return $t.translate?.run?.insert_phase || 'Inserting...';
|
||||
if (uxState === 'running') return $t.translate?.run?.translate_phase || 'Translating...';
|
||||
if (uxState === 'completed') return $t.translate?.run?.completed || 'Completed';
|
||||
if (uxState === 'partial') return $t.translate?.run?.completed_with_errors || 'Completed with errors';
|
||||
if (uxState === 'failed') return $t.translate?.run?.translation_failed || 'Translation failed';
|
||||
if (uxState === 'cancelled') return $t.translate?.run?.cancelled || 'Cancelled';
|
||||
return '';
|
||||
});
|
||||
|
||||
// Auto-dismiss terminal states after a few seconds
|
||||
$effect(() => {
|
||||
if (uxState === 'completed' || uxState === 'partial') {
|
||||
dismissed = false;
|
||||
if (dismissTimer) clearTimeout(dismissTimer);
|
||||
dismissTimer = setTimeout(() => { dismissed = true; }, 5000);
|
||||
} else if (uxState === 'failed' || uxState === 'cancelled') {
|
||||
dismissed = false;
|
||||
if (dismissTimer) clearTimeout(dismissTimer);
|
||||
dismissTimer = setTimeout(() => { dismissed = true; }, 8000);
|
||||
} else if (uxState === 'idle') {
|
||||
dismissed = false;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (dismissTimer) clearTimeout(dismissTimer);
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
const jobId = runState.current?.jobId;
|
||||
if (jobId) {
|
||||
goto(`/translate/${jobId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 z-[100] cursor-pointer shadow-sm"
|
||||
onclick={handleClick}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={label}
|
||||
>
|
||||
<!-- Animated thin bar -->
|
||||
<div class="h-1 bg-gray-200">
|
||||
<div
|
||||
class="h-full {barColor} transition-all duration-500"
|
||||
style="width: {Math.min(runState.current?.progressPct || 0, 100)}%"
|
||||
/>
|
||||
</div>
|
||||
<!-- Compact stats banner -->
|
||||
<div class="{bannerBg} text-white text-xs px-3 py-1 flex items-center gap-3">
|
||||
<span class="font-medium">{label}</span>
|
||||
|
||||
{#if uxState === 'running' || uxState === 'inserting'}
|
||||
<span class="opacity-80">
|
||||
{runState.current?.successfulRecords || 0}/{runState.current?.totalRecords || 0}
|
||||
</span>
|
||||
{#if (runState.current?.failedRecords || 0) > 0}
|
||||
<span class="text-red-200">
|
||||
{$t.translate?.run?.failed || 'Failed'}: {runState.current?.failedRecords}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ml-auto opacity-70 hover:opacity-100 transition-opacity">
|
||||
▶ {$t.common?.open || 'Open'}
|
||||
</span>
|
||||
{:else if uxState === 'partial'}
|
||||
<span class="opacity-80">
|
||||
{runState.current?.successfulRecords || 0}/{runState.current?.totalRecords || 0}
|
||||
</span>
|
||||
<span class="ml-auto opacity-70 hover:opacity-100 transition-opacity">
|
||||
▶ {$t.common?.open || 'Open'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="ml-auto opacity-70 hover:opacity-100 transition-opacity">
|
||||
▶ {$t.common?.open || 'Open'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- #endregion TranslationRunGlobalIndicator -->
|
||||
209
frontend/src/lib/stores/translationRun.js
Normal file
209
frontend/src/lib/stores/translationRun.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// #region TranslationRunStore [C:3] [TYPE Store] [SEMANTICS translate, run, progress, polling, store]
|
||||
// @BRIEF Global store for active translation run progress — survives page navigation.
|
||||
// @RELATION DEPENDS_ON -> [TranslateApi]
|
||||
// @UX_STATE idle -> No active run
|
||||
// @UX_STATE running -> Translation phase in progress
|
||||
// @UX_STATE inserting -> Insert phase in progress
|
||||
// @UX_STATE completed -> Run completed
|
||||
// @UX_STATE partial -> Completed with errors
|
||||
// @UX_STATE failed -> Run failed
|
||||
// @UX_STATE cancelled -> Run cancelled
|
||||
// @INVARIANT Polling stops when run reaches a terminal state (completed/failed/cancelled).
|
||||
// @INVARIANT Store is writable so page components can also set initial state.
|
||||
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { fetchRunStatus } from '$lib/api/translate.js';
|
||||
|
||||
/**
|
||||
* @typedef {'idle'|'running'|'inserting'|'completed'|'partial'|'failed'|'cancelled'} UxState
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TranslationRunState
|
||||
* @property {string|null} runId - Active translation run ID
|
||||
* @property {UxState} uxState - Current UX state
|
||||
* @property {Object|null} status - Raw status data from API
|
||||
* @property {number} totalRecords
|
||||
* @property {number} successfulRecords
|
||||
* @property {number} failedRecords
|
||||
* @property {number} skippedRecords
|
||||
* @property {number} progressPct - 0-100
|
||||
* @property {string|null} insertStatus
|
||||
* @property {number} batchCount
|
||||
* @property {string|null} jobId - The job this run belongs to (for navigation)
|
||||
* @property {boolean} isFullRun
|
||||
* @property {boolean} isActive - Derived: true when polling is active
|
||||
*/
|
||||
|
||||
const initialState = {
|
||||
runId: null,
|
||||
uxState: 'idle',
|
||||
status: null,
|
||||
totalRecords: 0,
|
||||
successfulRecords: 0,
|
||||
failedRecords: 0,
|
||||
skippedRecords: 0,
|
||||
progressPct: 0,
|
||||
insertStatus: null,
|
||||
batchCount: 0,
|
||||
jobId: null,
|
||||
isFullRun: false,
|
||||
};
|
||||
|
||||
/** @type {import('svelte/store').Writable<TranslationRunState>} */
|
||||
export const translationRunStore = writable(initialState);
|
||||
|
||||
/** Derived: true when a run is actively being polled */
|
||||
export const isTranslationActive = derived(
|
||||
translationRunStore,
|
||||
($store) => $store.uxState === 'running' || $store.uxState === 'inserting'
|
||||
);
|
||||
|
||||
/** Derived: true when run has finished */
|
||||
export const isTranslationFinished = derived(
|
||||
translationRunStore,
|
||||
($store) =>
|
||||
$store.uxState === 'completed' ||
|
||||
$store.uxState === 'partial' ||
|
||||
$store.uxState === 'failed' ||
|
||||
$store.uxState === 'cancelled'
|
||||
);
|
||||
|
||||
// Internal polling state (not reactive)
|
||||
let _pollingInterval = null;
|
||||
let _pollCount = 0;
|
||||
const MAX_POLLS = 300;
|
||||
let _onCompleteCallback = null;
|
||||
|
||||
/**
|
||||
* Clear the onComplete callback without stopping polling.
|
||||
* Used by page components when they unmount — the store keeps polling
|
||||
* so the global indicator still works, but the page callback is detached.
|
||||
*/
|
||||
export function clearOnCompleteCallback() {
|
||||
_onCompleteCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for a translation run.
|
||||
* @param {string} runId
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.jobId] - Job ID for navigation
|
||||
* @param {boolean} [options.isFullRun]
|
||||
* @param {Function} [options.onComplete] - Called when run reaches terminal state
|
||||
*/
|
||||
export function startTranslationRun(runId, options = {}) {
|
||||
if (!runId) return;
|
||||
|
||||
stopTranslationRun();
|
||||
|
||||
translationRunStore.set({
|
||||
...initialState,
|
||||
runId,
|
||||
uxState: 'running',
|
||||
jobId: options.jobId || null,
|
||||
isFullRun: options.isFullRun || false,
|
||||
});
|
||||
|
||||
_onCompleteCallback = options.onComplete || null;
|
||||
_pollCount = 0;
|
||||
_pollingInterval = setInterval(pollStatus, 2000);
|
||||
pollStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling without clearing state (run may persist).
|
||||
*/
|
||||
export function stopTranslationRun() {
|
||||
if (_pollingInterval) {
|
||||
clearInterval(_pollingInterval);
|
||||
_pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset store to idle.
|
||||
*/
|
||||
export function resetTranslationRun() {
|
||||
stopTranslationRun();
|
||||
translationRunStore.set(initialState);
|
||||
_onCompleteCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually set the run state (used by page component for lifecycle hooks).
|
||||
* @param {Partial<TranslationRunState>} state
|
||||
*/
|
||||
export function updateTranslationRunState(state) {
|
||||
translationRunStore.update(prev => ({ ...prev, ...state }));
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function pollStatus() {
|
||||
_pollCount++;
|
||||
if (_pollCount > MAX_POLLS) {
|
||||
console.warn('[translationRunStore] Max polls reached, stopping');
|
||||
stopTranslationRun();
|
||||
translationRunStore.update(s => ({ ...s, uxState: 'failed' }));
|
||||
if (_onCompleteCallback) _onCompleteCallback({ status: 'TIMEOUT', error_message: 'Translation run timed out' });
|
||||
return;
|
||||
}
|
||||
|
||||
let currentState;
|
||||
translationRunStore.subscribe(s => { currentState = s; })();
|
||||
if (!currentState || !currentState.runId) {
|
||||
stopTranslationRun();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchRunStatus(currentState.runId);
|
||||
if (!data) return;
|
||||
|
||||
const s = data?.status;
|
||||
const insertS = data?.insert_status;
|
||||
|
||||
let newUxState = currentState.uxState;
|
||||
|
||||
if (s === 'PENDING' || s === 'RUNNING') {
|
||||
newUxState = (insertS === 'started' || insertS === 'pending' || insertS === 'running')
|
||||
? 'inserting' : 'running';
|
||||
} else if (s === 'COMPLETED') {
|
||||
newUxState = (insertS === 'failed' || insertS === 'timeout') ? 'insert_failed'
|
||||
: (data.failed_records > 0) ? 'partial' : 'completed';
|
||||
stopTranslationRun();
|
||||
} else if (s === 'FAILED') {
|
||||
newUxState = 'failed';
|
||||
stopTranslationRun();
|
||||
} else if (s === 'CANCELLED') {
|
||||
newUxState = 'cancelled';
|
||||
stopTranslationRun();
|
||||
}
|
||||
|
||||
const total = data?.total_records || 0;
|
||||
const pct = total > 0
|
||||
? Math.round(((data.successful_records + data.failed_records + data.skipped_records) / total) * 100)
|
||||
: 0;
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Fire completion callback if terminal
|
||||
if (newUxState !== 'running' && newUxState !== 'inserting') {
|
||||
if (_onCompleteCallback) _onCompleteCallback(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[translationRunStore] Poll error:', err);
|
||||
}
|
||||
}
|
||||
// #endregion TranslationRunStore
|
||||
@@ -30,6 +30,7 @@
|
||||
import TopNavbar from '$lib/components/layout/TopNavbar.svelte';
|
||||
import TaskDrawer from '$lib/components/layout/TaskDrawer.svelte';
|
||||
import AssistantChatPanel from '$lib/components/assistant/AssistantChatPanel.svelte';
|
||||
import TranslationRunGlobalIndicator from '$lib/components/translate/TranslationRunGlobalIndicator.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
isProductionContextStore,
|
||||
@@ -53,6 +54,9 @@
|
||||
|
||||
<Toast />
|
||||
|
||||
<!-- Global persistent translation run progress indicator -->
|
||||
<TranslationRunGlobalIndicator />
|
||||
|
||||
<main class="min-h-screen {isProductionContext ? 'bg-red-50/40' : 'bg-slate-50'}">
|
||||
{#if isLoginPage}
|
||||
<div class="p-4">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
* @UX_REACTIVITY: columnList is $derived from selected datasource
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { t, _ } from '$lib/i18n';
|
||||
@@ -50,6 +50,13 @@
|
||||
import ScheduleConfig from '$lib/components/translate/ScheduleConfig.svelte';
|
||||
import { triggerRun, fetchRunHistory, cancelRun } from '$lib/api/translate.js';
|
||||
import BulkReplaceModal from '$lib/components/translate/BulkReplaceModal.svelte';
|
||||
import { fromStore } from 'svelte/store';
|
||||
import {
|
||||
translationRunStore,
|
||||
startTranslationRun,
|
||||
resetTranslationRun,
|
||||
clearOnCompleteCallback,
|
||||
} from '$lib/stores/translationRun.js';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
@@ -154,8 +161,9 @@
|
||||
let validationErrors = $state({});
|
||||
let warnings = $state([]);
|
||||
|
||||
// Run state
|
||||
let currentRunId = $state(null);
|
||||
// Run state — using global store to survive page navigation
|
||||
let runState = fromStore(translationRunStore);
|
||||
let currentRunId = $derived(runState.current?.runId || null);
|
||||
let completedRuns = $state([]);
|
||||
let isRunning = $state(false);
|
||||
let isFullRun = $state(false);
|
||||
@@ -168,7 +176,7 @@
|
||||
runError = '';
|
||||
try {
|
||||
const run = await triggerRun(jobId, full);
|
||||
currentRunId = run.id;
|
||||
startTranslationRun(run.id, { jobId, isFullRun: full, onComplete: handleRunComplete });
|
||||
addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success');
|
||||
} catch (err) {
|
||||
runError = err?.message || _('translate.config.run_failed');
|
||||
@@ -177,8 +185,9 @@
|
||||
}
|
||||
|
||||
function handleRunComplete(statusData) {
|
||||
if (!isRunning) return; // Idempotent: already handled
|
||||
isRunning = false;
|
||||
currentRunId = null;
|
||||
resetTranslationRun();
|
||||
loadRunHistory();
|
||||
const statusLabel = statusData?.status || _('translate.run.completed');
|
||||
addToast(`${_('translate.run.run_id')} ${statusLabel.toLowerCase()}`, 'info');
|
||||
@@ -199,7 +208,7 @@
|
||||
await cancelRun(currentRunId);
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
currentRunId = null;
|
||||
resetTranslationRun();
|
||||
await handleTriggerRun();
|
||||
}
|
||||
|
||||
@@ -235,6 +244,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up store callback when page unmounts — the store keeps
|
||||
// polling so the global indicator in the layout still works.
|
||||
onDestroy(() => {
|
||||
clearOnCompleteCallback();
|
||||
});
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user