refactor(translate): remove DRAFT status from UI, auto-transition via preflight

- Remove DRAFT badge, 'Mark as READY' button, DRAFT filter pill, DRAFT run gate
- Auto-transition DRAFT<->READY based on runReady (all required preflight items)
- Move PreflightChecklist to page top — always visible across tabs
- Remove orchestration DRAFT gate — preflight handles readiness
- Remove 10 dead i18n keys (status_draft, mark_ready, disabled_draft, hint_draft)
- Add reverse transition READY->DRAFT when fields cleared
- Remove unused runComplete prop from RunTabContent
- Add fallback badge styling for unrecognized statuses
This commit is contained in:
2026-07-08 19:35:05 +03:00
parent 2d0fe96a63
commit e6532c8dab
11 changed files with 304 additions and 109 deletions

View File

@@ -0,0 +1,119 @@
<!-- #region PreflightChecklist [C:2] [TYPE Component] [SEMANTICS translate, run, preflight, checklist, readiness] -->
<!-- @ingroup Translate -->
<!-- @BRIEF Compact preflight readiness checklist — progress ring + expandable details.
Shows required (★) and optional items, with inline missing-field chips when collapsed. -->
<!-- @LAYER UI -->
<!-- @UX_STATE Collapsed — summary bar with progress ring + missing chips -->
<!-- @UX_STATE Expanded — grid of all readiness items with check/cross icons -->
<!-- @UX_REACTIVITY Props -> $props() for runReadiness array -->
<script lang="ts">
import { getT } from '$lib/i18n/index.svelte.js';
let {
runReadiness = [],
onItemClick = (_key: string, _tab: string) => {},
} = $props();
const _t = $derived(getT());
const rr = $derived(_t.translate?.jobs?.run_readiness ?? _t.translate?.config?.run_readiness);
// Map item keys to i18n label keys
const KEY_I18N: Record<string, string> = {
'name': 'label_name', 'datasource': 'label_datasource',
'translationColumn': 'label_translation_column', 'targetLanguages': 'label_target_languages',
'provider': 'label_provider', 'targetSchema': 'label_target_schema',
'targetTable': 'label_target_table', 'schemaValidated': 'label_schema_validated',
'connectionId': 'label_connection_id',
};
function tLabel(key: string, fallback: string): string {
const k = KEY_I18N[key];
return (k && (rr as Record<string, string>)?.[k]) || fallback;
}
function tHint(key: string, fallback: string): string {
const k = KEY_I18N[key]?.replace('label_', 'hint_');
return (k && (rr as Record<string, string>)?.[k]) || fallback;
}
let showChecklist = $state(false);
let readyCount = $derived(runReadiness.filter((i: { ok: boolean }) => i.ok).length);
let totalCount = $derived(runReadiness.length);
let missingRequired = $derived(runReadiness.filter((i: { ok: boolean; required: boolean }) => !i.ok && i.required));
let allReady = $derived(readyCount === totalCount && totalCount > 0);
</script>
{#if runReadiness.length > 0}
<div class="mb-4 rounded-lg border {allReady ? 'border-success/30 bg-success-light' : 'border-warning/30 bg-warning-light'}">
<!-- Summary bar — always visible -->
<button
onclick={() => (showChecklist = !showChecklist)}
class="w-full flex items-center gap-3 px-4 py-2.5 text-left"
aria-expanded={showChecklist}
>
<!-- Progress ring -->
<div class="relative flex-shrink-0 w-7 h-7">
<svg class="w-7 h-7 -rotate-90" viewBox="0 0 28 28">
<circle cx="14" cy="14" r="11" fill="none" stroke="currentColor" stroke-width="3" class="text-surface-muted opacity-40" />
<circle
cx="14" cy="14" r="11" fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round"
stroke-dasharray="{2 * Math.PI * 11}"
stroke-dashoffset="{2 * Math.PI * 11 * (1 - readyCount / Math.max(totalCount, 1))}"
class={allReady ? 'text-success' : 'text-warning'}
/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-bold {allReady ? 'text-success' : 'text-warning'}">
{readyCount}/{totalCount}
</span>
</div>
<!-- Status text -->
<span class="text-sm font-medium flex-1 {allReady ? 'text-success' : 'text-warning'}">
{#if allReady}
{rr?.ready_to_run || 'Ready to run'}
{:else}
{missingRequired.length} {rr?.missing_required || 'required items missing'}
{/if}
</span>
<!-- Missing chips (compact, inline) -->
{#if !allReady && missingRequired.length > 0 && !showChecklist}
<div class="hidden sm:flex items-center gap-1 flex-wrap justify-end max-w-[50%]">
{#each missingRequired.slice(0, 3) as item}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] bg-warning/10 text-warning border border-warning/20">
{tLabel(item.key, item.label)}
</span>
{/each}
{#if missingRequired.length > 3}
<span class="text-[11px] text-text-subtle">+{missingRequired.length - 3}</span>
{/if}
</div>
{/if}
<!-- Expand chevron -->
<svg class="w-4 h-4 text-text-subtle transition-transform flex-shrink-0 {showChecklist ? 'rotate-180' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>
</button>
<!-- Expandable details -->
{#if showChecklist}
<div class="border-t border-border/50 px-4 py-3">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-1.5">
{#each runReadiness as item}
<div class="flex items-center gap-2 text-xs {!item.ok ? 'cursor-pointer hover:bg-surface-muted rounded px-1 -mx-1 py-0.5 transition-colors' : ''}" role={!item.ok ? 'button' : undefined} tabindex={!item.ok ? 0 : undefined} onclick={() => !item.ok && onItemClick(item.key, item.tab)} onkeydown={(e) => { if (!item.ok && (e.key === 'Enter' || e.key === ' ')) onItemClick(item.key, item.tab); }}>
{#if item.ok}
<svg class="w-3.5 h-3.5 shrink-0 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<span class="text-text-muted truncate">{tLabel(item.key, item.label)}</span>
{:else}
<svg class="w-3.5 h-3.5 shrink-0 {item.required ? 'text-warning' : 'text-text-subtle'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01"/></svg>
<span class="{item.required ? 'text-text font-medium' : 'text-text-muted'} truncate underline decoration-dotted underline-offset-2" title={tHint(item.key, item.message)}>{tLabel(item.key, item.label)}</span>
{#if item.required}
<span class="text-[10px] text-warning shrink-0">*</span>
{/if}
{/if}
</div>
{/each}
</div>
{#if missingRequired.length > 0}
<p class="mt-2 text-[11px] text-text-subtle">* — {rr?.required_before_run || 'required before run'}</p>
{/if}
</div>
{/if}
</div>
{/if}
<!-- #endregion PreflightChecklist -->

View File

@@ -21,7 +21,7 @@
<script lang="ts">
import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js';
import { updateJob, fetchJobMetrics } from '$lib/api/translate.js';
import { fetchJobMetrics } from '$lib/api/translate.js';
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
import { Icon } from '$lib/ui';
import TranslationRunProgress from './TranslationRunProgress.svelte';
@@ -36,7 +36,6 @@
isFullRun = false,
runError = null,
currentRunId = null,
runComplete = false,
completedRuns = [],
expandedRunIds = [],
runHistoryHasMore = false,
@@ -48,7 +47,6 @@
onLoadRunHistory = () => {},
onLoadMoreRuns = () => {},
onToggleRunDetails = (_id) => {},
onGoToTargetTab = () => {},
onDismissRun = () => {},
getJobStatusLabel = (s) => s,
} = $props();
@@ -88,7 +86,6 @@
function getRunDisabledReason(): string {
if (isRunning) return _t.translate?.run?.disabled_running || 'Translation is already running';
if (status === 'DRAFT') return _t.translate?.run?.disabled_draft || 'Mark the job as READY first';
if (!schemaValidated) return _t.translate?.run?.disabled_schema || 'Validate target schema first';
return '';
}
@@ -171,19 +168,18 @@
return labels[trigger] || trigger || _t.translate?.run?.trigger_manual;
}
async function handleMarkReady() {
try {
await updateJob(jobId, { status: 'READY' });
status = 'READY';
addToast(getT()?.translate?.config?.job_updated, 'success');
} catch (e) {
addToast(e?.message || 'Failed to update status', 'error');
}
}
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
const statusClass = $derived(
status === 'READY' ? 'bg-success-light text-success'
: status === 'ACTIVE' ? 'bg-success-light text-success'
: status === 'RUNNING' ? 'bg-primary-light text-primary'
: status === 'COMPLETED' ? 'bg-success-light text-success'
: status === 'FAILED' ? 'bg-destructive-light text-destructive'
: 'bg-surface-muted text-text-subtle'
);
</script>
<section class="bg-surface-card border border-border rounded-lg p-6">
@@ -192,23 +188,9 @@
<!-- Status display + transition -->
<div class="flex items-center gap-3 mb-4 p-3 bg-surface-muted rounded-lg">
<span class="text-sm text-text-muted">{_t.translate?.config?.status}:</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{status === 'READY' ? 'bg-success-light text-success' : ''}
{status === 'DRAFT' ? 'bg-warning-light text-warning' : ''}
{status === 'ACTIVE' ? 'bg-success-light text-success' : ''}
{status === 'RUNNING' ? 'bg-primary-light text-primary' : ''}
{status === 'COMPLETED' ? 'bg-success-light text-success' : ''}
{status === 'FAILED' ? 'bg-destructive-light text-destructive' : ''}">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {statusClass}">
{getJobStatusLabel(status)}
</span>
{#if status === 'DRAFT'}
<button
onclick={handleMarkReady}
class="ml-auto px-3 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
>
{_t.translate?.config?.mark_ready || 'Mark as READY'}
</button>
{/if}
</div>
<div class="space-y-4">
@@ -228,17 +210,12 @@
<p class="text-xs text-text-muted mt-1">{_t.translate?.config?.run_incremental_desc}</p>
<button
onclick={() => confirmRun(false)}
disabled={isRunning || status === 'DRAFT' || !schemaValidated}
disabled={isRunning || !schemaValidated}
title={getRunDisabledReason()}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-success text-white rounded-lg hover:bg-success disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isRunning && !isFullRun ? _t.translate?.config?.running : _t.translate?.config?.run_translation}
</button>
{#if !schemaValidated}
<div class="mt-1.5">
<button onclick={onGoToTargetTab} class="text-xs text-warning hover:underline cursor-pointer">{_t.translate?.target_schema?.validate_first || 'Please verify target schema first'}</button>
</div>
{/if}
</div>
</div>
</div>
@@ -259,17 +236,12 @@
<p class="text-xs text-text-muted mt-1">{_t.translate?.config?.run_full_desc}</p>
<button
onclick={() => confirmRun(true)}
disabled={isRunning || status === 'DRAFT' || !schemaValidated}
disabled={isRunning || !schemaValidated}
title={getRunDisabledReason()}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-warning text-white rounded-lg hover:bg-warning-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isRunning && isFullRun ? _t.translate?.config?.running : _t.translate?.config?.full_translate}
</button>
{#if !schemaValidated}
<div class="mt-1.5">
<button onclick={onGoToTargetTab} class="text-xs text-warning hover:underline cursor-pointer">{_t.translate?.target_schema?.validate_first || 'Please verify target schema first'}</button>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -13,6 +13,7 @@
"previous": "Previous",
"unknown_error": "An unknown error occurred",
"from": "From",
"to": "To",
"unknown": "Unknown",
"error": "Error",
"success": "Success",
@@ -58,7 +59,6 @@
"target_language_search_placeholder": "Search languages...",
"target_language_required": "Select at least one target language",
"status": "Status",
"status_draft": "Draft",
"status_ready": "Ready",
"status_active": "Active",
"target_column_placeholder": "Target Column",
@@ -130,11 +130,10 @@
"select_target_database": "Select database for INSERT...",
"target_database_hint": "Database connection used for writing translated data via SQL Lab",
"loading_databases": "Loading databases...",
"include_source_reference": "Include source language in translations",
"include_source_reference_hint": "The original text will be stored as a verified reference copy in its detected language",
"include_source_reference": "Add the original as a separate record",
"include_source_reference_hint": "The target table will receive a copy of the source text with its detected language",
"disable_reasoning": "Disable reasoning (save tokens)",
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning",
"mark_ready": "Mark as READY",
"save_job_first": "Save the job first",
"breadcrumb_job": "Job",
"help_name": "A unique job name for quick identification in the list.",
@@ -148,7 +147,7 @@
"help_target_language": "Languages to translate the text into. You can select multiple — a single LLM call translates each row into ALL selected languages at once. ⚠️ Important: the system auto-detects each source row's language (lingua detector). If the detected language matches one of the target languages, translation for THAT language is SKIPPED — the original text is saved as-is. Example: source 'Hello world' is detected as 'en'. If 'English (en)' is selected as a target, this row is skipped for en (stays 'Hello world') but translated into other selected languages (e.g., 'Привет мир' for ru).",
"help_batch_size": "Number of rows sent to the LLM per request. Smaller batches = higher quality but more tokens spent on overhead.",
"help_upsert_strategy": "Write strategy for the target table: UPSERT (MERGE) — insert or update; INSERT — new rows only; UPDATE — existing rows only.",
"help_include_source_reference": "Save the original text in a separate column as a reference copy. Useful for verification and auditing.",
"help_include_source_reference": "When enabled, the result includes both translations and a separate record with the original text and src_lang. This is useful for audit and comparison. When disabled, rows without an actual translation are not inserted.",
"help_disable_reasoning": "Disable LLM Chain-of-Thought reasoning. Saves tokens (~20-30%) but may reduce quality for complex translations.",
"help_dictionaries": "Terminology dictionaries for forced term mapping. Improves translation consistency within the subject domain.",
"help_target_schema": "The database schema (namespace) where translated data will be written. E.g., public.",
@@ -253,7 +252,6 @@
"subtitle": "Manage your translation jobs",
"new_job": "New Job",
"create_job": "Create Job",
"status_draft": "Draft",
"status_ready": "Ready",
"status_running": "Running",
"status_completed": "Completed",
@@ -274,7 +272,30 @@
"duplicate_failed": "Failed to duplicate job",
"job_deleted": "Job deleted",
"delete_failed": "Failed to delete job",
"jobs_flow_hint": "Flow: create a job → configure datasource and LLM → run translation → check run history."
"jobs_flow_hint": "Flow: create a job → configure datasource and LLM → run translation → check run history.",
"run_readiness": {
"label_name": "Job name",
"hint_name": "Enter a job name",
"label_datasource": "Datasource selected",
"hint_datasource": "Select a source datasource",
"label_translation_column": "Translation column",
"hint_translation_column": "Select a column to translate",
"label_target_languages": "Target languages",
"hint_target_languages": "Select at least one target language",
"label_provider": "LLM provider",
"hint_provider": "Select an LLM provider",
"label_target_schema": "Target schema",
"hint_target_schema": "Target schema not set — insert may fail",
"label_target_table": "Target table",
"hint_target_table": "Target table not set — insert may fail",
"label_schema_validated": "Schema validated",
"hint_schema_validated": "Validate target schema before first run",
"label_connection_id": "DB connection",
"hint_connection_id": "Select a Direct DB connection",
"missing_required": "required items missing",
"ready_to_run": "Ready to run",
"required_before_run": "required before run"
}
},
"history": {
"title": "Translation History",
@@ -579,7 +600,6 @@
"help_sum_total_tokens": "Cumulative LLM token consumption across all runs. Tokens are the unit of input/output processed by the language model.",
"help_sum_total_cost": "Estimated cumulative cost of all LLM API calls for this job, calculated from per-run token usage.",
"help_sum_avg_duration": "Average duration of a translation run across all runs. Measured from run start to completion (wall-clock time, including LLM calls and DB inserts).",
"disabled_draft": "Mark the job as READY first",
"disabled_running": "Translation is already running",
"disabled_schema": "Validate target schema first",
"load_more_runs": "Load more runs",

View File

@@ -13,6 +13,7 @@
"previous": "Назад",
"unknown_error": "Произошла неизвестная ошибка",
"from": "От",
"to": "До",
"unknown": "Неизвестно",
"error": "Ошибка",
"success": "Успешно",
@@ -58,7 +59,6 @@
"target_language_search_placeholder": "Поиск языков...",
"target_language_required": "Выберите хотя бы один язык перевода",
"status": "Статус",
"status_draft": "Черновик",
"status_ready": "Готово",
"status_active": "Активно",
"target_column_placeholder": "Целевая колонка",
@@ -130,11 +130,10 @@
"select_target_database": "Выберите базу данных для INSERT...",
"target_database_hint": "Подключение к БД для записи переведённых данных через SQL Lab",
"loading_databases": "Загрузка баз данных...",
"include_source_reference": "Сохранять исходный текст как эталонную копию",
"include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке",
"include_source_reference": "Добавлять оригинал отдельной записью",
"include_source_reference_hint": "В целевую таблицу будет записана копия исходного текста с его обнаруженным языком",
"disable_reasoning": "Отключить рассуждения (экономия токенов)",
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)",
"mark_ready": "Пометить как готово",
"save_job_first": "Сначала сохраните задание",
"breadcrumb_job": "Задание",
"help_name": "Уникальное название задания для быстрой идентификации в списке.",
@@ -148,7 +147,7 @@
"help_target_language": "Языки, на которые нужно перевести текст. Можно выбрать несколько — за один проход LLM переведёт строку на все языки сразу. ⚠️ Важно: система автоматически определяет язык каждой исходной строки (lingua-детектор). Если обнаруженный язык совпадает с одним из целевых — для этой строки перевод на данный язык НЕ выполняется, а исходный текст сохраняется как есть. Пример: исходная строка 'Hello world' определена как 'en'. Если 'English (en)' выбран как целевой язык, эта строка будет пропущена для en-перевода (останется 'Hello world'), но будет переведена на остальные выбранные языки (например, 'Привет мир' для ru).",
"help_batch_size": "Количество строк, отправляемых в LLM за один запрос. Меньше пакет = выше качество, но больше токенов на оверхед.",
"help_upsert_strategy": "Стратегия записи в целевую таблицу: UPSERT (MERGE) — вставка или обновление; INSERT — только новые строки; UPDATE — только обновление существующих.",
"help_include_source_reference": "Сохранить оригинальный текст в отдельной колонке как эталонную копию. Полезно для верификации и аудита.",
"help_include_source_reference": "Если включено, система добавит в результат не только переводы, но и отдельную запись с исходным текстом и src_lang. Это полезно для аудита и сверки. Если выключить, строки без фактического перевода не будут вставляться.",
"help_disable_reasoning": "Отключить Chain-of-Thought рассуждения LLM. Экономит токены (~20-30%), но может снизить качество сложных переводов.",
"help_dictionaries": "Терминологические словари для принудительного сопоставления терминов. Повышают консистентность перевода в рамках предметной области.",
"help_target_schema": "Схема (namespace) базы данных, в которую будут записаны переведённые данные. Например: public.",
@@ -254,7 +253,6 @@
"subtitle": "Управление заданиями перевода",
"new_job": "Новое задание",
"create_job": "Создать задание",
"status_draft": "Черновик",
"status_ready": "Готово",
"status_running": "Выполняется",
"status_completed": "Завершено",
@@ -275,7 +273,30 @@
"duplicate_failed": "Не удалось дублировать задание",
"job_deleted": "Задание удалено",
"delete_failed": "Не удалось удалить задание",
"jobs_flow_hint": "Flow: создайте задание → настройте источник данных и LLM → запустите перевод → проверьте историю запусков."
"jobs_flow_hint": "Flow: создайте задание → настройте источник данных и LLM → запустите перевод → проверьте историю запусков.",
"run_readiness": {
"label_name": "Название",
"hint_name": "Введите название задания",
"label_datasource": "Источник данных",
"hint_datasource": "Выберите источник данных",
"label_translation_column": "Колонка для перевода",
"hint_translation_column": "Выберите колонку для перевода",
"label_target_languages": "Языки перевода",
"hint_target_languages": "Выберите хотя бы один целевой язык",
"label_provider": "LLM провайдер",
"hint_provider": "Выберите LLM провайдера",
"label_target_schema": "Целевая схема",
"hint_target_schema": "Не указана целевая схема — вставка может не сработать",
"label_target_table": "Целевая таблица",
"hint_target_table": "Не указана целевая таблица — вставка может не сработать",
"label_schema_validated": "Схема проверена",
"hint_schema_validated": "Проверьте целевую схему перед первым запуском",
"label_connection_id": "Подключение к БД",
"hint_connection_id": "Выберите Direct DB подключение",
"missing_required": "обязательных полей не заполнено",
"ready_to_run": "Готово к запуску",
"required_before_run": "обязательно для запуска"
}
},
"history": {
"title": "История переводов",
@@ -580,7 +601,6 @@
"help_sum_total_tokens": "Суммарное потребление токенов LLM во всех запусках. Токены — единица входных/выходных данных, обрабатываемых языковой моделью.",
"help_sum_total_cost": "Оценка суммарной стоимости всех вызовов LLM API для этого задания, рассчитанная на основе использования токенов в каждом запуске.",
"help_sum_avg_duration": "Средняя продолжительность запуска перевода по всем запускам. Измеряется от начала до завершения (реальное время, включая вызовы LLM и вставки в БД).",
"disabled_draft": "Сначала переведите задание в статус ГОТОВО",
"disabled_running": "Перевод уже выполняется",
"disabled_schema": "Сначала проверьте схему целевой таблицы",
"load_more_runs": "Загрузить ещё запуски",

View File

@@ -18,10 +18,6 @@
// in arrow functions: onTriggerRun={(full) => m.handleTriggerRun(full)}.
// @REJECTED Converting methods to arrow class fields rejected — it would conflict with the Svelte 5
// `$state` rune initialization order for non-primitive state atoms in the constructor.
// @REJECTED includeSourceReference ($state at line 52) has no backend column or schema field.
// It is a UI-only checkbox; the value always resets to `true` on page load.
// The backend TranslateJobCreate/Update/Response schemas lack `include_source_reference`.
// Requires a DB migration + Pydantic schema update to persist. Not implemented as of 2026-06-03.
import { api } from '$lib/api.js';
import { addToast } from '$lib/toasts.svelte.js';
import { _, getT } from '$lib/i18n/index.svelte.js';
@@ -30,6 +26,15 @@ import { startTranslationRun, resetTranslationRun, translationRunStore } from '$
type UxState = 'idle' | 'loading' | 'configured' | 'saving' | 'validation_error' | 'datasource_unavailable';
type ReadinessItem = {
key: string;
label: string;
ok: boolean;
required: boolean;
message: string;
tab: string; // 'config' | 'target' — which tab to navigate to on click
};
export class TranslationJobModel {
// ── Context ───────────────────────────────────────────────────
uxState: UxState = $state('idle');
@@ -99,7 +104,35 @@ export class TranslationJobModel {
isSaving: boolean = $state(false);
isDirty: boolean = $state(false);
validationErrors: Record<string, unknown> = $state({});
warnings: string[] = $state([]);
/** Run readiness checklist — derived from current model state */
runReadiness: ReadinessItem[] = $derived.by(() => {
const items: ReadinessItem[] = [
{ key: 'name', label: 'Job name', ok: !!this.name, required: true, message: 'Enter a job name', tab: 'config' },
{ key: 'datasource', label: 'Datasource selected', ok: !!this.datasourceId, required: true, message: 'Select a source datasource', tab: 'config' },
{ key: 'translationColumn', label: 'Translation column', ok: !!this.translationColumn, required: true, message: 'Select a column to translate', tab: 'config' },
{ key: 'targetLanguages', label: 'Target languages', ok: this.targetLanguages.length > 0, required: true, message: 'Select at least one target language', tab: 'config' },
{ key: 'provider', label: 'LLM provider', ok: !!this.providerId, required: true, message: 'Select an LLM provider', tab: 'config' },
{ key: 'targetSchema', label: 'Target schema', ok: !!this.targetSchema, required: false, message: 'Target schema not set — insert may fail', tab: 'target' },
{ key: 'targetTable', label: 'Target table', ok: !!this.targetTable, required: false, message: 'Target table not set — insert may fail', tab: 'target' },
{ key: 'schemaValidated', label: 'Target schema validated', ok: this.schemaValidated, required: false, message: 'Validate target schema before first run', tab: 'target' },
];
if (this.insertMethod === 'direct_db') {
items.push({ key: 'connectionId', label: 'DB connection', ok: !!this.connectionId, required: true, message: 'Select a Direct DB connection', tab: 'target' });
}
return items;
});
/** True when all REQUIRED readiness items pass */
runReady: boolean = $derived(this.runReadiness.filter(i => i.required).every(i => i.ok));
/** Warnings derived from readiness items */
warnings: string[] = $derived.by(() => {
const w: string[] = [];
const missing = this.runReadiness.filter(i => !i.ok && !i.required);
for (const item of missing) w.push(item.message);
return w;
});
configValid = $derived(
!!this.translationColumn && !!this.datasourceId && this.targetLanguages.length > 0 && !!this.providerId
@@ -127,15 +160,22 @@ export class TranslationJobModel {
// ── Actions: Run ─────────────────────────────────────────────
async handleTriggerRun(full = false): Promise<void> {
this.runError = '';
// Auto-save any unsaved config changes before triggering a run
if (!this.isNewJob) {
try {
await this.saveJob();
} catch (err: unknown) {
this.runError = err instanceof Error ? err.message : _('translate.config.run_failed');
this.isRunning = false;
addToast(this.runError, 'error');
return; // don't proceed if auto-save fails
}
}
this.isRunning = true;
this.runComplete = false;
this.isFullRun = full;
this.runError = '';
try {
// Auto-save any unsaved config changes before triggering a run
if (!this.isNewJob) {
await this.saveJob();
}
const run = await triggerRun(this.jobId, full);
startTranslationRun(run.id, { jobId: this.jobId, isFullRun: full, onComplete: this._onRunComplete.bind(this) });
addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success');
@@ -252,10 +292,16 @@ export class TranslationJobModel {
this.upsertStrategy = (job.upsert_strategy as string) || 'MERGE';
this.insertMethod = (job.insert_method as string) || 'sqllab';
this.connectionId = (job.connection_id as string) || null;
this.includeSourceReference = (job.include_source_reference as boolean) ?? true;
this.disableReasoning = (job.disable_reasoning as boolean) ?? false;
this.databaseDialect = (job.database_dialect as string) || '';
this.datasourceId = (job.source_datasource_id as string) || '';
this.status = (job.status as string) || 'DRAFT';
if (this.runReady && this.status === 'DRAFT') {
this.status = 'READY';
} else if (!this.runReady && this.status === 'READY') {
this.status = 'DRAFT';
}
this.targetSchema = (job.target_schema as string) || '';
this.targetTable = (job.target_table as string) || '';
this.targetDatabaseId = (job.target_database_id as string) || '';
@@ -320,6 +366,11 @@ export class TranslationJobModel {
this.validationErrors = {};
this.isDirty = false;
try {
if (this.runReady && this.status === 'DRAFT') {
this.status = 'READY';
} else if (!this.runReady && this.status === 'READY') {
this.status = 'DRAFT';
}
const payload = {
name: this.name,
description: this.description,
@@ -337,6 +388,7 @@ export class TranslationJobModel {
upsert_strategy: this.upsertStrategy,
insert_method: this.insertMethod,
connection_id: this.connectionId || undefined,
include_source_reference: this.includeSourceReference,
disable_reasoning: this.disableReasoning,
database_dialect: this.databaseDialect && this.databaseDialect !== 'unknown' ? this.databaseDialect : undefined,
target_schema: this.targetSchema || undefined,
@@ -360,6 +412,38 @@ export class TranslationJobModel {
this.uxState = 'configured';
} catch (err: unknown) {
this.error = err instanceof Error ? err.message : 'Failed to save';
// Parse structured Pydantic 422 errors into per-field validationErrors
const apiErr = err as Record<string, unknown>;
if (Array.isArray(apiErr.detail)) {
const FIELD_MAP: Record<string, string> = {
'name': 'name',
'translation_column': 'translationColumn',
'source_datasource_id': 'datasourceId',
'target_languages': 'targetLanguages',
'provider_id': 'providerId',
'target_schema': 'targetSchema',
'target_table': 'targetTable',
'source_table': 'sourceTable',
'target_column': 'targetColumn',
'batch_size': 'batchSize',
'upsert_strategy': 'upsertStrategy',
'insert_method': 'insertMethod',
'connection_id': 'connectionId',
'environment_id': 'environmentId',
'target_database_id': 'targetDatabaseId',
'target_language_column': 'targetLanguageColumn',
'target_source_column': 'targetSourceColumn',
'target_source_language_column': 'targetSourceLanguageColumn',
};
for (const detail of apiErr.detail as Array<Record<string, unknown>>) {
const loc = detail.loc as string[] | undefined;
if (loc && loc.length > 0) {
const snakeField = loc[loc.length - 1];
const camelField = FIELD_MAP[snakeField] || snakeField;
this.validationErrors[camelField] = detail.msg as string;
}
}
}
addToast(this.error, 'error');
this.uxState = 'validation_error';
}

View File

@@ -24,7 +24,7 @@
// Count jobs by status for filter pills
let statusCounts = $derived.by(() => {
const counts = { DRAFT: 0, READY: 0, RUNNING: 0, COMPLETED: 0, FAILED: 0, CANCELLED: 0 };
const counts = { READY: 0, RUNNING: 0, COMPLETED: 0, FAILED: 0 };
for (const job of jobs) {
if (counts[job.status] !== undefined) counts[job.status]++;
}
@@ -43,7 +43,6 @@
let statusPills = $derived([
{ label: 'All', value: '', count: jobs.length },
{ label: $t.translate?.jobs?.status_draft, value: 'DRAFT', count: statusCounts.DRAFT },
{ label: $t.translate?.jobs?.status_ready, value: 'READY', count: statusCounts.READY },
{ label: $t.translate?.jobs?.status_running, value: 'RUNNING', count: statusCounts.RUNNING },
{ label: $t.translate?.jobs?.status_completed, value: 'COMPLETED', count: statusCounts.COMPLETED },
@@ -196,7 +195,7 @@
{:else if uxState === 'populated'}
<div class="grid gap-4">
{#each jobs as job}
{@const statusVariant = ({ DRAFT: "muted", READY: "primary", RUNNING: "warning", COMPLETED: "success", FAILED: "destructive", CANCELLED: "muted" })[job.status] || "muted"}
{@const statusVariant = ({ READY: "primary", RUNNING: "warning", COMPLETED: "success", FAILED: "destructive", CANCELLED: "muted" })[job.status] || "muted"}
<div
onclick={() => navigateToConfig(job.id)}
class="bg-surface-card border border-border rounded-lg p-4 hover:shadow-md hover:border-border-strong transition-all cursor-pointer"

View File

@@ -39,6 +39,7 @@
import ConfigTabForm from '$lib/components/translate/ConfigTabForm.svelte';
import TargetTabForm from '$lib/components/translate/TargetTabForm.svelte';
import RunTabContent from '$lib/components/translate/RunTabContent.svelte';
import PreflightChecklist from '$lib/components/translate/PreflightChecklist.svelte';
const m = new TranslationJobModel();
// @RATIONALE Svelte 5 compiler fails to detect `t` as store in deeply nested
@@ -101,18 +102,6 @@
</div>
</div>
<!-- Warnings -->
{#if m.warnings.length > 0}
<div class="bg-warning-light border border-warning rounded-lg p-4 mb-6">
<h4 class="text-sm font-medium text-warning mb-2">{_t.translate?.config?.warnings_title}</h4>
<ul class="list-disc list-inside text-sm text-warning space-y-1">
{#each m.warnings as w}
<li>{w}</li>
{/each}
</ul>
</div>
{/if}
<!-- Loading -->
{#if m.uxState === 'loading'}
<div class="space-y-6">{#each Array(6) as _}<div class="h-16 bg-surface-muted rounded-lg animate-pulse"></div>{/each}</div>
@@ -132,6 +121,9 @@
<!-- Form -->
{:else if m.uxState === 'configured' || m.uxState === 'saving' || m.uxState === 'validation_error' || m.uxState === 'idle'}
<!-- Preflight checklist — always visible at top -->
<PreflightChecklist runReadiness={m.runReadiness} onItemClick={(key, tab) => { m.activeTab = tab; }} />
<!-- Tab Navigation -->
<div class="border-b border-border mb-6">
<nav class="flex gap-1" aria-label="Tabs">
@@ -268,7 +260,6 @@
isFullRun={m.isFullRun}
runError={m.runError}
currentRunId={m.currentRunId}
runComplete={m.runComplete}
completedRuns={m.completedRuns}
expandedRunIds={m.expandedRunIds}
runHistoryHasMore={m.runHistoryHasMore}
@@ -280,7 +271,6 @@
onLoadRunHistory={() => m.loadRunHistory()}
onLoadMoreRuns={() => m.loadRunHistory(true)}
onToggleRunDetails={(id) => m.toggleRunDetails(id)}
onGoToTargetTab={() => m.activeTab = 'target'}
onDismissRun={() => m.handleDismissRun()}
/>
{:else}