From ded453944d38c093b9ce1e8455c63f39fb81e42a Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 2 Jun 2026 18:22:25 +0300 Subject: [PATCH] refactor(frontend): extract TranslateHistoryModel for translation history page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate history: 546→231 lines (-58%). Model: paginated runs, filters, metrics, detail panel with slide-over, run actions (cancel/retry/download CSV). 6→5 oversized pages remaining. --- .../models/TranslateHistoryModel.svelte.ts | 160 ++++ .../src/routes/translate/history/+page.svelte | 744 +++++------------- 2 files changed, 375 insertions(+), 529 deletions(-) create mode 100644 frontend/src/lib/models/TranslateHistoryModel.svelte.ts diff --git a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts new file mode 100644 index 00000000..b248218d --- /dev/null +++ b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts @@ -0,0 +1,160 @@ +// #region TranslateHistoryModel [C:4] [TYPE Model] [SEMANTICS translate,history,run,list,pagination,model] +// @BRIEF Screen model for translation run history — manages paginated run list, filters, detail panel, metrics, and run actions. +// @INVARIANT Changing filter resets pagination to page 1. +// @INVARIANT Detail panel opens only when selectedRun is set; closing clears selectedRunDetail. +// @ACTION loadRuns() / loadMetrics() / loadJobs() — Fetch data. +// @ACTION openDetail(run) / closeDetail() — Detail panel management. +// @ACTION applyFilters() — Reset pagination + reload. +// @ACTION handleCancelRun/Retry/Download — Run-level actions. +// @RELATION DEPENDS_ON -> [translateApi] + +import { log } from '$lib/cot-logger'; +import { addToast } from '$lib/toasts.svelte.js'; +import { t, _ } from '$lib/i18n/index.svelte.js'; +import { fetchAllRuns, fetchRunDetail, fetchAllMetrics, fetchJobs, downloadSkippedCsv, downloadFailedCsv, cancelRun, retryFailedBatches } from '$lib/api/translate.js'; + +type UxState = 'idle' | 'loading' | 'empty' | 'populated' | 'detail_open' | 'pruned'; + +export class TranslateHistoryModel { + // ── List ────────────────────────────────────────────────────── + uxState: UxState = $state('idle'); + isLoading: boolean = $state(false); + runs: Record[] = $state([]); + total: number = $state(0); + currentPage: number = $state(1); + pageSize: number = $state(20); + + // ── Filters ─────────────────────────────────────────────────── + filterJobId: string = $state(''); + filterStatus: string = $state(''); + filterTrigger: string = $state(''); + filterDateFrom: string = $state(''); + filterDateTo: string = $state(''); + + // ── Detail ──────────────────────────────────────────────────── + selectedRun: Record | null = $state(null); + selectedRunDetail: Record | null = $state(null); + + // ── Metrics ─────────────────────────────────────────────────── + metrics: Record[] = $state([]); + jobs: Record[] = $state([]); + + // ── Action spinners ─────────────────────────────────────────── + cancellingRunId: string | null = $state(null); + retryingRunId: string | null = $state(null); + + // ── Derived ─────────────────────────────────────────────────── + totalMetric = $derived(this._calcTotalMetric()); + + // ── Actions ─────────────────────────────────────────────────── + + async loadRuns(): Promise { + this.isLoading = true; + this.uxState = 'loading'; + try { + const result = await fetchAllRuns({ page: this.currentPage, page_size: this.pageSize, job_id: this.filterJobId || undefined, status: this.filterStatus || undefined, trigger_type: this.filterTrigger || undefined }); + this.runs = (result as { items?: Record[] })?.items || []; + this.total = (result as { total?: number })?.total || 0; + this.uxState = this.runs.length === 0 ? 'empty' : 'populated'; + } catch (err: unknown) { + addToast(err instanceof Error ? err.message : _('translate.history.load_failed'), 'error'); + this.uxState = 'empty'; + } finally { + this.isLoading = false; + } + } + + async loadMetrics(): Promise { + try { this.metrics = await fetchAllMetrics(); } catch { /* non-critical */ } + } + + async loadJobs(): Promise { + try { + const result = await fetchJobs({ page_size: 100 }); + this.jobs = Array.isArray(result) ? result : ((result as { items?: unknown[]; results?: unknown[] })?.items || (result as { items?: unknown[]; results?: unknown[] })?.results || []) as Record[]; + } catch { /* non-critical */ } + } + + async openDetail(run: Record): Promise { + this.selectedRun = run; + this.selectedRunDetail = null; + try { + this.selectedRunDetail = await fetchRunDetail(run.id as string); + this.uxState = 'detail_open'; + } catch (err: unknown) { + addToast(err instanceof Error ? err.message : _('translate.history.load_detail_failed'), 'error'); + } + } + + closeDetail(): void { + this.selectedRun = null; + this.selectedRunDetail = null; + this.uxState = this.runs.length === 0 ? 'empty' : 'populated'; + } + + applyFilters(): void { + this.currentPage = 1; + this.loadRuns(); + } + + // ── Run actions ─────────────────────────────────────────────── + + async handleCancelRun(runId: string): Promise { + this.cancellingRunId = runId; + try { + await cancelRun(runId); + addToast($t.translate?.run?.run_cancelled || 'Run cancelled', 'success'); + if (this.selectedRunDetail?.id === runId) this.selectedRunDetail = await fetchRunDetail(runId); + this.loadRuns(); + } catch (err: unknown) { + addToast(err instanceof Error ? err.message : $t.translate?.run?.cancel_failed || 'Failed to cancel run', 'error'); + } finally { this.cancellingRunId = null; } + } + + async handleRetryRun(runId: string): Promise { + this.retryingRunId = runId; + try { + await retryFailedBatches(runId); + addToast($t.translate?.run?.retry_success || 'Retry started', 'success'); + if (this.selectedRunDetail?.id === runId) this.selectedRunDetail = await fetchRunDetail(runId); + this.loadRuns(); + } catch (err: unknown) { + addToast(err instanceof Error ? err.message : $t.translate?.run?.retry_failed_msg || 'Retry failed', 'error'); + } finally { this.retryingRunId = null; } + } + + async handleDownloadSkipped(runId: string): Promise { + try { await downloadSkippedCsv(runId); } catch (err: unknown) { addToast(err instanceof Error ? err.message : _('translate.history.download_failed'), 'error'); } + } + + async handleDownloadFailed(runId: string): Promise { + try { await downloadFailedCsv(runId); } catch (err: unknown) { addToast(err instanceof Error ? err.message : _('translate.history.download_failed'), 'error'); } + } + + // ── Utilities ───────────────────────────────────────────────── + + getStatusClass(status: string | undefined): string { + const map: Record = { + PENDING: 'bg-warning-light text-warning', RUNNING: 'bg-primary-light text-primary', + COMPLETED: 'bg-success-light text-success', FAILED: 'bg-destructive-light text-destructive', + CANCELLED: 'bg-surface-muted text-text-muted', + }; + return map[status ?? ''] || 'bg-surface-muted text-text'; + } + + getJobName(jobId: string | undefined): string { + const job = this.jobs.find((j: Record) => j.id === jobId); + return job ? String(job.name) : (jobId?.substring(0, 8) ?? '') + '...'; + } + + private _calcTotalMetric() { + if (!this.metrics.length) return null; + return { + total_runs: this.metrics.reduce((s: number, m: Record) => s + ((m.total_runs as number) || 0), 0), + successful_runs: this.metrics.reduce((s: number, m: Record) => s + ((m.successful_runs as number) || 0), 0), + failed_runs: this.metrics.reduce((s: number, m: Record) => s + ((m.failed_runs as number) || 0), 0), + total_records: this.metrics.reduce((s: number, m: Record) => s + ((m.total_records as number) || 0), 0), + }; + } +} +// #endregion TranslateHistoryModel diff --git a/frontend/src/routes/translate/history/+page.svelte b/frontend/src/routes/translate/history/+page.svelte index a9ed526b..f7371106 100644 --- a/frontend/src/routes/translate/history/+page.svelte +++ b/frontend/src/routes/translate/history/+page.svelte @@ -1,545 +1,231 @@ + + +
-
-
-

