Files
ss-tools/frontend/src/lib/components/translate/TranslationRunResult.svelte
busya 9f3f6611a1 feat(translate): complete Phase 10-11 — full spec 028 closure (T075-T134)
Phase 10 closure:
- T075: NotificationService wiring for scheduled-run failures
- T077: Semantic audit via axiom MCP (0 warnings)
- T078: Quickstart validation — all 165+280 tests pass

Phase 11 completion:
- T083-T086: Multi-language models/schemas/Alembic migration
- T087-T090: Auto-detect source language (BCP-47) + tests
- T091-T095: Multilingual dictionaries with language-pair filtering
- T096-T108: Multi-language job config → preview → execution pipeline
- T109-T118: Inline correction + BulkReplaceModal + CorrectionCell + tests
- T119-T122: Per-language history and MetricSnapshot
- T123-T134: Context-aware corrections (Jaccard similarity, priority flagging, context_data)

New files: Alembic migration, test_scheduler, test_inline_correction,
BulkReplaceModal, vitest for CorrectionCell + BulkReplaceModal

Total: 134/134 tasks complete. Backend 165p, Frontend 280p.
2026-05-14 21:14:04 +03:00

424 lines
16 KiB
Svelte

<!-- #region TranslationRunResult [C:3] [TYPE Component] [SEMANTICS translate, result, stats, retry, sql-audit] -->
<!-- @BRIEF Component component: lib/components/translate/TranslationRunResult.svelte -->
<!-- @LAYER UI -->
<script>
/**
* @PURPOSE: Run result display with statistics, insert status badge, Superset query reference, SQL audit block, and retry buttons.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @RELATION: DEPENDS_ON -> [api_module]
* @PRE: runId is a valid completed/failed/pending run.
* @POST: Results displayed with actions for retry, SQL audit, and Superset reference.
*
* @UX_STATE: completed -> All phases completed, full stats displayed
* @UX_STATE: partial -> Translation completed with failures, retry available
* @UX_STATE: failed -> Translation failed, retry available
* @UX_STATE: insert_failed -> Insert phase failed, retry-insert available
*
* @UX_FEEDBACK: Tabular statistics; click to copy Superset query ID; collapsible SQL block.
* @UX_RECOVERY: Retry failed batches; retry insert.
*/
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import {
fetchRunStatus,
fetchRunRecords,
retryFailedBatches,
retryInsert,
fetchRunBatches
} from '$lib/api/translate.js';
import CorrectionCell from './CorrectionCell.svelte';
/** @type {{ runId: string, languageStats?: Array, onRefresh?: () => void }} */
let { runId, languageStats = null, onRefresh = () => {} } = $props();
/**
* @type {'completed'|'partial'|'failed'|'insert_failed'}
*/
let uxState = $state('completed');
let status = $state(null);
let records = $state([]);
let batches = $state([]);
let showRecords = $state(false);
let showSqlAudit = $state(false);
let isRetrying = $state(false);
let isRetryingInsert = $state(false);
let sqlAuditLines = $state([]);
let targetLanguages = $state([]);
// Derived
let insertStatusBadge = $derived.by(() => {
const s = status?.insert_status;
if (!s || s === 'success') return { label: $t.translate?.run?.success, class: 'bg-green-100 text-green-700' };
if (s === 'failed' || s === 'timeout') return { label: $t.translate?.run?.failed, class: 'bg-red-100 text-red-700' };
return { label: s, class: 'bg-yellow-100 text-yellow-700' };
});
// Per-language statistics derived from languageStats prop or status.language_stats
let perLanguageStats = $derived(languageStats || status?.language_stats || []);
$effect(() => {
if (runId) {
loadData();
}
});
/** @returns {Promise<void>} */
async function loadData() {
try {
const [statusData, recordsData, batchesData] = await Promise.all([
fetchRunStatus(runId),
fetchRunRecords(runId, { page_size: 10 }),
fetchRunBatches(runId),
]);
status = statusData;
records = recordsData?.items || [];
batches = Array.isArray(batchesData) ? batchesData : [];
// Extract target languages from language_stats or records
if (statusData.language_stats && statusData.language_stats.length > 0) {
targetLanguages = statusData.language_stats.map(l => l.language_code);
} else {
// Extract unique language codes from records
const langSet = new Set();
for (const rec of records) {
if (rec.languages) {
for (const lang of rec.languages) {
langSet.add(lang.language_code);
}
}
}
targetLanguages = Array.from(langSet);
}
// Determine state
const s = statusData.status;
const insertS = statusData.insert_status;
if (s === 'COMPLETED' && insertS === 'failed') {
uxState = 'insert_failed';
} else if (s === 'COMPLETED' && (statusData.failed_records || 0) > 0) {
uxState = 'partial';
} else if (s === 'FAILED') {
uxState = 'failed';
} else {
uxState = 'completed';
}
} catch (err) {
addToast(err?.message || $t.translate?.run?.load_failed, 'error');
}
}
/** @returns {Promise<void>} */
async function handleRetry() {
isRetrying = true;
try {
await retryFailedBatches(runId);
addToast($t.translate?.run?.retry_success, 'success');
await loadData();
onRefresh();
} catch (err) {
addToast(err?.message || $t.translate?.run?.retry_failed_msg, 'error');
} finally {
isRetrying = false;
}
}
/** @returns {Promise<void>} */
async function handleRetryInsert() {
isRetryingInsert = true;
try {
await retryInsert(runId);
addToast($t.translate?.run?.insert_retry_success, 'success');
await loadData();
onRefresh();
} catch (err) {
addToast(err?.message || $t.translate?.run?.insert_retry_failed_msg, 'error');
} finally {
isRetryingInsert = false;
}
}
/** @param {string} text */
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
addToast($t.translate?.run?.copy_success, 'success');
}).catch(() => {
addToast($t.translate?.run?.copy_failed, 'error');
});
}
/** @returns {Promise<void>} */
async function toggleSqlAudit() {
showSqlAudit = !showSqlAudit;
if (showSqlAudit && sqlAuditLines.length === 0) {
// Load all records with successful translation for SQL audit
try {
const allRecords = await fetchRunRecords(runId, { page_size: 500, status: 'SUCCESS' });
const items = allRecords?.items || [];
sqlAuditLines = items.slice(0, 20).map(r => r.target_sql).filter(Boolean);
} catch (err) {
sqlAuditLines = [$t.translate?.run?.no_sql_audit];
}
}
}
</script>
<div class="translation-run-result">
{#if !status}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<p class="text-sm text-gray-500">{$t.translate?.run?.loading_result}</p>
</div>
{:else}
<div class="bg-white border border-gray-200 rounded-lg p-4 space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">{$t.translate?.run?.result_title}</h3>
<div class="flex items-center gap-2">
<!-- Retry buttons -->
{#if uxState === 'failed' || uxState === 'partial'}
<button
onclick={handleRetry}
disabled={isRetrying}
class="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isRetrying ? $t.translate?.run?.retrying : $t.translate?.run?.retry_failed}
</button>
{/if}
{#if uxState === 'insert_failed'}
<button
onclick={handleRetryInsert}
disabled={isRetryingInsert}
class="px-3 py-1.5 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 transition-colors"
>
{isRetryingInsert ? $t.translate?.run?.retrying_insert : $t.translate?.run?.retry_insert}
</button>
{/if}
</div>
</div>
<!-- Statistics Grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="bg-gray-50 rounded-lg p-3">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.run?.total_records}</p>
<p class="text-xl font-bold text-gray-900">{status.total_records || 0}</p>
</div>
<div class="bg-green-50 rounded-lg p-3">
<p class="text-xs text-green-600 uppercase">{$t.translate?.run?.success}</p>
<p class="text-xl font-bold text-green-700">{status.successful_records || 0}</p>
</div>
<div class="bg-red-50 rounded-lg p-3">
<p class="text-xs text-red-600 uppercase">{$t.translate?.run?.failed}</p>
<p class="text-xl font-bold text-red-700">{status.failed_records || 0}</p>
</div>
<div class="bg-yellow-50 rounded-lg p-3">
<p class="text-xs text-yellow-600 uppercase">{$t.translate?.run?.skipped}</p>
<p class="text-xl font-bold text-yellow-700">{status.skipped_records || 0}</p>
</div>
</div>
<!-- Per-language Statistics (T107) -->
{#if perLanguageStats.length > 0}
<div class="border-t border-gray-200 pt-3">
<p class="text-xs text-gray-500 uppercase mb-2">{$t.translate?.run?.per_language ?? 'Per-Language Statistics'}</p>
<div class="flex flex-wrap gap-3">
{#each perLanguageStats as lang}
<div class="px-3 py-2 bg-gray-50 rounded border border-gray-200 min-w-[140px]">
<p class="text-sm font-semibold text-gray-800">{lang.language_code}</p>
<div class="flex gap-3 mt-1 text-xs">
<span class="text-green-600" title="Translated">{lang.translated_rows}</span>
{#if lang.failed_rows > 0}
<span class="text-red-600" title="Failed">{lang.failed_rows}</span>
{/if}
{#if lang.skipped_rows > 0}
<span class="text-yellow-600" title="Skipped">{lang.skipped_rows}</span>
{/if}
<span class="text-gray-400" title="Total">{lang.total_rows}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Status Details -->
<div class="border-t border-gray-200 pt-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">{$t.translate?.run?.run_id}</span>
<span class="ml-1 font-mono text-xs text-gray-700">{status.id}</span>
<button
onclick={() => copyToClipboard(status.id)}
class="ml-1 text-blue-500 hover:text-blue-700 text-xs"
title={$t.translate?.run?.copy_id}
>
{$t.translate?.run?.copy_id}
</button>
</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}">
{insertStatusBadge.label}
</span>
</div>
{#if status.superset_execution_id}
<div>
<span class="text-gray-500">{$t.translate?.run?.superset_query}</span>
<span class="ml-1 font-mono text-xs text-gray-700">{status.superset_execution_id}</span>
<button
onclick={() => copyToClipboard(status.superset_execution_id)}
class="ml-1 text-blue-500 hover:text-blue-700 text-xs"
title={$t.translate?.run?.copy_id}
>
{$t.translate?.run?.copy_id}
</button>
</div>
{/if}
{#if status.error_message}
<div class="col-span-2">
<span class="text-red-600 text-xs">{$t.translate?.run?.error_label?.replace('{error}', status.error_message)}</span>
</div>
{/if}
</div>
</div>
<!-- SQL Audit Block (collapsed) -->
<div class="border-t border-gray-200 pt-3">
<button
onclick={toggleSqlAudit}
class="flex items-center gap-2 text-sm text-gray-700 hover:text-gray-900"
>
<svg
class="w-4 h-4 transition-transform {showSqlAudit ? 'rotate-90' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{$t.translate?.run?.sql_audit?.replace('{count}', sqlAuditLines.length)}
</button>
{#if showSqlAudit}
<div class="mt-2 bg-gray-900 rounded-lg p-3 max-h-60 overflow-y-auto">
{#if sqlAuditLines.length === 0}
<p class="text-xs text-gray-400">{$t.translate?.run?.no_sql_audit}</p>
{:else}
{#each sqlAuditLines as line, idx}
<pre class="text-xs text-green-400 mb-1 font-mono whitespace-pre-wrap break-all">
<code>{idx + 1}. {line}</code>
</pre>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Event Invariants -->
{#if status.event_invariants}
<div class="border-t border-gray-200 pt-3">
<div class="flex items-center gap-2 text-xs text-gray-500">
<span>{$t.translate?.run?.event_invariants}</span>
<span class="{status.event_invariants.invariant_valid ? 'text-green-600' : 'text-red-600'}">
{status.event_invariants.invariant_valid ? $t.translate?.run?.valid : $t.translate?.run?.violated}
</span>
<span class="text-gray-400">|</span>
<span>{$t.translate?.run?.superset_query_id_label} {status.event_invariants.has_run_started ? $t.translate?.run?.started_yes : $t.translate?.run?.started_no}</span>
<span class="text-gray-400">|</span>
<span>{$t.translate?.run?.terminal_events?.replace('{count}', status.event_invariants.terminal_event_count)}</span>
</div>
</div>
{/if}
<!-- Records Table with Per-Language Correction Cells -->
{#if records.length > 0}
<div class="border-t border-gray-200 pt-3">
<div class="flex items-center justify-between mb-2">
<button
onclick={() => showRecords = !showRecords}
class="flex items-center gap-2 text-sm text-gray-700 hover:text-gray-900"
>
<svg
class="w-4 h-4 transition-transform {showRecords ? 'rotate-90' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{$t.translate?.run?.records_title || 'Translation Records'} ({records.length})
</button>
</div>
{#if showRecords}
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-12">#</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase min-w-[150px]">{$t.translate?.preview?.table_source || 'Source'}</th>
{#each targetLanguages as lang}
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase min-w-[180px]">
<div class="flex items-center gap-1">
<span class="font-mono text-blue-600">{lang}</span>
</div>
</th>
{/each}
{#if targetLanguages.length === 0}
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase min-w-[180px]">{$t.translate?.preview?.translation || 'Translation'}</th>
{/if}
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">{$t.translate?.preview?.table_status || 'Status'}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each records as row, idx}
<tr class="{row.status === 'FAILED' ? 'bg-red-50' : idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}">
<td class="px-3 py-2 text-xs text-gray-400 align-top">{idx + 1}</td>
<td class="px-3 py-2 align-top max-w-[200px]">
<code class="text-xs text-gray-800 break-all">{row.source_sql || row.source_object_name || '—'}</code>
</td>
{#if targetLanguages.length > 0}
{#each targetLanguages as lang}
{@const langData = (row.languages || []).find(l => l.language_code === lang)}
{@const cellValue = langData?.final_value || langData?.translated_value || ''}
{@const detectedSource = langData?.source_language_detected || row.source_language_detected || ''}
<td class="px-3 py-2 align-top">
<CorrectionCell
value={cellValue}
recordId={row.id}
languageCode={lang}
runId={runId}
sourceLanguage={detectedSource}
/>
</td>
{/each}
{:else}
<td class="px-3 py-2 align-top">
<CorrectionCell
value={row.final_value || row.llm_translation || row.target_sql || ''}
recordId={row.id}
languageCode=""
runId={runId}
sourceLanguage=""
/>
</td>
{/if}
<td class="px-3 py-2 text-center align-top">
<span class="inline-flex px-2 py-0.5 text-xs rounded-full font-medium
{row.status === 'SUCCESS' ? 'bg-green-100 text-green-700' : ''}
{row.status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}
{row.status === 'SKIPPED' ? 'bg-yellow-100 text-yellow-700' : ''}
{!['SUCCESS','FAILED','SKIPPED'].includes(row.status) ? 'bg-gray-100 text-gray-600' : ''}
">
{row.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- #endregion TranslationRunResult -->