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.
424 lines
16 KiB
Svelte
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 -->
|