feat: add deduplicate + metrics to Run tab
Backend:
- deduplicate param in GET /runs/{id}/records — NOT EXISTS subquery
with (created_at, id) tiebreaker for one row per source_hash
- source_data and source_hash added to records JSON response
- fix missing status import in _run_history_routes.py (NameError)
- fix tab/space mixup in orchestrator_aggregator.py
- remove duplicated @RELATION edges in metrics.py
- fix #region/#endregion style and @PURPOSE→@BRIEF in metrics.py
Frontend:
- RunTabContent: summary metrics bar (fetchJobMetrics) with HelpTooltips
- RunTabContent: trigger_type badge, duration, cache rate in run rows
- TranslationRunResult: deduplicate=true by default, paginated Load more
- TranslationRunResult: per-language token_count and estimated_cost
- TranslationRunResult: collapsible batch breakdown with timing
- TranslationRunResult: Bulk Replace button in header and records table
- TranslationRunResult: source_data key values shown under source text
i18n: all new keys in EN + RU (load_more_records, showing_records,
sum_*, help_sum_*, trigger_*, batch_*, duration_label, cost_label,
cache_rate, load_more_records)
This commit is contained in:
@@ -84,12 +84,13 @@ export async function fetchRunHistory<T = unknown>(jobId: string, options: RunHi
|
||||
// @PRE runId is a non-empty string.
|
||||
// @POST Returns paginated record list with translations.
|
||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||
export async function fetchRunRecords<T = unknown>(runId: string, options: RunQueryOptions & { status?: string } = {}): Promise<T> {
|
||||
export async function fetchRunRecords<T = unknown>(runId: string, options: RunQueryOptions & { status?: string; deduplicate?: boolean } = {}): Promise<T> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (options.page != null) params.append('page', String(options.page));
|
||||
if (options.page_size != null) params.append('page_size', String(options.page_size));
|
||||
if (options.status) params.append('status', options.status);
|
||||
if (options.deduplicate != null) params.append('deduplicate', String(options.deduplicate));
|
||||
const query = params.toString();
|
||||
return await api.fetchApi<T>(`/translate/runs/${runId}/records${query ? `?${query}` : ''}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<!-- #region RunTabContent [C:3] [TYPE Component] [SEMANTICS translate, run, execution, progress] -->
|
||||
<!-- #region RunTabContent [C:3] [TYPE Component] [SEMANTICS translate, run, execution, progress, metrics] -->
|
||||
<!-- @BRIEF Run tab — translation execution with incremental/full mode selection, progress
|
||||
tracking via TranslationRunProgress, and run history with expandable details. -->
|
||||
tracking via TranslationRunProgress, job metrics summary, and run history with
|
||||
trigger type, duration, cache rate, and expandable details. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TranslationRunProgress] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TranslationRunResult] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [TranslateMetricsRoutesModule] -->
|
||||
<!-- @RELATION BINDS_TO -> [translationRunStore] -->
|
||||
<!-- @UX_STATE Idle — no run active, run cards visible -->
|
||||
<!-- @UX_STATE Running — TranslationRunProgress bar visible, cards show active state -->
|
||||
<!-- @UX_STATE Completed — run history list with expandable details -->
|
||||
<!-- @UX_STATE Completed — run history list with expandable details, summary metrics bar -->
|
||||
<!-- @UX_STATE Error — error banner above run history -->
|
||||
<!-- @UX_FEEDBACK Toast on status change / run error / run complete -->
|
||||
<!-- @UX_FEEDBACK BulkReplace modal button in recent runs header -->
|
||||
<!-- @UX_FEEDBACK Summary metrics bar with success rate, tokens, cost, avg duration -->
|
||||
<!-- @UX_RECOVERY Retry button on TranslationRunProgress for failed runs -->
|
||||
<!-- @UX_REACTIVITY Props -> $props() for config/state, callbacks for actions -->
|
||||
<!-- @UX_REACTIVITY LocalState -> $state(jobMetrics, metricsLoading) from fetchJobMetrics -->
|
||||
<script lang="ts">
|
||||
import { getT } from '$lib/i18n/index.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { updateJob } from '$lib/api/translate.js';
|
||||
import { updateJob, fetchJobMetrics } from '$lib/api/translate.js';
|
||||
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
|
||||
import TranslationRunProgress from './TranslationRunProgress.svelte';
|
||||
import TranslationRunResult from './TranslationRunResult.svelte';
|
||||
@@ -40,6 +44,54 @@
|
||||
getJobStatusLabel = (s) => s,
|
||||
} = $props();
|
||||
|
||||
// Job metrics state (from fetchJobMetrics)
|
||||
let jobMetrics = $state(null);
|
||||
let metricsLoading = $state(false);
|
||||
let metricsError = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!jobId) return;
|
||||
loadJobMetrics();
|
||||
});
|
||||
|
||||
async function loadJobMetrics() {
|
||||
metricsLoading = true;
|
||||
metricsError = null;
|
||||
try {
|
||||
const data = await fetchJobMetrics(jobId);
|
||||
jobMetrics = data;
|
||||
} catch (e) {
|
||||
metricsError = e?.message || 'Failed to load metrics';
|
||||
jobMetrics = null;
|
||||
} finally {
|
||||
metricsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute duration seconds from started_at / completed_at. */
|
||||
function calcDuration(seconds) {
|
||||
if (!seconds && seconds !== 0) return null;
|
||||
if (seconds < 1) return '<1';
|
||||
return seconds.toFixed(1);
|
||||
}
|
||||
|
||||
function getRunDuration(run) {
|
||||
if (!run.started_at) return null;
|
||||
const start = new Date(run.started_at).getTime();
|
||||
const end = run.completed_at ? new Date(run.completed_at).getTime() : Date.now();
|
||||
return calcDuration((end - start) / 1000);
|
||||
}
|
||||
|
||||
function getTriggerLabel(trigger) {
|
||||
const labels = {
|
||||
manual: _t.translate?.run?.trigger_manual,
|
||||
scheduled: _t.translate?.run?.trigger_scheduled,
|
||||
retry: _t.translate?.run?.trigger_retry,
|
||||
baseline_expired: _t.translate?.run?.trigger_baseline_expired,
|
||||
};
|
||||
return labels[trigger] || trigger || _t.translate?.run?.trigger_manual;
|
||||
}
|
||||
|
||||
async function handleMarkReady() {
|
||||
try {
|
||||
await updateJob(jobId, { status: 'READY' });
|
||||
@@ -148,6 +200,73 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Summary metrics bar -->
|
||||
{#if jobMetrics}
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4">
|
||||
<h4 class="text-xs text-text-muted uppercase font-medium mb-3">{_t.translate?.run?.sum_metrics_title}</h4>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<div class="bg-surface-muted rounded p-2.5">
|
||||
<p class="text-xs text-text-muted flex items-center gap-1">
|
||||
{_t.translate?.run?.sum_total_runs}
|
||||
<HelpTooltip text={_t.translate?.run?.help_sum_total_runs || ''} />
|
||||
</p>
|
||||
<p class="text-lg font-bold text-text">{jobMetrics.total_runs || 0}</p>
|
||||
</div>
|
||||
<div class="bg-success-light rounded p-2.5">
|
||||
<p class="text-xs text-success flex items-center gap-1">
|
||||
{_t.translate?.run?.sum_success_rate}
|
||||
<HelpTooltip text={_t.translate?.run?.help_sum_success_rate || ''} />
|
||||
</p>
|
||||
<p class="text-lg font-bold text-success">
|
||||
{jobMetrics.total_runs > 0 ? Math.round(((jobMetrics.successful_runs || 0) / jobMetrics.total_runs) * 100) : 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-surface-muted rounded p-2.5">
|
||||
<p class="text-xs text-text-muted flex items-center gap-1">
|
||||
{_t.translate?.run?.sum_total_records}
|
||||
<HelpTooltip text={_t.translate?.run?.help_sum_total_records || ''} />
|
||||
</p>
|
||||
<p class="text-lg font-bold text-text">{(jobMetrics.total_records || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
<div class="bg-info-light rounded p-2.5">
|
||||
<p class="text-xs text-info flex items-center gap-1">
|
||||
{_t.translate?.run?.sum_total_tokens}
|
||||
<HelpTooltip text={_t.translate?.run?.help_sum_total_tokens || ''} />
|
||||
</p>
|
||||
<p class="text-lg font-bold text-info">{(jobMetrics.cumulative_tokens || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
<div class="bg-surface-muted rounded p-2.5">
|
||||
<p class="text-xs text-text-muted flex items-center gap-1">
|
||||
{_t.translate?.run?.sum_total_cost}
|
||||
<HelpTooltip text={_t.translate?.run?.help_sum_total_cost || ''} />
|
||||
</p>
|
||||
<p class="text-lg font-bold text-text">${(jobMetrics.cumulative_cost || 0).toFixed(2)}</p>
|
||||
</div>
|
||||
<div class="bg-primary-light rounded p-2.5">
|
||||
<p class="text-xs text-primary flex items-center gap-1">
|
||||
{_t.translate?.run?.sum_avg_duration}
|
||||
<HelpTooltip text={_t.translate?.run?.help_sum_avg_duration || ''} />
|
||||
</p>
|
||||
<p class="text-lg font-bold text-primary">
|
||||
{jobMetrics.avg_duration_ms != null ? (jobMetrics.avg_duration_ms / 1000).toFixed(1) + 's' : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if metricsLoading}
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4">
|
||||
<div class="grid grid-cols-6 gap-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-16 bg-surface-muted rounded animate-pulse" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if metricsError}
|
||||
<div class="bg-destructive-light border border-destructive-light rounded-lg p-3">
|
||||
<p class="text-sm text-destructive">{metricsError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if completedRuns.length > 0}
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
@@ -175,12 +294,25 @@
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
{#if run.trigger_type}
|
||||
<span class="text-[11px] text-text-subtle bg-surface-muted px-1.5 py-0.5 rounded">
|
||||
{getTriggerLabel(run.trigger_type)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if run.started_at}
|
||||
<span class="text-[11px] text-text-subtle">⏱ {_t.translate?.run?.duration_label?.replace('{seconds}', getRunDuration(run))}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-xs text-text-muted">
|
||||
<span>{_t.translate?.run?.total}: {run.total_records || 0}</span>
|
||||
<span>{_t.translate?.run?.success}: {run.successful_records || 0}</span>
|
||||
<span>{_t.translate?.run?.failed}: {run.failed_records || 0}</span>
|
||||
<span>{_t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
|
||||
<span class="text-success">{_t.translate?.run?.success}: {run.successful_records || 0}</span>
|
||||
<span class="text-destructive">{_t.translate?.run?.failed}: {run.failed_records || 0}</span>
|
||||
<span class="text-warning">{_t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
|
||||
{#if run.cache_hits}
|
||||
<span class="text-info">
|
||||
{_t.translate?.run?.cache_rate?.replace('{pct}', Math.round((run.cache_hits / Math.max(run.total_records || 1, 1)) * 100))}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if run.error_message}
|
||||
<p class="mt-2 text-xs text-destructive break-words">{run.error_message}</p>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<!-- #region TranslationRunResult [C:3] [TYPE Component] [SEMANTICS translate, result, stats, retry, sql-audit] -->
|
||||
<!-- @BRIEF Component component: lib/components/translate/TranslationRunResult.svelte -->
|
||||
<!-- #region TranslationRunResult [C:3] [TYPE Component] [SEMANTICS translate, result, stats, retry, sql-audit, metrics, bulk-replace] -->
|
||||
<!-- @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] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [BulkReplaceModal] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api_module] -->
|
||||
<!-- @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_FEEDBACK Tabular statistics; per-language token count and estimated cost; collapsible batch breakdown with per-batch duration -->
|
||||
<!-- @UX_FEEDBACK click to copy Superset query ID; collapsible SQL block. -->
|
||||
<!-- @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";
|
||||
@@ -15,6 +18,7 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
import {
|
||||
fetchRunStatus,
|
||||
fetchRunRecords,
|
||||
fetchRunBatches,
|
||||
retryFailedBatches,
|
||||
retryInsert,
|
||||
downloadFailedCsv
|
||||
@@ -44,6 +48,13 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
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 = {
|
||||
@@ -75,10 +86,13 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
try {
|
||||
const [statusData, recordsData] = await Promise.all([
|
||||
fetchRunStatus(runId),
|
||||
fetchRunRecords(runId, { page_size: 10 }),
|
||||
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) {
|
||||
@@ -152,6 +166,23 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
});
|
||||
}
|
||||
|
||||
/** Load next page of unique rows and append to records list. */
|
||||
async function loadMoreRecords() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function toggleSqlAudit() {
|
||||
showSqlAudit = !showSqlAudit;
|
||||
@@ -210,6 +241,12 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
{isRetryingInsert ? _t.translate?.run?.retrying_insert : _t.translate?.run?.retry_insert}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => showBulkReplace = true}
|
||||
class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{_t.translate?.run?.bulk_replace || 'Bulk Replace'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -245,15 +282,21 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
{#each 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 gap-3 mt-1 text-xs">
|
||||
<span class="text-success" title="Translated">{lang.translated_rows}</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs">
|
||||
<span class="text-success" title="Translated">✓{lang.translated_rows}</span>
|
||||
{#if lang.failed_rows > 0}
|
||||
<span class="text-destructive" title="Failed">{lang.failed_rows}</span>
|
||||
<span class="text-destructive" title="Failed">✗{lang.failed_rows}</span>
|
||||
{/if}
|
||||
{#if lang.skipped_rows > 0}
|
||||
<span class="text-warning" title="Skipped">{lang.skipped_rows}</span>
|
||||
<span class="text-warning" title="Skipped">⏭{lang.skipped_rows}</span>
|
||||
{/if}
|
||||
<span class="text-text-subtle" title="Total">∑{lang.total_rows}</span>
|
||||
{#if lang.token_count}
|
||||
<span class="text-info" title="Tokens">{_t.translate?.run?.tokens_label?.replace('{count}', lang.token_count.toLocaleString())}</span>
|
||||
{/if}
|
||||
{#if lang.estimated_cost}
|
||||
<span class="text-text-muted" title="Cost">{_t.translate?.run?.cost_label?.replace('{count}', lang.estimated_cost.toFixed(4))}</span>
|
||||
{/if}
|
||||
<span class="text-text-subtle" title="Total">{lang.total_rows}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -336,6 +379,67 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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;
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-2 text-sm text-text hover:text-text"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform {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)}
|
||||
</button>
|
||||
|
||||
{#if showBatches}
|
||||
<div class="mt-2 space-y-1">
|
||||
{#if batchesLoading}
|
||||
<div class="text-xs text-text-subtle">{_t.translate?.common?.loading}</div>
|
||||
{:else if batches.length === 0}
|
||||
<p class="text-xs text-text-subtle">{_t.translate?.common?.unknown}</p>
|
||||
{:else}
|
||||
{#each batches as batch, idx}
|
||||
<div class="flex items-center gap-3 bg-surface-muted rounded px-3 py-1.5 text-xs">
|
||||
<span class="font-medium text-text-muted w-8 shrink-0">
|
||||
{_t.translate?.run?.batch_number?.replace('{n}', batch.batch_index ?? idx + 1)}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium
|
||||
{batch.status === 'COMPLETED' ? 'bg-success-light text-success' : ''}
|
||||
{batch.status === 'FAILED' ? 'bg-destructive-light text-destructive' : ''}
|
||||
{batch.status === 'RUNNING' ? 'bg-primary-light text-primary' : ''}
|
||||
{!['COMPLETED','FAILED','RUNNING'].includes(batch.status) ? 'bg-surface-muted text-text-subtle' : ''}">
|
||||
{batch.status}
|
||||
</span>
|
||||
<span class="text-text-muted">
|
||||
{_t.translate?.run?.batch_records?.replace('{ok}', batch.successful_records || 0).replace('{total}', batch.total_records || 0)}
|
||||
</span>
|
||||
{#if batch.failed_records > 0}
|
||||
<span class="text-destructive">✗{batch.failed_records}</span>
|
||||
{/if}
|
||||
{#if batch.started_at && batch.completed_at}
|
||||
{@const bDur = ((new Date(batch.completed_at).getTime() - new Date(batch.started_at).getTime()) / 1000).toFixed(1)}
|
||||
<span class="text-text-subtle ml-auto">⏱ {_t.translate?.run?.batch_duration?.replace('{seconds}', bDur)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Event Invariants -->
|
||||
{#if status.event_invariants}
|
||||
<div class="border-t border-border pt-3">
|
||||
@@ -366,8 +470,27 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
>
|
||||
<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})
|
||||
{_t.translate?.run?.records_title || 'Translation Records'} ({records.length}{#if recordsTotal > records.length}/{recordsTotal}{/if})
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !allRecordsLoaded && recordsTotal > records.length}
|
||||
<button
|
||||
onclick={loadMoreRecords}
|
||||
disabled={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
|
||||
? _t.translate?.common?.loading
|
||||
: (_t.translate?.run?.load_more_records || 'Load more') + ' (' + (recordsTotal - records.length) + ')'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => showBulkReplace = true}
|
||||
class="px-2.5 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{_t.translate?.run?.bulk_replace || 'Bulk Replace'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showRecords}
|
||||
@@ -395,8 +518,13 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
{#each records 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-[200px]">
|
||||
<code class="text-xs text-text break-all">{row.source_sql || row.source_object_name || '—'}</code>
|
||||
<td class="px-3 py-2 align-top max-w-[240px]">
|
||||
<div class="text-xs text-text break-all">{row.source_sql || row.source_object_name || '—'}</div>
|
||||
{#if row.source_data && typeof row.source_data === 'object'}
|
||||
<div class="text-[10px] text-text-subtle mt-0.5 font-mono truncate" title={JSON.stringify(row.source_data)}>
|
||||
{Object.values(row.source_data).filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{#if targetLanguages.length > 0}
|
||||
{#each targetLanguages as lang}
|
||||
@@ -458,6 +586,13 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if !allRecordsLoaded && recordsTotal > records.length}
|
||||
<p class="text-xs text-text-subtle mt-1">
|
||||
{(_t.translate?.run?.showing_records || 'Showing {count} of {total} unique rows.')
|
||||
.replace('{count}', records.length)
|
||||
.replace('{total}', recordsTotal)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -508,9 +508,35 @@
|
||||
"failed_label": "{count} failed",
|
||||
"skipped_label": "{count} skipped",
|
||||
"tokens_label": "{count} tokens",
|
||||
"cost_label": "${count}",
|
||||
"duration_label": "{seconds}s",
|
||||
"cache_rate": "Cache: {pct}%",
|
||||
"trigger_manual": "Manual",
|
||||
"trigger_scheduled": "Scheduled",
|
||||
"trigger_retry": "Retry",
|
||||
"trigger_baseline_expired": "Baseline Expired",
|
||||
"load_failed": "Failed to load run status",
|
||||
"bulk_replace": "Bulk Replace",
|
||||
"status_label": "Status"
|
||||
"status_label": "Status",
|
||||
"batches_title": "Batches ({count})",
|
||||
"batch_number": "#{n}",
|
||||
"batch_records": "{ok}/{total}",
|
||||
"batch_duration": "{seconds}s",
|
||||
"sum_metrics_title": "Job Metrics",
|
||||
"sum_total_runs": "Total Runs",
|
||||
"sum_success_rate": "Success Rate",
|
||||
"sum_total_records": "Records",
|
||||
"sum_total_tokens": "Tokens",
|
||||
"sum_total_cost": "Cost",
|
||||
"load_more_records": "Load more",
|
||||
"showing_records": "Showing {count} of {total} unique rows.",
|
||||
"sum_avg_duration": "Avg Duration",
|
||||
"help_sum_total_runs": "Total number of translation runs executed for this job, including completed, failed, and cancelled.",
|
||||
"help_sum_success_rate": "Percentage of runs that completed successfully relative to total runs.",
|
||||
"help_sum_total_records": "Total number of database records processed across all runs of this job.",
|
||||
"help_sum_total_tokens": "Cumulative LLM token consumption across all runs. Tokens are the unit of input/output processed by the language model.",
|
||||
"help_sum_total_cost": "Estimated cumulative cost of all LLM API calls for this job, calculated from per-run token usage.",
|
||||
"help_sum_avg_duration": "Average duration of a translation run across all runs. Measured from run start to completion (wall-clock time, including LLM calls and DB inserts)."
|
||||
},
|
||||
"corrections": {
|
||||
"title": "Bulk Corrections",
|
||||
|
||||
@@ -509,9 +509,35 @@
|
||||
"failed_label": "{count} с ошибками",
|
||||
"skipped_label": "{count} пропущено",
|
||||
"tokens_label": "{count} токенов",
|
||||
"cost_label": "${count}",
|
||||
"duration_label": "{seconds}с",
|
||||
"cache_rate": "Кэш: {pct}%",
|
||||
"trigger_manual": "Вручную",
|
||||
"trigger_scheduled": "По расписанию",
|
||||
"trigger_retry": "Повтор",
|
||||
"trigger_baseline_expired": "Истёк Baseline",
|
||||
"load_failed": "Не удалось загрузить статус запуска",
|
||||
"bulk_replace": "Массовая замена",
|
||||
"status_label": "Статус"
|
||||
"status_label": "Статус",
|
||||
"batches_title": "Пакеты ({count})",
|
||||
"batch_number": "№{n}",
|
||||
"batch_records": "{ok}/{total}",
|
||||
"batch_duration": "{seconds}с",
|
||||
"sum_metrics_title": "Метрики задания",
|
||||
"sum_total_runs": "Всего запусков",
|
||||
"sum_success_rate": "Успешность",
|
||||
"sum_total_records": "Записей",
|
||||
"sum_total_tokens": "Токенов",
|
||||
"sum_total_cost": "Стоимость",
|
||||
"load_more_records": "Загрузить ещё",
|
||||
"showing_records": "Показано {count} из {total} уникальных строк.",
|
||||
"sum_avg_duration": "Ср. длительность",
|
||||
"help_sum_total_runs": "Общее количество запусков перевода для этого задания, включая завершённые, с ошибками и отменённые.",
|
||||
"help_sum_success_rate": "Процент запусков, завершившихся успешно, от общего числа запусков.",
|
||||
"help_sum_total_records": "Общее количество записей БД, обработанных во всех запусках этого задания.",
|
||||
"help_sum_total_tokens": "Суммарное потребление токенов LLM во всех запусках. Токены — единица входных/выходных данных, обрабатываемых языковой моделью.",
|
||||
"help_sum_total_cost": "Оценка суммарной стоимости всех вызовов LLM API для этого задания, рассчитанная на основе использования токенов в каждом запуске.",
|
||||
"help_sum_avg_duration": "Средняя продолжительность запуска перевода по всем запускам. Измеряется от начала до завершения (реальное время, включая вызовы LLM и вставки в БД)."
|
||||
},
|
||||
"corrections": {
|
||||
"title": "Массовые исправления",
|
||||
|
||||
Reference in New Issue
Block a user