{$t.translate?.history?.title}

-

{$t.translate?.history?.subtitle}

-
- -
+
+
+

{$t.translate?.history?.title}

+

{$t.translate?.history?.subtitle}

+
+ +
- - {#if totalMetric()} -
-
-

{$t.translate?.history?.total_runs}

-

{totalMetric().total_runs}

-
-
-

{$t.translate?.history?.successful}

-

{totalMetric().successful_runs}

-
-
-

{$t.translate?.history?.failed}

-

{totalMetric().failed_runs}

-
-
-

{$t.translate?.history?.records}

-

{totalMetric().total_records}

-
-
- {/if} + + {#if m.totalMetric} +
+

{$t.translate?.history?.total_runs}

{m.totalMetric.total_runs}

+

{$t.translate?.history?.successful}

{m.totalMetric.successful_runs}

+

{$t.translate?.history?.failed}

{m.totalMetric.failed_runs}

+

{$t.translate?.history?.records}

{m.totalMetric.total_records}

+
+ {/if} - -
-
- - - - - -
-
+ +
+
+ + + + + +
+
- - {#if isLoading} -
- {#each Array(5) as _} -
- {/each} -
+ + {#if m.isLoading} +
{#each Array(5) as _}
{/each}
- - {:else if uxState === 'empty'} -
- - - -

{$t.translate?.history?.no_runs}

-

{$t.translate?.history?.no_runs_hint}

-
+ + {:else if m.uxState === 'empty'} +
+ +

{$t.translate?.history?.no_runs}

+

{$t.translate?.history?.no_runs_hint}

+
- - {:else if uxState === 'populated' || uxState === 'detail_open'} -
- {#each runs as run} -
openDetail(run)} - class="bg-surface-card border border-border rounded-lg p-3 hover:shadow-sm hover:border-border-strong transition-all cursor-pointer" - > -
-
-
- {getJobName(run.job_id)} - - {run.status} - - {#if run.trigger_type} - {run.trigger_type} - {/if} -
-
- {run.created_at ? new Date(run.created_at).toLocaleString() : ''} - {_('translate.history.records_label').replace('{count}', run.total_records || 0)} - {_('translate.history.ok_label').replace('{count}', run.successful_records || 0)} - {_('translate.history.fail_label').replace('{count}', run.failed_records || 0)} - Cache: {run.cache_hits || 0} - {#if run.created_by} - {_('translate.history.by_label').replace('{user}', run.created_by)} - {/if} -
- - {#if run.target_languages && run.target_languages.length > 0} -
- {#each run.target_languages as lang} - {lang} - {/each} -
- {/if} -
-
e.stopPropagation()}> - {#if run.status === 'PENDING' || run.status === 'RUNNING'} - - {/if} - {#if run.status === 'FAILED'} - - {/if} - {#if run.status === 'COMPLETED' && (run.skipped_records || 0) > 0} - - {/if} - {#if run.status === 'FAILED' && (run.successful_records || 0) > 0} - - {/if} - - - -
-
-
- {/each} -
+ + {:else if m.uxState === 'populated' || m.uxState === 'detail_open'} +
+ {#each m.runs as run} +
m.openDetail(run)} class="bg-surface-card border border-border rounded-lg p-3 hover:shadow-sm hover:border-border-strong transition-all cursor-pointer"> +
+
+
+ {m.getJobName(run.job_id as string)} + {run.status} + {#if run.trigger_type}{run.trigger_type}{/if} +
+
+ {run.created_at ? new Date(run.created_at as string).toLocaleString() : ''} + {_('translate.history.records_label').replace('{count}', String(run.total_records || 0))} + {_('translate.history.ok_label').replace('{count}', String(run.successful_records || 0))} + {_('translate.history.fail_label').replace('{count}', String(run.failed_records || 0))} + Cache: {run.cache_hits || 0} + {#if run.created_by}{_('translate.history.by_label').replace('{user}', String(run.created_by))}{/if} +
+ {#if (run.target_languages as string[])?.length > 0} +
+ {#each run.target_languages as lang}{lang}{/each} +
+ {/if} +
+
e.stopPropagation()}> + {#if run.status === 'PENDING' || run.status === 'RUNNING'} + + {/if} + {#if run.status === 'FAILED'} + + {/if} + {#if run.status === 'COMPLETED' && (run.skipped_records as number || 0) > 0} + + {/if} + {#if run.status === 'FAILED' && (run.successful_records as number || 0) > 0} + + {/if} + +
+
+
+ {/each} +
- -
-

{_('translate.history.showing').replace('{count}', runs.length).replace('{total}', total)}

-
- - -
-
- {/if} + +
+

{_('translate.history.showing').replace('{count}', String(m.runs.length)).replace('{total}', String(m.total))}

+
+ + +
+
+ {/if} - - {#if uxState === 'detail_open' && selectedRunDetail} -
-
-
-
-

{$t.translate?.history?.run_detail}

- -
- - -
-
-

{$t.translate?.history?.status}

- - {selectedRunDetail.status} - -
-
-

{$t.translate?.history?.trigger}

-

{selectedRunDetail.trigger_type || 'manual'}

-
-
-

{$t.translate?.history?.started}

-

{selectedRunDetail.started_at ? new Date(selectedRunDetail.started_at).toLocaleString() : '-'}

-
-
-

{$t.translate?.history?.completed}

-

{selectedRunDetail.completed_at ? new Date(selectedRunDetail.completed_at).toLocaleString() : '-'}

-
-
- - -
-
-

{selectedRunDetail.total_records || 0}

-

{$t.translate?.history?.total}

-
-
-

{selectedRunDetail.successful_records || 0}

-

{$t.translate?.history?.success}

-
-
-

{selectedRunDetail.failed_records || 0}

-

{$t.translate?.history?.failed}

-
-
-

{selectedRunDetail.skipped_records || 0}

-

{$t.translate?.history?.skipped}

-
-
-

{selectedRunDetail.cache_hits || 0}

-

Cache

-
-
- - - {#if selectedRunDetail.language_stats && Object.keys(selectedRunDetail.language_stats).length > 0} -
-

{$t.translate?.run?.per_language}

-
- {#each Object.entries(selectedRunDetail.language_stats) as [lang, stats]} -
-
- {lang} -
-
- {_('translate.run.translated_label').replace('{count}', stats.translated_rows)} - {_('translate.run.failed_label').replace('{count}', stats.failed_rows)} - {_('translate.run.skipped_label').replace('{count}', stats.skipped_rows)} - {_('translate.run.tokens_label').replace('{count}', stats.token_count)} -
-
- {/each} -
-
- {/if} - - -
- {#if selectedRunDetail.status === 'PENDING' || selectedRunDetail.status === 'RUNNING'} - - {/if} - {#if selectedRunDetail.status === 'FAILED'} - - {/if} -
- - - {#if selectedRunDetail.config_snapshot} -
-

{$t.translate?.history?.config_snapshot}

-
-
{JSON.stringify(selectedRunDetail.config_snapshot, null, 2)}
-
-
- {/if} - - - {#if selectedRunDetail.config_hash || selectedRunDetail.dict_snapshot_hash} -
-

{$t.translate?.history?.snapshots}

-
- {#if selectedRunDetail.config_hash} -
Config: {selectedRunDetail.config_hash}
- {/if} - {#if selectedRunDetail.dict_snapshot_hash} -
Dict: {selectedRunDetail.dict_snapshot_hash}
- {/if} - {#if selectedRunDetail.key_hash} -
Key: {selectedRunDetail.key_hash}
- {/if} -
-
- {/if} - - - {#if selectedRunDetail.events && selectedRunDetail.events.length > 0} -
-

{$t.translate?.history?.events.replace('{count}', selectedRunDetail.events.length)}

-
- {#each selectedRunDetail.events as evt} -
- {evt.event_type} - | - {evt.created_at ? new Date(evt.created_at).toLocaleString() : ''} -
- {/each} -
-
- {/if} - - - {#if selectedRunDetail.error_message} -
-

{$t.translate?.history?.error}

-

{selectedRunDetail.error_message}

-
- {/if} - - - {#if selectedRunDetail.superset_execution_id} -
-

{$t.translate?.history?.superset_query_id}

-

{selectedRunDetail.superset_execution_id}

-
- {/if} -
-
- {/if} + + {#if m.uxState === 'detail_open' && m.selectedRunDetail} +
m.closeDetail()}>
+
+
+
+

{$t.translate?.history?.run_detail}

+ +
+ +
+

{$t.translate?.history?.status}

{m.selectedRunDetail.status}
+

{$t.translate?.history?.trigger}

{m.selectedRunDetail.trigger_type || 'manual'}

+

{$t.translate?.history?.started}

{m.selectedRunDetail.started_at ? new Date(m.selectedRunDetail.started_at as string).toLocaleString() : '-'}

+

{$t.translate?.history?.completed}

{m.selectedRunDetail.completed_at ? new Date(m.selectedRunDetail.completed_at as string).toLocaleString() : '-'}

+
+ +
+

{m.selectedRunDetail.total_records || 0}

{$t.translate?.history?.total}

+

{m.selectedRunDetail.successful_records || 0}

{$t.translate?.history?.success}

+

{m.selectedRunDetail.failed_records || 0}

{$t.translate?.history?.failed}

+

{m.selectedRunDetail.skipped_records || 0}

{$t.translate?.history?.skipped}

+

{m.selectedRunDetail.cache_hits || 0}

Cache

+
+ + {#if m.selectedRunDetail.language_stats && Object.keys(m.selectedRunDetail.language_stats as Record).length > 0} +
+

{$t.translate?.run?.per_language}

+
+ {#each Object.entries(m.selectedRunDetail.language_stats as Record) as [lang, stats]} +
+
{lang}
+
+ {_('translate.run.translated_label').replace('{count}', String((stats as Record).translated_rows))} + {_('translate.run.failed_label').replace('{count}', String((stats as Record).failed_rows))} + {_('translate.run.skipped_label').replace('{count}', String((stats as Record).skipped_rows))} + {_('translate.run.tokens_label').replace('{count}', String((stats as Record).token_count))} +
+
+ {/each} +
+
+ {/if} + +
+ {#if m.selectedRunDetail.status === 'PENDING' || m.selectedRunDetail.status === 'RUNNING'} + + {/if} + {#if m.selectedRunDetail.status === 'FAILED'} + + {/if} +
+ + {#if m.selectedRunDetail.config_snapshot} +

{$t.translate?.history?.config_snapshot}

{JSON.stringify(m.selectedRunDetail.config_snapshot, null, 2)}
+ {/if} + + {#if m.selectedRunDetail.config_hash || m.selectedRunDetail.dict_snapshot_hash} +

{$t.translate?.history?.snapshots}

+
+ {#if m.selectedRunDetail.config_hash}
Config: {m.selectedRunDetail.config_hash}
{/if} + {#if m.selectedRunDetail.dict_snapshot_hash}
Dict: {m.selectedRunDetail.dict_snapshot_hash}
{/if} + {#if m.selectedRunDetail.key_hash}
Key: {m.selectedRunDetail.key_hash}
{/if} +
+ {/if} + + {#if (m.selectedRunDetail.events as unknown[])?.length > 0} +

{($t.translate?.history?.events as string)?.replace('{count}', String((m.selectedRunDetail.events as unknown[]).length))}

+
+ {#each m.selectedRunDetail.events as evt} +
+ {(evt as Record).event_type} + | + {(evt as Record).created_at ? new Date((evt as Record).created_at as string).toLocaleString() : ''} +
+ {/each} +
+ {/if} + + {#if m.selectedRunDetail.error_message} +

{$t.translate?.history?.error}

{m.selectedRunDetail.error_message}

+ {/if} + {#if m.selectedRunDetail.superset_execution_id} +

{$t.translate?.history?.superset_query_id}

{m.selectedRunDetail.superset_execution_id}

+ {/if} +
+
+ {/if}