feat(translate): add translation cache with source_hash dedup + enforce dictionary post-processing

- Add source_hash column (SHA256 of source_text+source_data+dict_hash+config_hash) to TranslationRecord for cache dedup
- Add Alembic migration b1c2d3e4f5a6 for the new column and composite index (source_hash, status)
- Implement _compute_source_hash / _check_translation_cache in executor.py — before LLM call, check if same source+dict+config combo was already translated successfully
- Implement cache-aware routing in _process_batch: full cache hit → skip LLM, partial hit → route to LLM
- Store source_hash on all new TranslationRecord rows for future cache hits
- Strengthen dictionary prompt instructions from 'use when applicable' to 'MUST use — mandatory'
- Add _enforce_dictionary() post-processing: force-replace dictionary terms in LLM output if LLM ignored them
- Fix CRITICAL: use run.dict_snapshot_hash/config_hash (exist on TranslationRun, not TranslationJob)
- Fix MAJOR: verify cached languages cover ALL target_languages before accepting cache hit
- Fix MAJOR: prevent regex back-reference injection in dictionary enforcement (use lambda)
- Fix: add joinedload to cache lookup to avoid N+1 queries
- Fix: remove redundant single-column index (composite index is sufficient)
- Fix(frontend): parse paginated /translate/dictionaries response correctly (result.items)
This commit is contained in:
2026-05-16 00:42:07 +03:00
parent 30ba70933d
commit b466ac6211
12 changed files with 458 additions and 113 deletions

View File

@@ -81,12 +81,17 @@
admin: "nav.admin",
settings: "nav.settings",
git: "nav.git",
translate: "translate.jobs.title",
};
if (specialCases[segment]) {
return _(specialCases[segment]) || segment;
}
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
return _('translate.config.breadcrumb_job') || 'Job';
}
return segment
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))

View File

@@ -39,6 +39,7 @@
let nextExecutions = $state([]);
let hasPriorRun = $state(true);
let scheduleExists = $state(false);
let isEditing = $derived(uxState === 'editing');
$effect(() => {
if (jobId) loadSchedule();
@@ -190,11 +191,13 @@
type="text"
bind:value={cronExpression}
placeholder="0 2 * * *"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500"
disabled={!isEditing}
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
/>
<select
bind:value={cronExpression}
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
disabled={!isEditing}
class="px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
>
<option value="0 2 * * *">{$t.translate?.schedule?.preset_daily || 'Daily at 02:00'}</option>
<option value="0 */6 * * *">{$t.translate?.schedule?.preset_every_6h || 'Every 6 hours'}</option>
@@ -213,7 +216,8 @@
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.timezone || 'Timezone'}</label>
<select
bind:value={timezone}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
disabled={!isEditing}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
>
{#each commonTimezones as tz}
<option value={tz}>{tz}</option>
@@ -226,15 +230,15 @@
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.execution_mode || 'Execution Mode'}</label>
<div class="flex gap-4">
<label class="inline-flex items-center cursor-pointer">
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" disabled={!isEditing} />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
<div class="font-medium">{$t.translate?.schedule?.mode_full || 'Full'}</div>
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_full_desc || 'Translate all rows every run'}</div>
</div>
</label>
<label class="inline-flex items-center cursor-pointer">
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" disabled={!isEditing} />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
<div class="font-medium">{$t.translate?.schedule?.mode_new_keys || 'New Keys Only'}</div>
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_new_keys_desc || 'Only translate rows with new key values'}</div>
</div>

View File

