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:
2026-05-15 22:06:27 +03:00
parent fdf48491a1
commit 27168664b8
10 changed files with 781 additions and 96 deletions

View File

@@ -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>

View File

@@ -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">
&#9654; {$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">
&#9654; {$t.common?.open || 'Open'}
</span>
{:else}
<span class="ml-auto opacity-70 hover:opacity-100 transition-opacity">
&#9654; {$t.common?.open || 'Open'}
</span>
{/if}
</div>
</div>
{/if}
<!-- #endregion TranslationRunGlobalIndicator -->

View 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

View File

@@ -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">

View File

@@ -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 {