refactor(frontend): extract TranslateHistoryModel for translation history page
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.
This commit is contained in:
160
frontend/src/lib/models/TranslateHistoryModel.svelte.ts
Normal file
160
frontend/src/lib/models/TranslateHistoryModel.svelte.ts
Normal file
@@ -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<string, unknown>[] = $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<string, unknown> | null = $state(null);
|
||||
selectedRunDetail: Record<string, unknown> | null = $state(null);
|
||||
|
||||
// ── Metrics ───────────────────────────────────────────────────
|
||||
metrics: Record<string, unknown>[] = $state([]);
|
||||
jobs: Record<string, unknown>[] = $state([]);
|
||||
|
||||
// ── Action spinners ───────────────────────────────────────────
|
||||
cancellingRunId: string | null = $state(null);
|
||||
retryingRunId: string | null = $state(null);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
totalMetric = $derived(this._calcTotalMetric());
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
async loadRuns(): Promise<void> {
|
||||
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<string, unknown>[] })?.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<void> {
|
||||
try { this.metrics = await fetchAllMetrics(); } catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async loadJobs(): Promise<void> {
|
||||
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<string, unknown>[];
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async openDetail(run: Record<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try { await downloadSkippedCsv(runId); } catch (err: unknown) { addToast(err instanceof Error ? err.message : _('translate.history.download_failed'), 'error'); }
|
||||
}
|
||||
|
||||
async handleDownloadFailed(runId: string): Promise<void> {
|
||||
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<string, string> = {
|
||||
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<string, unknown>) => 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<string, unknown>) => s + ((m.total_runs as number) || 0), 0),
|
||||
successful_runs: this.metrics.reduce((s: number, m: Record<string, unknown>) => s + ((m.successful_runs as number) || 0), 0),
|
||||
failed_runs: this.metrics.reduce((s: number, m: Record<string, unknown>) => s + ((m.failed_runs as number) || 0), 0),
|
||||
total_records: this.metrics.reduce((s: number, m: Record<string, unknown>) => s + ((m.total_records as number) || 0), 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
// #endregion TranslateHistoryModel
|
||||
@@ -1,545 +1,231 @@
|
||||
<!-- #region TranslateHistoryPage [C:3] [TYPE Page] [SEMANTICS sveltekit, translate, history, run, metrics] -->
|
||||
<!-- @BRIEF Translation run history — renders TranslateHistoryModel state. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION BINDS_TO -> [TranslateHistoryModel] -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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';
|
||||
import { onMount } from 'svelte';
|
||||
import { t, _ } from '$lib/i18n/index.svelte.js';
|
||||
import { TranslateHistoryModel } from '$lib/models/TranslateHistoryModel.svelte';
|
||||
|
||||
/** @type {'idle'|'loading'|'empty'|'populated'|'detail_open'|'pruned'} */
|
||||
let uxState = $state('idle');
|
||||
const m = new TranslateHistoryModel();
|
||||
|
||||
let cancellingRunId = $state(null);
|
||||
let retryingRunId = $state(null);
|
||||
|
||||
let runs = $state([]);
|
||||
let total = $state(0);
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(20);
|
||||
let selectedRun = $state(null);
|
||||
let selectedRunDetail = $state(null);
|
||||
let isLoading = $state(false);
|
||||
let metrics = $state([]);
|
||||
let jobs = $state([]);
|
||||
|
||||
// Filters
|
||||
let filterJobId = $state('');
|
||||
let filterStatus = $state('');
|
||||
let filterTrigger = $state('');
|
||||
let filterDateFrom = $state('');
|
||||
let filterDateTo = $state('');
|
||||
|
||||
onMount(() => {
|
||||
loadRuns();
|
||||
loadMetrics();
|
||||
loadJobs();
|
||||
});
|
||||
|
||||
async function loadRuns() {
|
||||
isLoading = true;
|
||||
uxState = 'loading';
|
||||
try {
|
||||
const result = await fetchAllRuns({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
job_id: filterJobId || undefined,
|
||||
status: filterStatus || undefined,
|
||||
trigger_type: filterTrigger || undefined,
|
||||
});
|
||||
runs = result?.items || [];
|
||||
total = result?.total || 0;
|
||||
uxState = runs.length === 0 ? 'empty' : 'populated';
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.history.load_failed'), 'error');
|
||||
uxState = 'empty';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
metrics = await fetchAllMetrics();
|
||||
} catch (err) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const result = await fetchJobs({ page_size: 100 });
|
||||
jobs = Array.isArray(result) ? result : (result?.items || result?.results || []);
|
||||
} catch (err) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(run) {
|
||||
selectedRun = run;
|
||||
selectedRunDetail = null;
|
||||
try {
|
||||
selectedRunDetail = await fetchRunDetail(run.id);
|
||||
uxState = 'detail_open';
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.history.load_detail_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
selectedRun = null;
|
||||
selectedRunDetail = null;
|
||||
uxState = runs.length === 0 ? 'empty' : 'populated';
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
currentPage = 1;
|
||||
loadRuns();
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
const map = {
|
||||
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';
|
||||
}
|
||||
|
||||
function getJobName(jobId) {
|
||||
const job = jobs.find(j => j.id === jobId);
|
||||
return job ? job.name : jobId?.substring(0, 8) + '...';
|
||||
}
|
||||
|
||||
async function handleCancelRun(runId) {
|
||||
cancellingRunId = runId;
|
||||
try {
|
||||
await cancelRun(runId);
|
||||
addToast($t.translate?.run?.run_cancelled || 'Run cancelled', 'success');
|
||||
// Refresh the detail panel and list
|
||||
if (selectedRunDetail?.id === runId) {
|
||||
selectedRunDetail = await fetchRunDetail(runId);
|
||||
}
|
||||
loadRuns();
|
||||
} catch (err) {
|
||||
addToast(err?.message || $t.translate?.run?.cancel_failed || 'Failed to cancel run', 'error');
|
||||
} finally {
|
||||
cancellingRunId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetryRun(runId) {
|
||||
retryingRunId = runId;
|
||||
try {
|
||||
await retryFailedBatches(runId);
|
||||
addToast($t.translate?.run?.retry_success || 'Retry started', 'success');
|
||||
// Refresh the detail panel and list
|
||||
if (selectedRunDetail?.id === runId) {
|
||||
selectedRunDetail = await fetchRunDetail(runId);
|
||||
}
|
||||
loadRuns();
|
||||
} catch (err) {
|
||||
addToast(err?.message || $t.translate?.run?.retry_failed_msg || 'Retry failed', 'error');
|
||||
} finally {
|
||||
retryingRunId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadSkipped(runId) {
|
||||
try {
|
||||
await downloadSkippedCsv(runId);
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.history.download_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadFailed(runId) {
|
||||
try {
|
||||
await downloadFailedCsv(runId);
|
||||
} catch (err) {
|
||||
addToast(err?.message || _('translate.history.download_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function totalMetric() {
|
||||
if (!metrics.length) return null;
|
||||
const agg = {
|
||||
total_runs: metrics.reduce((s, m) => s + (m.total_runs || 0), 0),
|
||||
successful_runs: metrics.reduce((s, m) => s + (m.successful_runs || 0), 0),
|
||||
failed_runs: metrics.reduce((s, m) => s + (m.failed_runs || 0), 0),
|
||||
total_records: metrics.reduce((s, m) => s + (m.total_records || 0), 0),
|
||||
};
|
||||
return agg;
|
||||
}
|
||||
onMount(() => {
|
||||
m.loadRuns();
|
||||
m.loadMetrics();
|
||||
m.loadJobs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text">{$t.translate?.history?.title}</h1>
|
||||
<p class="text-sm text-text-muted mt-1">{$t.translate?.history?.subtitle}</p>
|
||||
</div>
|
||||
<button onclick={() => { currentPage = 1; applyFilters(); }} class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover">
|
||||
{$t.translate?.history?.refresh}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text">{$t.translate?.history?.title}</h1>
|
||||
<p class="text-sm text-text-muted mt-1">{$t.translate?.history?.subtitle}</p>
|
||||
</div>
|
||||
<button onclick={() => { m.currentPage = 1; m.applyFilters(); }} class="px-4 py-2 text-sm text-white bg-primary rounded-lg hover:bg-primary-hover">{$t.translate?.history?.refresh}</button>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Summary -->
|
||||
{#if totalMetric()}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div class="bg-surface-muted rounded-lg p-3">
|
||||
<p class="text-xs text-text-muted uppercase">{$t.translate?.history?.total_runs}</p>
|
||||
<p class="text-xl font-bold text-text">{totalMetric().total_runs}</p>
|
||||
</div>
|
||||
<div class="bg-success-light rounded-lg p-3">
|
||||
<p class="text-xs text-success uppercase">{$t.translate?.history?.successful}</p>
|
||||
<p class="text-xl font-bold text-success">{totalMetric().successful_runs}</p>
|
||||
</div>
|
||||
<div class="bg-destructive-light rounded-lg p-3">
|
||||
<p class="text-xs text-destructive uppercase">{$t.translate?.history?.failed}</p>
|
||||
<p class="text-xl font-bold text-destructive">{totalMetric().failed_runs}</p>
|
||||
</div>
|
||||
<div class="bg-primary-light rounded-lg p-3">
|
||||
<p class="text-xs text-primary uppercase">{$t.translate?.history?.records}</p>
|
||||
<p class="text-xl font-bold text-primary">{totalMetric().total_records}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Metrics -->
|
||||
{#if m.totalMetric}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div class="bg-surface-muted rounded-lg p-3"><p class="text-xs text-text-muted uppercase">{$t.translate?.history?.total_runs}</p><p class="text-xl font-bold text-text">{m.totalMetric.total_runs}</p></div>
|
||||
<div class="bg-success-light rounded-lg p-3"><p class="text-xs text-success uppercase">{$t.translate?.history?.successful}</p><p class="text-xl font-bold text-success">{m.totalMetric.successful_runs}</p></div>
|
||||
<div class="bg-destructive-light rounded-lg p-3"><p class="text-xs text-destructive uppercase">{$t.translate?.history?.failed}</p><p class="text-xl font-bold text-destructive">{m.totalMetric.failed_runs}</p></div>
|
||||
<div class="bg-primary-light rounded-lg p-3"><p class="text-xs text-primary uppercase">{$t.translate?.history?.records}</p><p class="text-xl font-bold text-primary">{m.totalMetric.total_records}</p></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
<select bind:value={filterJobId} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.history?.all_jobs}</option>
|
||||
{#each jobs as job}
|
||||
<option value={job.id}>{job.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={filterStatus} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.history?.all_status}</option>
|
||||
<option value="PENDING">{$t.translate?.history?.status_pending}</option>
|
||||
<option value="RUNNING">{$t.translate?.history?.status_running}</option>
|
||||
<option value="COMPLETED">{$t.translate?.history?.status_completed}</option>
|
||||
<option value="FAILED">{$t.translate?.history?.status_failed}</option>
|
||||
<option value="CANCELLED">{$t.translate?.history?.status_cancelled}</option>
|
||||
</select>
|
||||
<select bind:value={filterTrigger} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.history?.all_triggers}</option>
|
||||
<option value="manual">{$t.translate?.history?.trigger_manual}</option>
|
||||
<option value="scheduled">{$t.translate?.history?.trigger_scheduled}</option>
|
||||
<option value="baseline_expired">{$t.translate?.history?.trigger_baseline_expired}</option>
|
||||
</select>
|
||||
<input type="date" bind:value={filterDateFrom} class="px-3 py-2 border border-border-strong rounded-lg text-sm" placeholder={$t.translate?.common?.from} />
|
||||
<button onclick={applyFilters} class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover">
|
||||
{$t.translate?.history?.filter}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
<select bind:value={m.filterJobId} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.history?.all_jobs}</option>
|
||||
{#each m.jobs as job}<option value={job.id}>{job.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={m.filterStatus} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.history?.all_status}</option>
|
||||
<option value="PENDING">{$t.translate?.history?.status_pending}</option>
|
||||
<option value="RUNNING">{$t.translate?.history?.status_running}</option>
|
||||
<option value="COMPLETED">{$t.translate?.history?.status_completed}</option>
|
||||
<option value="FAILED">{$t.translate?.history?.status_failed}</option>
|
||||
<option value="CANCELLED">{$t.translate?.history?.status_cancelled}</option>
|
||||
</select>
|
||||
<select bind:value={m.filterTrigger} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.history?.all_triggers}</option>
|
||||
<option value="manual">{$t.translate?.history?.trigger_manual}</option>
|
||||
<option value="scheduled">{$t.translate?.history?.trigger_scheduled}</option>
|
||||
<option value="baseline_expired">{$t.translate?.history?.trigger_baseline_expired}</option>
|
||||
</select>
|
||||
<input type="date" bind:value={m.filterDateFrom} class="px-3 py-2 border border-border-strong rounded-lg text-sm" placeholder={$t.translate?.common?.from} />
|
||||
<button onclick={() => m.applyFilters()} class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover">{$t.translate?.history?.filter}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if isLoading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-16 bg-surface-muted rounded-lg animate-pulse" />
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
{#if m.isLoading}
|
||||
<div class="space-y-3">{#each Array(5) as _}<div class="h-16 bg-surface-muted rounded-lg animate-pulse" />{/each}</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
{:else if uxState === 'empty'}
|
||||
<div class="bg-surface-muted border border-dashed border-border-strong rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 text-text-subtle mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p class="text-text-muted mb-2">{$t.translate?.history?.no_runs}</p>
|
||||
<p class="text-xs text-text-subtle">{$t.translate?.history?.no_runs_hint}</p>
|
||||
</div>
|
||||
<!-- Empty -->
|
||||
{:else if m.uxState === 'empty'}
|
||||
<div class="bg-surface-muted border border-dashed border-border-strong rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 text-text-subtle mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||
<p class="text-text-muted mb-2">{$t.translate?.history?.no_runs}</p>
|
||||
<p class="text-xs text-text-subtle">{$t.translate?.history?.no_runs_hint}</p>
|
||||
</div>
|
||||
|
||||
<!-- Populated state -->
|
||||
{:else if uxState === 'populated' || uxState === 'detail_open'}
|
||||
<div class="space-y-2">
|
||||
{#each runs as run}
|
||||
<div
|
||||
onclick={() => openDetail(run)}
|
||||
class="bg-surface-card border border-border rounded-lg p-3 hover:shadow-sm hover:border-border-strong transition-all cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-text">{getJobName(run.job_id)}</span>
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full {getStatusClass(run.status)}">
|
||||
{run.status}
|
||||
</span>
|
||||
{#if run.trigger_type}
|
||||
<span class="text-xs text-text-subtle bg-surface-muted px-2 py-0.5 rounded">{run.trigger_type}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-text-subtle">
|
||||
<span>{run.created_at ? new Date(run.created_at).toLocaleString() : ''}</span>
|
||||
<span>{_('translate.history.records_label').replace('{count}', run.total_records || 0)}</span>
|
||||
<span>{_('translate.history.ok_label').replace('{count}', run.successful_records || 0)}</span>
|
||||
<span>{_('translate.history.fail_label').replace('{count}', run.failed_records || 0)}</span>
|
||||
<span class="text-info">Cache: {run.cache_hits || 0}</span>
|
||||
{#if run.created_by}
|
||||
<span>{_('translate.history.by_label').replace('{user}', run.created_by)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Languages badges -->
|
||||
{#if run.target_languages && run.target_languages.length > 0}
|
||||
<div class="flex gap-1 mt-1">
|
||||
{#each run.target_languages as lang}
|
||||
<span class="px-1.5 py-0.5 text-xs rounded bg-primary-light text-primary">{lang}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-2" onclick={(e) => e.stopPropagation()}>
|
||||
{#if run.status === 'PENDING' || run.status === 'RUNNING'}
|
||||
<button
|
||||
onclick={() => handleCancelRun(run.id)}
|
||||
disabled={cancellingRunId === run.id}
|
||||
class="px-2 py-1 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingRunId === run.id
|
||||
? ($t.translate?.run?.cancelling || '...')
|
||||
: ($t.translate?.run?.cancel || 'Cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if run.status === 'FAILED'}
|
||||
<button
|
||||
onclick={() => handleRetryRun(run.id)}
|
||||
disabled={retryingRunId === run.id}
|
||||
class="px-2 py-1 text-xs bg-warning-light text-warning rounded hover:bg-warning-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{retryingRunId === run.id
|
||||
? ($t.translate?.run?.retrying || '...')
|
||||
: ($t.translate?.run?.retry_failed || 'Retry')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if run.status === 'COMPLETED' && (run.skipped_records || 0) > 0}
|
||||
<button
|
||||
onclick={() => handleDownloadSkipped(run.id)}
|
||||
class="text-xs text-primary hover:text-primary-hover"
|
||||
>
|
||||
{$t.translate?.history?.skipped_csv}
|
||||
</button>
|
||||
{/if}
|
||||
{#if run.status === 'FAILED' && (run.successful_records || 0) > 0}
|
||||
<button
|
||||
onclick={() => handleDownloadFailed(run.id)}
|
||||
class="text-xs text-primary hover:text-primary-hover"
|
||||
>
|
||||
{$t.translate?.history?.failed_csv || 'Download CSV'}
|
||||
</button>
|
||||
{/if}
|
||||
<svg class="w-4 h-4 text-text-subtle" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Populated / Detail -->
|
||||
{:else if m.uxState === 'populated' || m.uxState === 'detail_open'}
|
||||
<div class="space-y-2">
|
||||
{#each m.runs as run}
|
||||
<div onclick={() => 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">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-text">{m.getJobName(run.job_id as string)}</span>
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full {m.getStatusClass(run.status as string)}">{run.status}</span>
|
||||
{#if run.trigger_type}<span class="text-xs text-text-subtle bg-surface-muted px-2 py-0.5 rounded">{run.trigger_type}</span>{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-text-subtle">
|
||||
<span>{run.created_at ? new Date(run.created_at as string).toLocaleString() : ''}</span>
|
||||
<span>{_('translate.history.records_label').replace('{count}', String(run.total_records || 0))}</span>
|
||||
<span>{_('translate.history.ok_label').replace('{count}', String(run.successful_records || 0))}</span>
|
||||
<span>{_('translate.history.fail_label').replace('{count}', String(run.failed_records || 0))}</span>
|
||||
<span class="text-info">Cache: {run.cache_hits || 0}</span>
|
||||
{#if run.created_by}<span>{_('translate.history.by_label').replace('{user}', String(run.created_by))}</span>{/if}
|
||||
</div>
|
||||
{#if (run.target_languages as string[])?.length > 0}
|
||||
<div class="flex gap-1 mt-1">
|
||||
{#each run.target_languages as lang}<span class="px-1.5 py-0.5 text-xs rounded bg-primary-light text-primary">{lang}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-2" onclick={(e) => e.stopPropagation()}>
|
||||
{#if run.status === 'PENDING' || run.status === 'RUNNING'}
|
||||
<button onclick={() => m.handleCancelRun(run.id as string)} disabled={m.cancellingRunId === run.id} class="px-2 py-1 text-xs bg-destructive-light text-destructive rounded hover:bg-destructive-light disabled:opacity-50 transition-colors">
|
||||
{m.cancellingRunId === run.id ? ($t.translate?.run?.cancelling || '...') : ($t.translate?.run?.cancel || 'Cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if run.status === 'FAILED'}
|
||||
<button onclick={() => m.handleRetryRun(run.id as string)} disabled={m.retryingRunId === run.id} class="px-2 py-1 text-xs bg-warning-light text-warning rounded hover:bg-warning-light disabled:opacity-50 transition-colors">
|
||||
{m.retryingRunId === run.id ? ($t.translate?.run?.retrying || '...') : ($t.translate?.run?.retry_failed || 'Retry')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if run.status === 'COMPLETED' && (run.skipped_records as number || 0) > 0}
|
||||
<button onclick={() => m.handleDownloadSkipped(run.id as string)} class="text-xs text-primary hover:text-primary-hover">{$t.translate?.history?.skipped_csv}</button>
|
||||
{/if}
|
||||
{#if run.status === 'FAILED' && (run.successful_records as number || 0) > 0}
|
||||
<button onclick={() => m.handleDownloadFailed(run.id as string)} class="text-xs text-primary hover:text-primary-hover">{$t.translate?.history?.failed_csv}</button>
|
||||
{/if}
|
||||
<svg class="w-4 h-4 text-text-subtle" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-text-muted">{_('translate.history.showing').replace('{count}', runs.length).replace('{total}', total)}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => { currentPage--; loadRuns(); }}
|
||||
disabled={currentPage <= 1}
|
||||
class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{$t.translate?.history?.previous}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { currentPage++; loadRuns(); }}
|
||||
disabled={currentPage * pageSize >= total}
|
||||
class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{$t.translate?.history?.next}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-text-muted">{_('translate.history.showing').replace('{count}', String(m.runs.length)).replace('{total}', String(m.total))}</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => { m.currentPage--; m.loadRuns(); }} disabled={m.currentPage <= 1} class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-50">{$t.translate?.history?.previous}</button>
|
||||
<button onclick={() => { m.currentPage++; m.loadRuns(); }} disabled={m.currentPage * m.pageSize >= m.total} class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-50">{$t.translate?.history?.next}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Detail panel (slide-over) -->
|
||||
{#if uxState === 'detail_open' && selectedRunDetail}
|
||||
<div class="fixed inset-0 z-50 bg-black/30" onclick={closeDetail}></div>
|
||||
<div class="fixed right-0 top-0 h-full w-full max-w-2xl bg-surface-card shadow-xl z-50 overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-text">{$t.translate?.history?.run_detail}</h2>
|
||||
<button onclick={closeDetail} class="text-text-subtle hover:text-text-muted">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status row -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<p class="text-xs text-text-muted uppercase">{$t.translate?.history?.status}</p>
|
||||
<span class="inline-flex px-2 py-1 text-sm rounded-full {getStatusClass(selectedRunDetail.status)}">
|
||||
{selectedRunDetail.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-muted uppercase">{$t.translate?.history?.trigger}</p>
|
||||
<p class="text-sm font-medium text-text">{selectedRunDetail.trigger_type || 'manual'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-muted uppercase">{$t.translate?.history?.started}</p>
|
||||
<p class="text-sm text-text">{selectedRunDetail.started_at ? new Date(selectedRunDetail.started_at).toLocaleString() : '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-muted uppercase">{$t.translate?.history?.completed}</p>
|
||||
<p class="text-sm text-text">{selectedRunDetail.completed_at ? new Date(selectedRunDetail.completed_at).toLocaleString() : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-5 gap-3 mb-6">
|
||||
<div class="bg-surface-muted rounded p-3 text-center">
|
||||
<p class="text-lg font-bold text-text">{selectedRunDetail.total_records || 0}</p>
|
||||
<p class="text-xs text-text-muted">{$t.translate?.history?.total}</p>
|
||||
</div>
|
||||
<div class="bg-success-light rounded p-3 text-center">
|
||||
<p class="text-lg font-bold text-success">{selectedRunDetail.successful_records || 0}</p>
|
||||
<p class="text-xs text-success">{$t.translate?.history?.success}</p>
|
||||
</div>
|
||||
<div class="bg-destructive-light rounded p-3 text-center">
|
||||
<p class="text-lg font-bold text-destructive">{selectedRunDetail.failed_records || 0}</p>
|
||||
<p class="text-xs text-destructive">{$t.translate?.history?.failed}</p>
|
||||
</div>
|
||||
<div class="bg-warning-light rounded p-3 text-center">
|
||||
<p class="text-lg font-bold text-warning">{selectedRunDetail.skipped_records || 0}</p>
|
||||
<p class="text-xs text-warning">{$t.translate?.history?.skipped}</p>
|
||||
</div>
|
||||
<div class="bg-info-light rounded p-3 text-center">
|
||||
<p class="text-lg font-bold text-info">{selectedRunDetail.cache_hits || 0}</p>
|
||||
<p class="text-xs text-info">Cache</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-Language Statistics -->
|
||||
{#if selectedRunDetail.language_stats && Object.keys(selectedRunDetail.language_stats).length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-text mb-2">{$t.translate?.run?.per_language}</h3>
|
||||
<div class="space-y-2">
|
||||
{#each Object.entries(selectedRunDetail.language_stats) as [lang, stats]}
|
||||
<div class="bg-surface-muted rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-text">{lang}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2 text-xs text-text-muted">
|
||||
<span>{_('translate.run.translated_label').replace('{count}', stats.translated_rows)}</span>
|
||||
<span>{_('translate.run.failed_label').replace('{count}', stats.failed_rows)}</span>
|
||||
<span>{_('translate.run.skipped_label').replace('{count}', stats.skipped_rows)}</span>
|
||||
<span>{_('translate.run.tokens_label').replace('{count}', stats.token_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
{#if selectedRunDetail.status === 'PENDING' || selectedRunDetail.status === 'RUNNING'}
|
||||
<button
|
||||
onclick={() => handleCancelRun(selectedRunDetail.id)}
|
||||
disabled={cancellingRunId === selectedRunDetail.id}
|
||||
class="px-4 py-2 text-sm bg-destructive text-white rounded-lg hover:bg-destructive-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{cancellingRunId === selectedRunDetail.id
|
||||
? ($t.translate?.run?.cancelling || 'Cancelling...')
|
||||
: ($t.translate?.run?.cancel || 'Cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if selectedRunDetail.status === 'FAILED'}
|
||||
<button
|
||||
onclick={() => handleRetryRun(selectedRunDetail.id)}
|
||||
disabled={retryingRunId === selectedRunDetail.id}
|
||||
class="px-4 py-2 text-sm bg-warning text-white rounded-lg hover:bg-warning disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{retryingRunId === selectedRunDetail.id
|
||||
? ($t.translate?.run?.retrying || 'Retrying...')
|
||||
: ($t.translate?.run?.retry_failed || 'Retry Failed')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Config Snapshot -->
|
||||
{#if selectedRunDetail.config_snapshot}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-text mb-2">{$t.translate?.history?.config_snapshot}</h3>
|
||||
<div class="bg-surface-muted rounded-lg p-3">
|
||||
<pre class="text-xs text-text-muted overflow-x-auto">{JSON.stringify(selectedRunDetail.config_snapshot, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hash info -->
|
||||
{#if selectedRunDetail.config_hash || selectedRunDetail.dict_snapshot_hash}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-text mb-2">{$t.translate?.history?.snapshots}</h3>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs font-mono text-text-muted">
|
||||
{#if selectedRunDetail.config_hash}
|
||||
<div class="bg-surface-muted rounded p-2">Config: {selectedRunDetail.config_hash}</div>
|
||||
{/if}
|
||||
{#if selectedRunDetail.dict_snapshot_hash}
|
||||
<div class="bg-surface-muted rounded p-2">Dict: {selectedRunDetail.dict_snapshot_hash}</div>
|
||||
{/if}
|
||||
{#if selectedRunDetail.key_hash}
|
||||
<div class="bg-surface-muted rounded p-2">Key: {selectedRunDetail.key_hash}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Events -->
|
||||
{#if selectedRunDetail.events && selectedRunDetail.events.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-text mb-2">{$t.translate?.history?.events.replace('{count}', selectedRunDetail.events.length)}</h3>
|
||||
<div class="space-y-1 max-h-48 overflow-y-auto">
|
||||
{#each selectedRunDetail.events as evt}
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted bg-surface-muted rounded px-2 py-1">
|
||||
<span class="font-medium text-text">{evt.event_type}</span>
|
||||
<span class="text-text-subtle">|</span>
|
||||
<span>{evt.created_at ? new Date(evt.created_at).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if selectedRunDetail.error_message}
|
||||
<div class="bg-destructive-light border border-destructive-light rounded-lg p-3 mb-6">
|
||||
<p class="text-xs font-medium text-destructive">{$t.translate?.history?.error}</p>
|
||||
<p class="text-xs text-destructive mt-1">{selectedRunDetail.error_message}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Superset ref -->
|
||||
{#if selectedRunDetail.superset_execution_id}
|
||||
<div class="bg-primary-light rounded-lg p-3 mb-6">
|
||||
<p class="text-xs font-medium text-primary">{$t.translate?.history?.superset_query_id}</p>
|
||||
<p class="text-xs font-mono text-primary mt-1">{selectedRunDetail.superset_execution_id}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Detail panel -->
|
||||
{#if m.uxState === 'detail_open' && m.selectedRunDetail}
|
||||
<div class="fixed inset-0 z-50 bg-black/30" onclick={() => m.closeDetail()}></div>
|
||||
<div class="fixed right-0 top-0 h-full w-full max-w-2xl bg-surface-card shadow-xl z-50 overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-text">{$t.translate?.history?.run_detail}</h2>
|
||||
<button onclick={() => m.closeDetail()} class="text-text-subtle hover:text-text-muted">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Status -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div><p class="text-xs text-text-muted uppercase">{$t.translate?.history?.status}</p><span class="inline-flex px-2 py-1 text-sm rounded-full {m.getStatusClass(m.selectedRunDetail.status as string)}">{m.selectedRunDetail.status}</span></div>
|
||||
<div><p class="text-xs text-text-muted uppercase">{$t.translate?.history?.trigger}</p><p class="text-sm font-medium text-text">{m.selectedRunDetail.trigger_type || 'manual'}</p></div>
|
||||
<div><p class="text-xs text-text-muted uppercase">{$t.translate?.history?.started}</p><p class="text-sm text-text">{m.selectedRunDetail.started_at ? new Date(m.selectedRunDetail.started_at as string).toLocaleString() : '-'}</p></div>
|
||||
<div><p class="text-xs text-text-muted uppercase">{$t.translate?.history?.completed}</p><p class="text-sm text-text">{m.selectedRunDetail.completed_at ? new Date(m.selectedRunDetail.completed_at as string).toLocaleString() : '-'}</p></div>
|
||||
</div>
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-5 gap-3 mb-6">
|
||||
<div class="bg-surface-muted rounded p-3 text-center"><p class="text-lg font-bold text-text">{m.selectedRunDetail.total_records || 0}</p><p class="text-xs text-text-muted">{$t.translate?.history?.total}</p></div>
|
||||
<div class="bg-success-light rounded p-3 text-center"><p class="text-lg font-bold text-success">{m.selectedRunDetail.successful_records || 0}</p><p class="text-xs text-success">{$t.translate?.history?.success}</p></div>
|
||||
<div class="bg-destructive-light rounded p-3 text-center"><p class="text-lg font-bold text-destructive">{m.selectedRunDetail.failed_records || 0}</p><p class="text-xs text-destructive">{$t.translate?.history?.failed}</p></div>
|
||||
<div class="bg-warning-light rounded p-3 text-center"><p class="text-lg font-bold text-warning">{m.selectedRunDetail.skipped_records || 0}</p><p class="text-xs text-warning">{$t.translate?.history?.skipped}</p></div>
|
||||
<div class="bg-info-light rounded p-3 text-center"><p class="text-lg font-bold text-info">{m.selectedRunDetail.cache_hits || 0}</p><p class="text-xs text-info">Cache</p></div>
|
||||
</div>
|
||||
<!-- Per-language -->
|
||||
{#if m.selectedRunDetail.language_stats && Object.keys(m.selectedRunDetail.language_stats as Record<string, unknown>).length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-text mb-2">{$t.translate?.run?.per_language}</h3>
|
||||
<div class="space-y-2">
|
||||
{#each Object.entries(m.selectedRunDetail.language_stats as Record<string, unknown>) as [lang, stats]}
|
||||
<div class="bg-surface-muted rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1"><span class="text-sm font-medium text-text">{lang}</span></div>
|
||||
<div class="grid grid-cols-4 gap-2 text-xs text-text-muted">
|
||||
<span>{_('translate.run.translated_label').replace('{count}', String((stats as Record<string, unknown>).translated_rows))}</span>
|
||||
<span>{_('translate.run.failed_label').replace('{count}', String((stats as Record<string, unknown>).failed_rows))}</span>
|
||||
<span>{_('translate.run.skipped_label').replace('{count}', String((stats as Record<string, unknown>).skipped_rows))}</span>
|
||||
<span>{_('translate.run.tokens_label').replace('{count}', String((stats as Record<string, unknown>).token_count))}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
{#if m.selectedRunDetail.status === 'PENDING' || m.selectedRunDetail.status === 'RUNNING'}
|
||||
<button onclick={() => m.handleCancelRun(m.selectedRunDetail!.id as string)} disabled={m.cancellingRunId === m.selectedRunDetail!.id} class="px-4 py-2 text-sm bg-destructive text-white rounded-lg hover:bg-destructive-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{m.cancellingRunId === m.selectedRunDetail!.id ? ($t.translate?.run?.cancelling || 'Cancelling...') : ($t.translate?.run?.cancel || 'Cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if m.selectedRunDetail.status === 'FAILED'}
|
||||
<button onclick={() => m.handleRetryRun(m.selectedRunDetail!.id as string)} disabled={m.retryingRunId === m.selectedRunDetail!.id} class="px-4 py-2 text-sm bg-warning text-white rounded-lg hover:bg-warning disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
{m.retryingRunId === m.selectedRunDetail!.id ? ($t.translate?.run?.retrying || 'Retrying...') : ($t.translate?.run?.retry_failed || 'Retry Failed')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Config snapshot -->
|
||||
{#if m.selectedRunDetail.config_snapshot}
|
||||
<div class="mb-6"><h3 class="text-sm font-semibold text-text mb-2">{$t.translate?.history?.config_snapshot}</h3><div class="bg-surface-muted rounded-lg p-3"><pre class="text-xs text-text-muted overflow-x-auto">{JSON.stringify(m.selectedRunDetail.config_snapshot, null, 2)}</pre></div></div>
|
||||
{/if}
|
||||
<!-- Hashes -->
|
||||
{#if m.selectedRunDetail.config_hash || m.selectedRunDetail.dict_snapshot_hash}
|
||||
<div class="mb-6"><h3 class="text-sm font-semibold text-text mb-2">{$t.translate?.history?.snapshots}</h3>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs font-mono text-text-muted">
|
||||
{#if m.selectedRunDetail.config_hash}<div class="bg-surface-muted rounded p-2">Config: {m.selectedRunDetail.config_hash}</div>{/if}
|
||||
{#if m.selectedRunDetail.dict_snapshot_hash}<div class="bg-surface-muted rounded p-2">Dict: {m.selectedRunDetail.dict_snapshot_hash}</div>{/if}
|
||||
{#if m.selectedRunDetail.key_hash}<div class="bg-surface-muted rounded p-2">Key: {m.selectedRunDetail.key_hash}</div>{/if}
|
||||
</div></div>
|
||||
{/if}
|
||||
<!-- Events -->
|
||||
{#if (m.selectedRunDetail.events as unknown[])?.length > 0}
|
||||
<div class="mb-6"><h3 class="text-sm font-semibold text-text mb-2">{($t.translate?.history?.events as string)?.replace('{count}', String((m.selectedRunDetail.events as unknown[]).length))}</h3>
|
||||
<div class="space-y-1 max-h-48 overflow-y-auto">
|
||||
{#each m.selectedRunDetail.events as evt}
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted bg-surface-muted rounded px-2 py-1">
|
||||
<span class="font-medium text-text">{(evt as Record<string, unknown>).event_type}</span>
|
||||
<span class="text-text-subtle">|</span>
|
||||
<span>{(evt as Record<string, unknown>).created_at ? new Date((evt as Record<string, unknown>).created_at as string).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div></div>
|
||||
{/if}
|
||||
<!-- Error -->
|
||||
{#if m.selectedRunDetail.error_message}
|
||||
<div class="bg-destructive-light border border-destructive-light rounded-lg p-3 mb-6"><p class="text-xs font-medium text-destructive">{$t.translate?.history?.error}</p><p class="text-xs text-destructive mt-1">{m.selectedRunDetail.error_message}</p></div>
|
||||
{/if}
|
||||
{#if m.selectedRunDetail.superset_execution_id}
|
||||
<div class="bg-primary-light rounded-lg p-3 mb-6"><p class="text-xs font-medium text-primary">{$t.translate?.history?.superset_query_id}</p><p class="text-xs font-mono text-primary mt-1">{m.selectedRunDetail.superset_execution_id}</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion TranslateHistoryPage -->
|
||||
|
||||
Reference in New Issue
Block a user