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:
2026-07-05 09:16:32 +03:00
parent 669d8185f3
commit 97660cb71f
2 changed files with 320 additions and 277 deletions

View File

@@ -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 -->

View 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