@@ -55,6 +55,15 @@
let pendingCount = $derived(records.filter(r => r.status === 'PENDING').length);
let isAllApproved = $derived(pendingCount === 0 && records.length > 0);
function getRowStatusLabel(status) {
const labels = {
APPROVED: $t.translate?.preview?.status_approved || 'Approved',
REJECTED: $t.translate?.preview?.status_rejected || 'Rejected',
PENDING: $t.translate?.preview?.status_pending || 'Pending',
};
return labels[status] || status;
}
/** Get language status for a record and language code */
function getLangStatus(record, langCode) {
const lang = (record.languages || []).find(l => l.language_code === langCode);
@@ -131,6 +140,37 @@
}
}
/** @param {string} rowId @param {string} langCode */
async function handleApproveLanguage(rowId, langCode) {
try {
const updated = await approveRow(jobId, rowId, langCode);
records = records.map(r => r.id === rowId ? {
...r,
status: updated.status || r.status,
target_sql: updated.target_sql || r.target_sql,
languages: updated.languages || r.languages,
_isEdited: false
} : r);
} catch (err) {
addToast(err?.message || $t.translate?.preview?.approve_failed, 'error');
}
}
/** @param {string} rowId @param {string} langCode */
async function handleRejectLanguage(rowId, langCode) {
try {
const updated = await rejectRow(jobId, rowId, langCode);
records = records.map(r => r.id === rowId ? {
...r,
status: updated.status || r.status,
languages: updated.languages || r.languages,
_isEdited: false
} : r);
} catch (err) {
addToast(err?.message || $t.translate?.preview?.reject_failed, 'error');
}
}
/** @param {string} rowId @param {string} langCode */
function startLangEdit(rowId, langCode) {
const key = `${rowId}_${langCode}`;
@@ -427,7 +467,7 @@
<div class="flex gap-1 mt-1 flex-wrap">
{#if langStatus !== 'approved' && langStatus !== 'edited'}
<button
onclick={() => approveRow(jobId, row.id, lang)}
onclick={() => handleApproveLanguage(row.id, lang)}
class="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
title={$t.translate?.preview?.approve}
>
@@ -443,7 +483,7 @@
</button>
{#if langStatus !== 'rejected'}
<button
onclick={() => rejectRow(jobId, row.id, lang)}
onclick={() => handleRejectLanguage(row.id, lang)}
class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
title={$t.translate?.preview?.reject}
>
@@ -461,7 +501,7 @@
{row.status === 'REJECTED' ? 'bg-red-100 text-red-700' : ''}
{row.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : ''}
">
{row.status}
{getRowStatusLabel(row.status)}
</span>
<div class="flex gap-1">
{#if row.status !== 'APPROVED'}
@@ -528,4 +568,4 @@
</div>
{/if}
</div>
<!-- #endregion TranslationPreview -->
<!-- #endregion TranslationPreview -->

View File

@@ -45,7 +45,6 @@
import CorrectionCell from './CorrectionCell.svelte';
import TermCorrectionPopup from './TermCorrectionPopup.svelte';
import BulkReplaceModal from './BulkReplaceModal.svelte';
import BulkCorrectionSidebar from './BulkCorrectionSidebar.svelte';
/** @type {{ runId: string, onRefresh: Function }} */
let { runId, onRefresh = () => {} } = $props();
@@ -65,7 +64,17 @@
let targetLanguages = $state([]);
let correctionPopupData = $state(null);
let showBulkReplace = $state(false);
let bulkCorrectionItems = $state([]);
function getRunStatusLabel(value) {
const labels = {
COMPLETED: $t.translate?.run?.completed || 'Completed',
FAILED: $t.translate?.run?.translation_failed || 'Failed',
RUNNING: $t.translate?.history?.status_running || 'Running',
PENDING: $t.translate?.history?.status_pending || 'Pending',
CANCELLED: $t.translate?.run?.cancelled || 'Cancelled',
};
return labels[value] || value;
}
// Derived
let insertStatusBadge = $derived.by(() => {
@@ -211,12 +220,6 @@
{isRetryingInsert ? $t.translate?.run?.retrying_insert : $t.translate?.run?.retry_insert}
</button>
{/if}
<button
onclick={() => showBulkReplace = true}
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors"
>
{$t.translate?.run?.bulk_replace || 'Bulk Replace'}
</button>
</div>
</div>
@@ -278,6 +281,10 @@
{$t.translate?.run?.copy_id}
</button>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.status_label || 'Статус'}</span>
<span class="ml-1 text-xs font-medium text-gray-700">{getRunStatusLabel(status.status)}</span>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.insert_status}</span>
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertStatusBadge.class}">
@@ -434,45 +441,23 @@
</span>
</td>
<td class="px-3 py-2 text-center align-top">
<div class="flex flex-col gap-1">
<button
onclick={() => {
const firstLang = row.languages?.[0] || {};
correctionPopupData = {
sourceTerm: row.source_sql || row.source_object_name || '',
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
sourceLanguage: firstLang?.source_language_detected || row.source_language_detected || '',
targetLanguage: targetLanguages[0] || '',
runId,
rowKey: row.id
};
}}
class="px-2 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Submit Correction"
>
{$t.translate?.run?.correct || 'Correct'}
</button>
<button
onclick={() => {
const firstLang = row.languages?.[0] || {};
const item = {
sourceTerm: row.source_sql || row.source_object_name || '',
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
rowKey: row.id
};
const exists = bulkCorrectionItems.some(
i => i.sourceTerm === item.sourceTerm && i.incorrectTarget === item.incorrectTarget
);
if (!exists) {
bulkCorrectionItems = [...bulkCorrectionItems, item];
}
}}
class="px-2 py-0.5 text-[10px] bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
title="Collect for bulk correction"
>
{$t.translate?.run?.collect || 'Collect'}
</button>
</div>
<button
onclick={() => {
const firstLang = row.languages?.[0] || {};
correctionPopupData = {
sourceTerm: row.source_sql || row.source_object_name || '',
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
sourceLanguage: firstLang?.source_language_detected || row.source_language_detected || '',
targetLanguage: targetLanguages[0] || '',
runId,
rowKey: row.id
};
}}
class="px-2 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Submit Correction"
>
{$t.translate?.run?.correct || 'Correct'}
</button>
</td>
</tr>
{/each}
@@ -500,11 +485,4 @@
onClose={() => showBulkReplace = false}
onApplied={(count) => { showBulkReplace = false; loadData(); onRefresh(); }}
/>
<BulkCorrectionSidebar
{runId}
initialItems={bulkCorrectionItems}
onClose={() => bulkCorrectionItems = []}
onSubmitted={() => { bulkCorrectionItems = []; loadData(); onRefresh(); }}
/>
<!-- #endregion TranslationRunResult -->

View File

@@ -55,8 +55,15 @@
"target_schema_placeholder": "e.g. public",
"target_table_placeholder": "e.g. users_translated",
"target_language": "Target Language",
"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",
"target_column_mapping_title": "Target Column Mapping",
"target_column_mapping_description": "Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.",
"target_column_label": "Target Column (translated text)",
"target_column_hint": "Column where translated text will be inserted",
"target_language_column_label": "Language Column",
@@ -108,7 +115,10 @@
"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",
"disable_reasoning": "Disable reasoning (save tokens)",
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning"
"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"
},
"preview": {
"title": "Preview",
@@ -170,7 +180,10 @@
"detected_language": "Detected Lang",
"languages_count": "Languages: {count}",
"across_languages": "Across {count} language(s)",
"language_label": "Language"
"language_label": "Language",
"status_approved": "Approved",
"status_rejected": "Rejected",
"status_pending": "Pending"
},
"jobs": {
"title": "Translation Jobs",
@@ -256,6 +269,7 @@
"next_executions": "Next Executions",
"enabled": "Enabled",
"disabled": "Disabled",
"tab_label": "Schedule",
"edit": "Edit",
"update": "Update",
"create": "Create",
@@ -450,7 +464,9 @@
"failed_label": "{count} failed",
"skipped_label": "{count} skipped",
"tokens_label": "{count} tokens",
"load_failed": "Failed to load run status"
"load_failed": "Failed to load run status",
"bulk_replace": "Bulk Replace",
"status_label": "Status"
},
"corrections": {
"title": "Bulk Corrections",

View File

@@ -55,8 +55,15 @@
"target_schema_placeholder": "например, public",
"target_table_placeholder": "например, users_translated",
"target_language": "Язык перевода",
"target_language_search_placeholder": "Поиск языков...",
"target_language_required": "Выберите хотя бы один язык перевода",
"status": "Статус",
"status_draft": "Черновик",
"status_ready": "Готово",
"status_active": "Активно",
"target_column_placeholder": "Целевая колонка",
"target_column_mapping_title": "Маппинг целевых колонок",
"target_column_mapping_description": "Настройте, в какие колонки писать перевод, коды языков, исходный текст и определённый язык источника при INSERT.",
"target_column_label": "Целевая колонка (переведённый текст)",
"target_column_hint": "Колонка, в которую будет вставлен переведённый текст",
"target_language_column_label": "Колонка языка",
@@ -108,7 +115,10 @@
"include_source_reference": "Сохранять исходный текст как эталонную копию",
"include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке",
"disable_reasoning": "Отключить рассуждения (экономия токенов)",
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)"
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)",
"mark_ready": "Пометить как готово",
"save_job_first": "Сначала сохраните задание",
"breadcrumb_job": "Задание"
},
"preview": {
"title": "Предпросмотр",
@@ -171,6 +181,10 @@
"languages_count": "Языков: {count}",
"across_languages": "Для {count} язык(ов)",
"language_label": "Язык"
,
"status_approved": "Одобрено",
"status_rejected": "Отклонено",
"status_pending": "Ожидает"
},
"jobs": {
"title": "Задания перевода",
@@ -256,6 +270,7 @@
"next_executions": "Следующие выполнения",
"enabled": "Включено",
"disabled": "Отключено",
"tab_label": "Расписание",
"edit": "Редактировать",
"update": "Обновить",
"create": "Создать",
@@ -450,7 +465,9 @@
"failed_label": "{count} с ошибками",
"skipped_label": "{count} пропущено",
"tokens_label": "{count} токенов",
"load_failed": "Не удалось загрузить статус запуска"
"load_failed": "Не удалось загрузить статус запуска",
"bulk_replace": "Массовая замена",
"status_label": "Статус"
},
"corrections": {
"title": "Массовые исправления",

View File

@@ -58,33 +58,35 @@
clearOnCompleteCallback,
} from '$lib/stores/translationRun.js';
const LANGUAGES = [
{ code: 'ru', name: 'Russian' },
{ code: 'en', name: 'English' },
{ code: 'de', name: 'German' },
{ code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' },
{ code: 'it', name: 'Italian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'ar', name: 'Arabic' },
{ code: 'tr', name: 'Turkish' },
{ code: 'nl', name: 'Dutch' },
{ code: 'pl', name: 'Polish' },
{ code: 'sv', name: 'Swedish' },
{ code: 'da', name: 'Danish' },
{ code: 'fi', name: 'Finnish' },
{ code: 'cs', name: 'Czech' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'ro', name: 'Romanian' },
{ code: 'vi', name: 'Vietnamese' },
{ code: 'th', name: 'Thai' },
{ code: 'he', name: 'Hebrew' },
{ code: 'id', name: 'Indonesian' },
{ code: 'ms', name: 'Malay' },
];
const LANGUAGE_LABELS = {
ru: 'Русский',
en: 'English',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
it: 'Italiano',
pt: 'Português',
zh: '中文',
ja: '日本語',
ko: '한국어',
ar: 'العربية',
tr: 'Türkçe',
nl: 'Nederlands',
pl: 'Polski',
sv: 'Svenska',
da: 'Dansk',
fi: 'Suomi',
cs: 'Čeština',
hu: 'Magyar',
ro: 'Română',
vi: 'Tiếng Việt',
th: 'ไทย',
he: 'עברית',
id: 'Bahasa Indonesia',
ms: 'Bahasa Melayu',
};
const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name }));
/** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */
let uxState = $state('idle');
@@ -147,7 +149,7 @@
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' },
];
if (!isNewJob && existingJob) {
base.push({ id: 'schedule', label: 'Schedule' });
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' });
}
return base;
});
@@ -160,6 +162,12 @@
let isSaving = $state(false);
let validationErrors = $state({});
let warnings = $state([]);
let pageTitle = $derived.by(() => {
if (isNewJob) return $t.translate?.config?.new_title || 'New Translation Job';
const jobName = existingJob?.name || name;
const base = $t.translate?.config?.edit_title || 'Edit Translation Job';
return jobName ? `${jobName} | ${base}` : base;
});
// Run state — using global store to survive page navigation
let runState = fromStore(translationRunStore);
@@ -272,8 +280,8 @@
// Load dictionaries (from available translate dictionaries)
try {
const dicts = await api.fetchApi('/translate/dictionaries').catch(() => []);
availableDictionaries = Array.isArray(dicts) ? dicts : [];
const result = await api.fetchApi('/translate/dictionaries').catch(() => ({}));
availableDictionaries = Array.isArray(result) ? result : (result?.items || []);
} catch (_e) {
availableDictionaries = [];
}
@@ -610,8 +618,25 @@
function isVirtual(col) {
return virtualColumnNames.includes(col);
}
function getJobStatusLabel(value) {
const labels = {
DRAFT: $t.translate?.config?.status_draft || 'Draft',
READY: $t.translate?.config?.status_ready || 'Ready',
ACTIVE: $t.translate?.config?.status_active || 'Active',
RUNNING: $t.translate?.jobs?.status_running || 'Running',
COMPLETED: $t.translate?.jobs?.status_completed || 'Completed',
FAILED: $t.translate?.jobs?.status_failed || 'Failed',
CANCELLED: $t.translate?.history?.status_cancelled || 'Cancelled',
};
return labels[value] || value || ($t.translate?.config?.status_draft || 'Draft');
}
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<div class="container mx-auto px-4 py-6 max-w-full xl:max-w-7xl">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
@@ -1031,8 +1056,8 @@
<!-- Target Column Mapping -->
<div class="mt-4 pt-4 border-t border-gray-100">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Target Column Mapping</h3>
<p class="text-xs text-gray-400 mb-3">Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.</p>
<h3 class="text-sm font-semibold text-gray-900 mb-3">{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}</h3>
<p class="text-xs text-gray-400 mb-3">{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
@@ -1105,11 +1130,11 @@
options={LANGUAGES}
bind:selected={targetLanguages}
searchable={true}
placeholder="Search languages..."
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
required={true}
/>
{#if targetLanguages.length === 0}
<p class="text-xs text-amber-600 mt-1">Select at least one target language</p>
<p class="text-xs text-amber-600 mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
{/if}
</div>
<div>
@@ -1225,7 +1250,7 @@
</section>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
</div>
{/if}
</div>
@@ -1242,10 +1267,11 @@
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{status === 'READY' ? 'bg-green-100 text-green-700' : ''}
{status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700' : ''}
{status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : ''}
{status === 'RUNNING' ? 'bg-blue-100 text-blue-700' : ''}
{status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
{status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}">
{status || 'DRAFT'}
{getJobStatusLabel(status)}
</span>
{#if status === 'DRAFT'}
<button
@@ -1260,7 +1286,7 @@
}}
class="ml-auto px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Mark as READY
{$t.translate?.config?.mark_ready || 'Mark as READY'}
</button>
{/if}
</div>
@@ -1350,7 +1376,7 @@
</section>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
</div>
{/if}
</div>