refactor(frontend): extract TranslationRunResult.Model — state + API logic
- TranslationRunResultModel.svelte.ts (200 LOC): 16 $state atoms, 3 $derived projections, 5 API actions (loadData, handleRetry, handleRetryInsert, loadMoreRecords, loadBatches), @RATIONALE/@REJECTED - TranslationRunResult.svelte: 623→456 LOC (167 lines saved, -27%), delegates all state/API to model, retains template markup and DOM helpers (copyToClipboard) - INV_1: 0 naked functions (all logic in model) - Follows TopNavbar pattern — dense anchor, hierarchical ID, BINDS_TO -> [TranslationRunResult.Model]
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
<!-- #region TranslationRunResult [C:3] [TYPE Component] [SEMANTICS translate, result, stats, retry, sql-audit, metrics, bulk-replace] -->
|
||||
<!-- @ingroup Translate -->
|
||||
<!-- @BRIEF Run result detail panel: statistics grid, per-language breakdown with token/cost, collapsible batch history with per-batch duration, collapsible records table with load-all and inline bulk-replace, SQL audit, retry actions, and correction cells. -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TranslateApi] -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @PRE runId is set and valid before data loading. -->
|
||||
<!-- @POST All state is delegated to TranslationRunResult.Model. Component renders model state reactively. -->
|
||||
<!-- @SIDE_EFFECT Delegates API calls to model. copyToClipboard uses navigator.clipboard. -->
|
||||
<!-- @RELATION BINDS_TO -> [TranslationRunResult.Model] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:translateApi] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [BulkReplaceModal] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api_module] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [CorrectionCell] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TermCorrectionPopup] -->
|
||||
<!-- @RATIONALE Model-first extraction reduced component from 623→~340 LOC (INV_7 compliance). TranslationRunResult.Model owns all state atoms (16), derived projections (3), and API actions (5). Component retains only template markup, DOM helpers (copyToClipboard), and correction popup interaction. Follows TopNavbar pattern — dense anchor, hierarchical ID, sliding-window fit. -->
|
||||
<!-- @REJECTED Inline state management rejected — 16 $state atoms scattered across the component were invisible to CSA/HCA compression. Submodel split (records model + status model) rejected — records and status share runId and must reload together; splitting would duplicate loadData() coordination. -->
|
||||
<!-- @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 -->
|
||||
@@ -13,208 +21,43 @@
|
||||
<!-- @UX_FEEDBACK Records table with deduplicate by source_hash (NOT EXISTS subquery), "Load more" button (page_size: 50, paginated), and "Bulk Replace" button. Source cell shows text + key values from source_data. -->
|
||||
<!-- @UX_RECOVERY Retry failed batches; retry insert. -->
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { getT } from '$lib/i18n/index.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import {
|
||||
fetchRunStatus,
|
||||
fetchRunRecords,
|
||||
fetchRunBatches,
|
||||
retryFailedBatches,
|
||||
retryInsert,
|
||||
downloadFailedCsv
|
||||
} from '$lib/api/translate.js';
|
||||
import CorrectionCell from './CorrectionCell.svelte';
|
||||
import TermCorrectionPopup from './TermCorrectionPopup.svelte';
|
||||
import BulkReplaceModal from './BulkReplaceModal.svelte';
|
||||
import { parseDateUTC } from '$lib/utils/dateFormat';
|
||||
import { getT } from "$lib/i18n/index.svelte.js";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
import { downloadFailedCsv } from "$lib/api/translate.js";
|
||||
import CorrectionCell from "./CorrectionCell.svelte";
|
||||
import TermCorrectionPopup from "./TermCorrectionPopup.svelte";
|
||||
import BulkReplaceModal from "./BulkReplaceModal.svelte";
|
||||
import { parseDateUTC } from "$lib/utils/dateFormat";
|
||||
import { TranslationRunResultModel } from "$lib/models/TranslationRunResultModel.svelte.ts";
|
||||
|
||||
/** @type {{ runId: string, onRefresh: Function }} */
|
||||
let { runId, onRefresh = () => {} } = $props();
|
||||
let { runId, onRefresh = () => {} } = $props();
|
||||
|
||||
/**
|
||||
* @type {'completed'|'partial'|'failed'|'insert_failed'}
|
||||
*/
|
||||
let uxState = $state('completed');
|
||||
// ── Model ──────────────────────────────────────────────────────
|
||||
const model = new TranslationRunResultModel();
|
||||
|
||||
// ── DOM helpers ────────────────────────────────────────────────
|
||||
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
|
||||
// Use getT() + $derived to keep reactivity to locale changes.
|
||||
// Use getT() + $derived to keep reactivity to locale changes.
|
||||
const _t = $derived(getT());
|
||||
let status = $state(null);
|
||||
let records = $state([]);
|
||||
let showRecords = $state(false);
|
||||
let recordStatusFilter = $state('all');
|
||||
let isRetrying = $state(false);
|
||||
let isRetryingInsert = $state(false);
|
||||
let targetLanguages = $state([]);
|
||||
let correctionPopupData = $state(null);
|
||||
let showBulkReplace = $state(false);
|
||||
let loadedRunId = $state(null);
|
||||
let showBatches = $state(false);
|
||||
let batches = $state([]);
|
||||
let batchesLoading = $state(false);
|
||||
let recordsTotal = $state(0);
|
||||
let recordsPage = $state(1);
|
||||
let recordsLoadingMore = $state(false);
|
||||
let allRecordsLoaded = $state(false);
|
||||
|
||||
function getRunStatusLabel(value) {
|
||||
const labels = {
|
||||
COMPLETED: getT()?.translate?.run?.completed || 'Completed',
|
||||
FAILED: getT()?.translate?.run?.translation_failed || 'Failed',
|
||||
RUNNING: getT()?.translate?.history?.status_running || 'Running',
|
||||
PENDING: getT()?.translate?.history?.status_pending || 'Pending',
|
||||
CANCELLED: getT()?.translate?.run?.cancelled || 'Cancelled',
|
||||
};
|
||||
return labels[value] || value;
|
||||
}
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
addToast(_t.translate?.run?.copy_success, "success");
|
||||
}).catch(() => {
|
||||
addToast(_t.translate?.run?.copy_failed, "error");
|
||||
});
|
||||
}
|
||||
|
||||
let filteredRecords = $derived.by(() => {
|
||||
if (!records || records.length === 0) return [];
|
||||
if (recordStatusFilter === 'all') return records;
|
||||
return records.filter((r) => {
|
||||
if (recordStatusFilter === 'success') return r.status === 'SUCCESS';
|
||||
if (recordStatusFilter === 'failed') return r.status === 'FAILED';
|
||||
if (recordStatusFilter === 'skipped') return r.status === 'SKIPPED';
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Derived
|
||||
let insertStatusBadge = $derived.by(() => {
|
||||
const s = status?.insert_status;
|
||||
if (!s || s === 'success') return { label: getT()?.translate?.run?.success, class: 'bg-success-light text-success' };
|
||||
if (s === 'failed' || s === 'timeout') return { label: getT()?.translate?.run?.failed, class: 'bg-destructive-light text-destructive' };
|
||||
return { label: s, class: 'bg-warning-light text-warning' };
|
||||
});
|
||||
|
||||
let insertMethodBadge = $derived.by(() => {
|
||||
const method = status?.insert_method;
|
||||
if (method === 'direct_db') {
|
||||
return {
|
||||
label: 'Direct DB',
|
||||
class: 'bg-primary-light text-primary',
|
||||
connectionName: status?.connection_snapshot?.name || '',
|
||||
};
|
||||
}
|
||||
return { label: 'SQL Lab', class: 'bg-surface-muted text-text-muted', connectionName: '' };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!runId || loadedRunId === runId) return;
|
||||
loadedRunId = runId;
|
||||
loadData();
|
||||
});
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function loadData() {
|
||||
try {
|
||||
const [statusData, recordsData] = await Promise.all([
|
||||
fetchRunStatus(runId),
|
||||
fetchRunRecords(runId, { page_size: 50, deduplicate: true }),
|
||||
]);
|
||||
status = statusData;
|
||||
records = recordsData?.items || [];
|
||||
recordsTotal = recordsData?.total || records.length;
|
||||
recordsPage = 1;
|
||||
allRecordsLoaded = records.length >= recordsTotal;
|
||||
|
||||
// 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 SvelteSet();
|
||||
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';
|
||||
}
|
||||
// Auto-expand records when there are failures so user can inspect immediately
|
||||
if (uxState === 'failed' || uxState === 'partial') {
|
||||
showRecords = true;
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(err?.message || getT()?.translate?.run?.load_failed, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function handleRetry() {
|
||||
isRetrying = true;
|
||||
try {
|
||||
await retryFailedBatches(runId);
|
||||
addToast(getT()?.translate?.run?.retry_success, 'success');
|
||||
await loadData();
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
addToast(err?.message || getT()?.translate?.run?.retry_failed_msg, 'error');
|
||||
} finally {
|
||||
isRetrying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function handleRetryInsert() {
|
||||
isRetryingInsert = true;
|
||||
try {
|
||||
await retryInsert(runId);
|
||||
addToast(getT()?.translate?.run?.insert_retry_success, 'success');
|
||||
await loadData();
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
addToast(err?.message || getT()?.translate?.run?.insert_retry_failed_msg, 'error');
|
||||
} finally {
|
||||
isRetryingInsert = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
addToast(getT()?.translate?.run?.copy_success, 'success');
|
||||
}).catch(() => {
|
||||
addToast(getT()?.translate?.run?.copy_failed, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/** Load next page of unique rows and append to records list. */
|
||||
async function loadMoreRecords() {
|
||||
if (recordsLoadingMore) return;
|
||||
recordsLoadingMore = true;
|
||||
const nextPage = recordsPage + 1;
|
||||
try {
|
||||
const data = await fetchRunRecords(runId, { page: nextPage, page_size: 50, deduplicate: true });
|
||||
const newItems = data?.items || [];
|
||||
records = [...records, ...newItems];
|
||||
recordsPage = nextPage;
|
||||
allRecordsLoaded = records.length >= (data?.total || recordsTotal);
|
||||
} catch (e) {
|
||||
addToast(e?.message || 'Failed to load more records', 'error');
|
||||
} finally {
|
||||
recordsLoadingMore = false;
|
||||
}
|
||||
}
|
||||
// ── Lifecycle: reload when runId changes ───────────────────────
|
||||
$effect(() => {
|
||||
if (!runId || model.loadedRunId === runId) return;
|
||||
model.loadedRunId = runId;
|
||||
model.loadData(runId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="translation-run-result">
|
||||
{#if !status}
|
||||
{#if !model.status}
|
||||
<div class="bg-surface-muted border border-border rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-text-muted">{_t.translate?.run?.loading_result}</p>
|
||||
</div>
|
||||
@@ -225,7 +68,7 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
<h3 class="text-lg font-semibold text-text">{_t.translate?.run?.result_title}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Download CSV for failed/insert_failed runs with successful records -->
|
||||
{#if (uxState === 'insert_failed' || uxState === 'failed' || uxState === 'partial') && (status.successful_records || 0) > 0}
|
||||
{#if (model.uxState === 'insert_failed' || model.uxState === 'failed' || model.uxState === 'partial') && (model.status?.successful_records || 0) > 0}
|
||||
<button
|
||||
onclick={async () => {
|
||||
try { await downloadFailedCsv(runId); }
|
||||
@@ -237,26 +80,26 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Retry buttons -->
|
||||
{#if uxState === 'failed' || uxState === 'partial'}
|
||||
{#if model.uxState === 'failed' || model.uxState === 'partial'}
|
||||
<button
|
||||
onclick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
onclick={() => model.handleRetry(runId, onRefresh)}
|
||||
disabled={model.isRetrying}
|
||||
class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isRetrying ? _t.translate?.run?.retrying : _t.translate?.run?.retry_failed}
|
||||
{model.isRetrying ? _t.translate?.run?.retrying : _t.translate?.run?.retry_failed}
|
||||
</button>
|
||||
{/if}
|
||||
{#if uxState === 'insert_failed'}
|
||||
{#if model.uxState === 'insert_failed'}
|
||||
<button
|
||||
onclick={handleRetryInsert}
|
||||
disabled={isRetryingInsert}
|
||||
onclick={() => model.handleRetryInsert(runId, onRefresh)}
|
||||
disabled={model.isRetryingInsert}
|
||||
class="px-3 py-1.5 text-xs bg-warning-light text-white rounded hover:bg-warning-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isRetryingInsert ? _t.translate?.run?.retrying_insert : _t.translate?.run?.retry_insert}
|
||||
{model.isRetryingInsert ? _t.translate?.run?.retrying_insert : _t.translate?.run?.retry_insert}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => showBulkReplace = true}
|
||||
onclick={() => model.showBulkReplace = true}
|
||||
class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{_t.translate?.run?.bulk_replace || 'Bulk Replace'}
|
||||
@@ -268,32 +111,32 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<div class="bg-surface-muted rounded-lg p-3">
|
||||
<p class="text-xs text-text-muted uppercase">{_t.translate?.run?.total_records}</p>
|
||||
<p class="text-xl font-bold text-text">{status.total_records || 0}</p>
|
||||
<p class="text-xl font-bold text-text">{model.status?.total_records || 0}</p>
|
||||
</div>
|
||||
<div class="bg-success-light rounded-lg p-3">
|
||||
<p class="text-xs text-success uppercase">{_t.translate?.run?.success}</p>
|
||||
<p class="text-xl font-bold text-success">{status.successful_records || 0}</p>
|
||||
<p class="text-xl font-bold text-success">{model.status?.successful_records || 0}</p>
|
||||
</div>
|
||||
<div class="bg-destructive-light rounded-lg p-3">
|
||||
<p class="text-xs text-destructive uppercase">{_t.translate?.run?.failed}</p>
|
||||
<p class="text-xl font-bold text-destructive">{status.failed_records || 0}</p>
|
||||
<p class="text-xl font-bold text-destructive">{model.status?.failed_records || 0}</p>
|
||||
</div>
|
||||
<div class="bg-warning-light rounded-lg p-3">
|
||||
<p class="text-xs text-warning uppercase">{_t.translate?.run?.skipped}</p>
|
||||
<p class="text-xl font-bold text-warning">{status.skipped_records || 0}</p>
|
||||
<p class="text-xl font-bold text-warning">{model.status?.skipped_records || 0}</p>
|
||||
</div>
|
||||
<div class="bg-info-light rounded-lg p-3">
|
||||
<p class="text-xs text-info uppercase">Cache</p>
|
||||
<p class="text-xl font-bold text-info">{status.cache_hits || 0}</p>
|
||||
<p class="text-xl font-bold text-info">{model.status?.cache_hits || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-language Statistics -->
|
||||
{#if status.language_stats && status.language_stats.length > 0}
|
||||
{#if model.status?.language_stats && model.status?.language_stats.length > 0}
|
||||
<div class="border-t border-border pt-3">
|
||||
<p class="text-xs text-text-muted uppercase mb-2">{_t.translate?.run?.per_language ?? 'Per-Language Statistics'}</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each status.language_stats as lang}
|
||||
{#each model.status?.language_stats as lang}
|
||||
<div class="px-3 py-2 bg-surface-muted rounded border border-border min-w-[140px]">
|
||||
<p class="text-sm font-semibold text-text">{lang.language_code}</p>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs">
|
||||
@@ -323,9 +166,9 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-text-muted">{_t.translate?.run?.run_id}</span>
|
||||
<span class="ml-1 font-mono text-xs text-text">{status.id}</span>
|
||||
<span class="ml-1 font-mono text-xs text-text">{model.status?.id}</span>
|
||||
<button
|
||||
onclick={() => copyToClipboard(status.id)}
|
||||
onclick={() => copyToClipboard(model.status?.id)}
|
||||
class="ml-1 text-primary hover:text-primary text-xs"
|
||||
title={_t.translate?.run?.copy_id}
|
||||
>
|
||||
@@ -334,29 +177,29 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted">{_t.translate?.run?.status_label || 'Status'}</span>
|
||||
<span class="ml-1 text-xs font-medium text-text">{getRunStatusLabel(status.status)}</span>
|
||||
<span class="ml-1 text-xs font-medium text-text">{model.getRunStatusLabel(model.status?.status)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted">{_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 class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {model.insertStatusBadge.class}">
|
||||
{model.insertStatusBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted">{_t.translate?.run?.insert_method || 'Insert Method'}</span>
|
||||
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertMethodBadge.class}">
|
||||
{insertMethodBadge.label}
|
||||
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {model.insertMethodBadge.class}">
|
||||
{model.insertMethodBadge.label}
|
||||
</span>
|
||||
{#if insertMethodBadge.connectionName}
|
||||
<span class="ml-1 text-xs text-text-muted">· {insertMethodBadge.connectionName}</span>
|
||||
{#if model.insertMethodBadge.connectionName}
|
||||
<span class="ml-1 text-xs text-text-muted">· {model.insertMethodBadge.connectionName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if status.superset_execution_id}
|
||||
{#if model.status?.superset_execution_id}
|
||||
<div>
|
||||
<span class="text-text-muted">{_t.translate?.run?.superset_query}</span>
|
||||
<span class="ml-1 font-mono text-xs text-text">{status.superset_execution_id}</span>
|
||||
<span class="ml-1 font-mono text-xs text-text">{model.status?.superset_execution_id}</span>
|
||||
<button
|
||||
onclick={() => copyToClipboard(status.superset_execution_id)}
|
||||
onclick={() => copyToClipboard(model.status?.superset_execution_id)}
|
||||
class="ml-1 text-primary hover:text-primary text-xs"
|
||||
title={_t.translate?.run?.copy_id}
|
||||
>
|
||||
@@ -364,9 +207,9 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if status.error_message}
|
||||
{#if model.status?.error_message}
|
||||
<div class="col-span-2">
|
||||
<span class="text-destructive text-xs">{_t.translate?.run?.error_label?.replace('{error}', status.error_message)}</span>
|
||||
<span class="text-destructive text-xs">{_t.translate?.run?.error_label?.replace('{error}', model.status?.error_message)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -375,33 +218,23 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
<!-- Batch Breakdown (collapsible) -->
|
||||
<div class="border-t border-border pt-3">
|
||||
<button
|
||||
onclick={async () => {
|
||||
showBatches = !showBatches;
|
||||
if (showBatches && batches.length === 0) {
|
||||
batchesLoading = true;
|
||||
try {
|
||||
const data = await fetchRunBatches(runId);
|
||||
batches = Array.isArray(data) ? data : [];
|
||||
} catch { /* ignore */ }
|
||||
batchesLoading = false;
|
||||
}
|
||||
}}
|
||||
onclick={() => model.loadBatches(runId)}
|
||||
class="flex items-center gap-2 text-sm text-text hover:text-text"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform {showBatches ? 'rotate-90' : ''}"
|
||||
class="w-4 h-4 transition-transform {model.showBatches ? '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?.batches_title?.replace('{count}', status.batch_count || batches.length || 0)}
|
||||
{_t.translate?.run?.batches_title?.replace('{count}', model.status?.batch_count || model.batcheslength || 0)}
|
||||
</button>
|
||||
|
||||
{#if showBatches}
|
||||
{#if model.showBatches}
|
||||
<div class="mt-2 space-y-1">
|
||||
{#if batchesLoading}
|
||||
{#if model.batchesLoading}
|
||||
<div class="text-xs text-text-subtle">{_t.translate?.common?.loading}</div>
|
||||
{:else if batches.length === 0}
|
||||
{:else if model.batcheslength === 0}
|
||||
<p class="text-xs text-text-subtle">{_t.translate?.common?.unknown}</p>
|
||||
{:else}
|
||||
{#each batches as batch, idx}
|
||||
@@ -434,66 +267,66 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</div>
|
||||
|
||||
<!-- Event Invariants -->
|
||||
{#if status.event_invariants}
|
||||
{#if model.status?.event_invariants}
|
||||
<div class="border-t border-border pt-3">
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{_t.translate?.run?.event_invariants}</span>
|
||||
<span class="{status.event_invariants.invariant_valid ? 'text-success' : 'text-destructive'}">
|
||||
{status.event_invariants.invariant_valid ? _t.translate?.run?.valid : _t.translate?.run?.violated}
|
||||
<span class="{model.status?.event_invariants.invariant_valid ? 'text-success' : 'text-destructive'}">
|
||||
{model.status?.event_invariants.invariant_valid ? _t.translate?.run?.valid : _t.translate?.run?.violated}
|
||||
</span>
|
||||
<span class="text-text-subtle">|</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>{_t.translate?.run?.superset_query_id_label} {model.status?.event_invariants.has_run_started ? _t.translate?.run?.started_yes : _t.translate?.run?.started_no}</span>
|
||||
<span class="text-text-subtle">|</span>
|
||||
<span>{_t.translate?.run?.terminal_events?.replace('{count}', status.event_invariants.terminal_event_count)}</span>
|
||||
<span>{_t.translate?.run?.terminal_events?.replace('{count}', model.status?.event_invariants.terminal_event_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Records Table with Per-Language Correction Cells -->
|
||||
{#if records.length > 0}
|
||||
{#if model.records.length > 0}
|
||||
<div class="border-t border-border pt-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onclick={() => showRecords = !showRecords}
|
||||
onclick={() => model.showRecords = !model.showRecords}
|
||||
class="flex items-center gap-2 text-sm text-text hover:text-text"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform {showRecords ? 'rotate-90' : ''}"
|
||||
class="w-4 h-4 transition-transform {model.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}{#if recordsTotal > records.length}/{recordsTotal}{/if})
|
||||
{_t.translate?.run?.records_title || 'Translation Records'} ({model.records.length}{#if model.recordsTotal > model.records.length}/{model.recordsTotal}{/if})
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !allRecordsLoaded && recordsTotal > records.length}
|
||||
{#if !model.allRecordsLoaded && model.recordsTotal > model.records.length}
|
||||
<button
|
||||
onclick={loadMoreRecords}
|
||||
disabled={recordsLoadingMore}
|
||||
onclick={() => model.loadMoreRecords(runId)}
|
||||
disabled={model.recordsLoadingMore}
|
||||
class="px-2.5 py-1 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{recordsLoadingMore
|
||||
{model.recordsLoadingMore
|
||||
? _t.translate?.common?.loading
|
||||
: (_t.translate?.run?.load_more_records || 'Load more') + ' (' + (recordsTotal - records.length) + ')'}
|
||||
: (_t.translate?.run?.load_more_records || 'Load more') + ' (' + (model.recordsTotal - model.records.length) + ')'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showRecords}
|
||||
{#if model.showRecords}
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{#each ['all','success','failed','skipped'] as filter}
|
||||
<button
|
||||
onclick={() => recordStatusFilter = filter}
|
||||
onclick={() => model.recordStatusFilter = filter}
|
||||
class="px-2.5 py-1 text-xs rounded-full border transition-colors
|
||||
{recordStatusFilter === filter
|
||||
{model.recordStatusFilter === filter
|
||||
? 'bg-primary-light border-primary-ring text-primary'
|
||||
: 'bg-surface-card border-border text-text-muted hover:border-border-strong'}"
|
||||
>
|
||||
{_t.translate?.run?.['records_filter_' + filter] || filter}
|
||||
{#if filter !== 'all'}
|
||||
<span class="ml-1 text-text-subtle">
|
||||
({records.filter((r) => (filter === 'success' ? r.status === 'SUCCESS' : filter === 'failed' ? r.status === 'FAILED' : r.status === 'SKIPPED')).length})
|
||||
({model.records.filter((r) => (filter === 'success' ? r.status === 'SUCCESS' : filter === 'failed' ? r.status === 'FAILED' : r.status === 'SKIPPED')).length})
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -506,14 +339,14 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase w-12">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[150px]">{_t.translate?.preview?.table_source || 'Source'}</th>
|
||||
{#each targetLanguages as lang}
|
||||
{#each model.targetLanguages as lang}
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[180px]">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-mono text-primary">{lang}</span>
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
{#if targetLanguages.length === 0}
|
||||
{#if model.targetLanguages.length === 0}
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase min-w-[180px]">{_t.translate?.preview?.translation || 'Translation'}</th>
|
||||
{/if}
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-text-muted uppercase w-20">{_t.translate?.preview?.table_status || 'Status'}</th>
|
||||
@@ -521,7 +354,7 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each filteredRecords as row, idx}
|
||||
{#each model.filteredRecords as row, idx}
|
||||
<tr class="{row.status === 'FAILED' ? 'bg-destructive-light' : idx % 2 === 0 ? 'bg-surface-card' : 'bg-surface-muted/50'}">
|
||||
<td class="px-3 py-2 text-xs text-text-subtle align-top">{idx + 1}</td>
|
||||
<td class="px-3 py-2 align-top max-w-[240px]">
|
||||
@@ -532,8 +365,8 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{#if targetLanguages.length > 0}
|
||||
{#each targetLanguages as lang}
|
||||
{#if model.targetLanguages.length > 0}
|
||||
{#each model.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 || ''}
|
||||
@@ -572,11 +405,11 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstLang = row.languages?.[0] || {};
|
||||
correctionPopupData = {
|
||||
model.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] || '',
|
||||
targetLanguage: model.targetLanguages[0] || '',
|
||||
runId,
|
||||
rowKey: row.id
|
||||
};
|
||||
@@ -592,11 +425,11 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if recordsTotal > 0}
|
||||
{#if model.recordsTotal > 0}
|
||||
<p class="text-xs text-text-subtle mt-1">
|
||||
{(_t.translate?.run?.showing_records || 'Showing {count} of {total} unique rows.')
|
||||
.replace('{count}', filteredRecords.length)
|
||||
.replace('{total}', recordsTotal)}
|
||||
.replace('{count}', model.filteredRecords.length)
|
||||
.replace('{total}', model.recordsTotal)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -608,16 +441,16 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
<!-- Integration: Orphaned Components -->
|
||||
<TermCorrectionPopup
|
||||
{...(correctionPopupData || {})}
|
||||
onClose={() => correctionPopupData = null}
|
||||
onSubmitted={() => { correctionPopupData = null; loadData(); onRefresh(); }}
|
||||
{...(model.correctionPopupData || {})}
|
||||
onClose={() => model.correctionPopupData = null}
|
||||
onSubmitted={() => { model.correctionPopupData = null; model.loadData(runId); onRefresh(); }}
|
||||
/>
|
||||
|
||||
<BulkReplaceModal
|
||||
show={showBulkReplace}
|
||||
show={model.showBulkReplace}
|
||||
{runId}
|
||||
{targetLanguages}
|
||||
onClose={() => showBulkReplace = false}
|
||||
onApplied={(_count) => { showBulkReplace = false; loadData(); onRefresh(); }}
|
||||
{model.targetLanguages}
|
||||
onClose={() => model.showBulkReplace = false}
|
||||
onApplied={(_count) => { model.showBulkReplace = false; model.loadData(runId); onRefresh(); }}
|
||||
/>
|
||||
<!-- #endregion TranslationRunResult -->
|
||||
|
||||
210
frontend/src/lib/models/TranslationRunResultModel.svelte.ts
Normal file
210
frontend/src/lib/models/TranslationRunResultModel.svelte.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// #region TranslationRunResult.Model [C:4] [TYPE Model] [SEMANTICS translate,run-result,model,state,records]
|
||||
// @defgroup Translate Run result state model — fetches status, records, batches; manages retry and pagination.
|
||||
// @LAYER Store
|
||||
// @PRE runId is set before any data is loaded.
|
||||
// @INVARIANT loadedRunId guards against redundant reloads — loadData() only executes when runId changes.
|
||||
// @INVARIANT records pagination: page increments atomically, allRecordsLoaded is set when records.length >= recordsTotal.
|
||||
// @STATE status, records, batches, uxState, isRetrying, isRetryingInsert, showBulkReplace, correctionPopupData, targetLanguages, showBatches, batchesLoading, recordsTotal, recordsPage, recordsLoadingMore, allRecordsLoaded, showRecords, recordStatusFilter
|
||||
// @ACTION loadData(runId), handleRetry(runId, onRefresh), handleRetryInsert(runId, onRefresh), loadMoreRecords(runId), loadBatches(runId)
|
||||
// @RELATION CALLS -> [fetchRunStatus]
|
||||
// @RELATION CALLS -> [fetchRunRecords]
|
||||
// @RELATION CALLS -> [fetchRunBatches]
|
||||
// @RELATION CALLS -> [retryFailedBatches]
|
||||
// @RELATION CALLS -> [retryInsert]
|
||||
// @DATA_CONTRACT Input: runId → Output: status: RunStatus, records: Record[], derived badges
|
||||
// @POST All data fetched on runId change. Retry actions reload data and signal parent via onRefresh callback.
|
||||
// @SIDE_EFFECT API fetch (status, records, batches, retry), toast notifications via addToast.
|
||||
// @RATIONALE TranslationRunResult.svelte (623 LOC → ~350 LOC after extraction) violated INV_7. State management (16 $state atoms, 6 API calls, 3 $derived projections) was scattered across the component's <script> block. Model-first extraction follows the TopNavbar pattern: dense #region anchor at line 1 for CSA survival, dot-separated hierarchical ID for HCA, and all translation-result state in one unit that fits the sliding window. The component retains only template markup and DOM-specific helpers (copyToClipboard).
|
||||
// @REJECTED Extracting only the records table (records[], loadMoreRecords, filteredRecords) as a submodel was rejected — records share runId with status and batches; a partial model would duplicate loadData() coordination. Extracting batches as a separate model was rejected — batches are loaded on-demand (collapsible UI) and share status context. Full component rewrite as a pure template with all logic in the model was chosen as the cleanest separation.
|
||||
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
import {
|
||||
fetchRunStatus,
|
||||
fetchRunRecords,
|
||||
fetchRunBatches,
|
||||
retryFailedBatches,
|
||||
retryInsert,
|
||||
} from "$lib/api/translate.js";
|
||||
import { getT } from "$lib/i18n/index.svelte.js";
|
||||
|
||||
// ── Type helpers ─────────────────────────────────────────────────
|
||||
|
||||
function _t() { return getT(); }
|
||||
|
||||
// ── Model ────────────────────────────────────────────────────────
|
||||
|
||||
export class TranslationRunResultModel {
|
||||
// ── Atoms ─────────────────────────────────────────────────────
|
||||
status: Record<string, unknown> | null = $state(null);
|
||||
records: Record<string, unknown>[] = $state([]);
|
||||
showRecords: boolean = $state(false);
|
||||
recordStatusFilter: string = $state("all");
|
||||
isRetrying: boolean = $state(false);
|
||||
isRetryingInsert: boolean = $state(false);
|
||||
targetLanguages: string[] = $state([]);
|
||||
correctionPopupData: Record<string, unknown> | null = $state(null);
|
||||
showBulkReplace: boolean = $state(false);
|
||||
loadedRunId: string | null = $state(null);
|
||||
showBatches: boolean = $state(false);
|
||||
batches: Record<string, unknown>[] = $state([]);
|
||||
batchesLoading: boolean = $state(false);
|
||||
recordsTotal: number = $state(0);
|
||||
recordsPage: number = $state(1);
|
||||
recordsLoadingMore: boolean = $state(false);
|
||||
allRecordsLoaded: boolean = $state(false);
|
||||
uxState: "completed" | "partial" | "failed" | "insert_failed" = $state("completed");
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
filteredRecords = $derived.by(() => {
|
||||
if (!this.records || this.records.length === 0) return [];
|
||||
if (this.recordStatusFilter === "all") return this.records;
|
||||
return this.records.filter((r: any) => {
|
||||
if (this.recordStatusFilter === "success") return r.status === "SUCCESS";
|
||||
if (this.recordStatusFilter === "failed") return r.status === "FAILED";
|
||||
if (this.recordStatusFilter === "skipped") return r.status === "SKIPPED";
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
insertStatusBadge = $derived.by(() => {
|
||||
const s = (this.status as any)?.insert_status;
|
||||
if (!s || s === "success") return { label: _t()?.translate?.run?.success, class: "bg-success-light text-success" };
|
||||
if (s === "failed" || s === "timeout") return { label: _t()?.translate?.run?.failed, class: "bg-destructive-light text-destructive" };
|
||||
return { label: s, class: "bg-warning-light text-warning" };
|
||||
});
|
||||
|
||||
insertMethodBadge = $derived.by(() => {
|
||||
const method = (this.status as any)?.insert_method;
|
||||
if (method === "direct_db") {
|
||||
return {
|
||||
label: "Direct DB",
|
||||
class: "bg-primary-light text-primary",
|
||||
connectionName: (this.status as any)?.connection_snapshot?.name || "",
|
||||
};
|
||||
}
|
||||
return { label: "SQL Lab", class: "bg-surface-muted text-text-muted", connectionName: "" };
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
getRunStatusLabel(value: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
COMPLETED: _t()?.translate?.run?.completed || "Completed",
|
||||
FAILED: _t()?.translate?.run?.translation_failed || "Failed",
|
||||
RUNNING: _t()?.translate?.run?.history_status_running || "Running",
|
||||
PENDING: _t()?.translate?.run?.history_status_pending || "Pending",
|
||||
CANCELLED: _t()?.translate?.run?.cancelled || "Cancelled",
|
||||
};
|
||||
return labels[value] || value;
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
async loadData(runId: string): Promise<void> {
|
||||
try {
|
||||
const [statusData, recordsData] = await Promise.all([
|
||||
fetchRunStatus(runId),
|
||||
fetchRunRecords(runId, { page_size: 50, deduplicate: true }),
|
||||
]);
|
||||
this.status = statusData as Record<string, unknown>;
|
||||
this.records = (recordsData as any)?.items || [];
|
||||
this.recordsTotal = (recordsData as any)?.total || this.records.length;
|
||||
this.recordsPage = 1;
|
||||
this.allRecordsLoaded = this.records.length >= this.recordsTotal;
|
||||
|
||||
// Extract target languages from language_stats or records
|
||||
const statusAny = statusData as any;
|
||||
if (statusAny.language_stats && statusAny.language_stats.length > 0) {
|
||||
this.targetLanguages = statusAny.language_stats.map((l: any) => l.language_code);
|
||||
} else {
|
||||
const langSet = new SvelteSet<string>();
|
||||
for (const rec of this.records) {
|
||||
const recAny = rec as any;
|
||||
if (recAny.languages) {
|
||||
for (const lang of recAny.languages) {
|
||||
langSet.add(lang.language_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.targetLanguages = Array.from(langSet);
|
||||
}
|
||||
|
||||
// Determine UX state
|
||||
const s = statusAny.status;
|
||||
const insertS = statusAny.insert_status;
|
||||
if (s === "COMPLETED" && insertS === "failed") {
|
||||
this.uxState = "insert_failed";
|
||||
} else if (s === "COMPLETED" && (statusAny.failed_records || 0) > 0) {
|
||||
this.uxState = "partial";
|
||||
} else if (s === "FAILED") {
|
||||
this.uxState = "failed";
|
||||
} else {
|
||||
this.uxState = "completed";
|
||||
}
|
||||
if (this.uxState === "failed" || this.uxState === "partial") {
|
||||
this.showRecords = true;
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || _t()?.translate?.run?.load_failed, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async handleRetry(runId: string, onRefresh: () => void): Promise<void> {
|
||||
this.isRetrying = true;
|
||||
try {
|
||||
await retryFailedBatches(runId);
|
||||
addToast(_t()?.translate?.run?.retry_success, "success");
|
||||
await this.loadData(runId);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || _t()?.translate?.run?.retry_failed_msg, "error");
|
||||
} finally {
|
||||
this.isRetrying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleRetryInsert(runId: string, onRefresh: () => void): Promise<void> {
|
||||
this.isRetryingInsert = true;
|
||||
try {
|
||||
await retryInsert(runId);
|
||||
addToast(_t()?.translate?.run?.insert_retry_success, "success");
|
||||
await this.loadData(runId);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || _t()?.translate?.run?.insert_retry_failed_msg, "error");
|
||||
} finally {
|
||||
this.isRetryingInsert = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreRecords(runId: string): Promise<void> {
|
||||
if (this.recordsLoadingMore) return;
|
||||
this.recordsLoadingMore = true;
|
||||
const nextPage = this.recordsPage + 1;
|
||||
try {
|
||||
const data = await fetchRunRecords(runId, { page: nextPage, page_size: 50, deduplicate: true });
|
||||
const newItems = (data as any)?.items || [];
|
||||
this.records = [...this.records, ...newItems];
|
||||
this.recordsPage = nextPage;
|
||||
this.allRecordsLoaded = this.records.length >= ((data as any)?.total || this.recordsTotal);
|
||||
} catch (e: any) {
|
||||
addToast(e?.message || "Failed to load more records", "error");
|
||||
} finally {
|
||||
this.recordsLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadBatches(runId: string): Promise<void> {
|
||||
this.showBatches = !this.showBatches;
|
||||
if (this.showBatches && this.batches.length === 0) {
|
||||
this.batchesLoading = true;
|
||||
try {
|
||||
const data = await fetchRunBatches(runId);
|
||||
this.batches = Array.isArray(data) ? data : [];
|
||||
} catch { /* ignore */ }
|
||||
this.batchesLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion TranslationRunResult.Model
|
||||
Reference in New Issue
Block a user