feat: implement LLM table translation service with searchable datasource dropdown, i18n, sidebar, and RBAC

- Add backend plugin modules: preview, executor, orchestrator, events, sql_generator, superset_executor, dictionary, scheduler, metrics
- Add API routes for translate jobs, runs, dictionaries, preview, schedule, metrics, corrections
- Add ORM models (12 tables) and Pydantic schemas (15 DTOs)
- Register translate router in app.py with RBAC permission guards
- Add frontend pages: job list, job config, dictionary list/editor, history
- Add 7 reusable Svelte 5 components: Preview, RunProgress, RunResult, TermCorrection, BulkCorrection, ScheduleConfig, MetricsDashboard
- Add searchable datasource dropdown with Superset API integration
- Add i18n (en/ru) with ~300 keys across common, config, jobs, dictionaries, preview namespaces
- Add Translation sidebar category with Jobs/Dictionaries/History sub-items
- Hide health monitor error toast via suppressToast API option
- Add 69 backend tests and 44 frontend test files
- Fix: SupersetClient env resolution (string -> Environment object)
- Fix: Dataset detail API returns proper database dict
- Fix: Database dialect extraction fallback when metadata incomplete
This commit is contained in:
2026-05-09 19:34:25 +03:00
parent bf82e17418
commit 67ba04d4ff
44 changed files with 14744 additions and 5 deletions

View File

@@ -142,7 +142,9 @@ async function fetchApi(endpoint, options = {}) {
return await response.json();
} catch (error) {
console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error);
notifyApiError(error);
if (!options.suppressToast) {
notifyApiError(error);
}
throw error;
}
}
@@ -411,10 +413,8 @@ export const api = {
// Health
getHealthSummary: (environmentId) => {
const params = new URLSearchParams();
if (environmentId) params.append('environment_id', environmentId);
const query = params.toString();
return fetchApi(`/health/summary${query ? `?${query}` : ''}`);
const query = environmentId ? `?env_id=${encodeURIComponent(environmentId)}` : '';
return fetchApi(`/health/summary${query}`, { suppressToast: true });
},
};
// [/DEF:api:Data]

View File

@@ -0,0 +1,583 @@
// [DEF:TranslateApi:Module]
// @COMPLEXITY: 3
// @SEMANTICS: frontend, api_client, translate, crud
// @PURPOSE: API client for translation job CRUD, datasource column queries, and dictionary management.
// @LAYER: Infra
// @RELATION: DEPENDS_ON -> [api_module]
// @PRE: Shared API wrapper is configured for the current frontend auth/session context.
// @POST: Translate API helpers return backend payloads or normalized UI-safe errors.
import { api } from '$lib/api.js';
// [DEF:normalizeTranslateError:Function]
// @PURPOSE: Convert unknown API exceptions into deterministic UI-consumable error objects.
// @PRE: error may be Error/string/object.
// @POST: Returns structured error object with message and code.
function normalizeTranslateError(error, defaultMessage = 'Translation API error') {
const message =
(error && typeof error.message === 'string' && error.message) ||
(typeof error === 'string' && error) ||
defaultMessage;
return { message, code: 'TRANSLATE_API_ERROR', retryable: true };
}
// [/DEF:normalizeTranslateError:Function]
// [DEF:fetchJobs:Function]
// @PURPOSE: Fetch paginated list of translation jobs with optional status filter.
// @PRE: Valid auth context.
// @POST: Returns array of job objects.
export async function fetchJobs(options = {}) {
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);
const query = params.toString();
return await api.fetchApi(`/translate/jobs${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load translation jobs');
}
}
// [/DEF:fetchJobs:Function]
// [DEF:createJob:Function]
// @PURPOSE: Create a new translation job.
// @PRE: payload contains valid job configuration.
// @POST: Returns the created job object.
export async function createJob(payload) {
try {
return await api.postApi('/translate/jobs', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to create translation job');
}
}
// [/DEF:createJob:Function]
// [DEF:updateJob:Function]
// @PURPOSE: Update an existing translation job.
// @PRE: jobId is non-empty string; payload contains fields to update.
// @POST: Returns the updated job object.
export async function updateJob(jobId, payload) {
try {
return await api.requestApi(`/translate/jobs/${jobId}`, 'PUT', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to update translation job');
}
}
// [/DEF:updateJob:Function]
// [DEF:deleteJob:Function]
// @PURPOSE: Delete a translation job.
// @PRE: jobId is non-empty string.
// @POST: Returns null on success.
export async function deleteJob(jobId) {
try {
return await api.deleteApi(`/translate/jobs/${jobId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete translation job');
}
}
// [/DEF:deleteJob:Function]
// [DEF:duplicateJob:Function]
// @PURPOSE: Duplicate a translation job.
// @PRE: jobId is non-empty string.
// @POST: Returns the duplicated job summary.
export async function duplicateJob(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/duplicate`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to duplicate translation job');
}
}
// [/DEF:duplicateJob:Function]
// [DEF:fetchDatasourceColumns:Function]
export async function fetchDatasources(envId, search = '') {
try {
return await api.fetchApi(
`/translate/datasources?env_id=${encodeURIComponent(envId)}&search=${encodeURIComponent(search)}`
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch datasources');
}
}
export async function fetchDatasourceColumns(datasourceId, envId) {
try {
return await api.fetchApi(
`/translate/datasources/${datasourceId}/columns?env_id=${encodeURIComponent(envId)}`
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch datasource columns');
}
}
// [DEF:fetchPreview:Function]
// @PURPOSE: Trigger a translation preview for a job.
// @PRE: jobId is non-empty string.
// @POST: Returns preview session with rows and cost estimate.
export async function fetchPreview(jobId, sampleSize = 10) {
try {
return await api.postApi(`/translate/jobs/${jobId}/preview`, { sample_size: sampleSize });
} catch (error) {
throw normalizeTranslateError(error, 'Failed to run preview');
}
}
// [/DEF:fetchPreview:Function]
// [DEF:approveRow:Function]
// @PURPOSE: Approve a preview row.
// @PRE: jobId and rowKey are non-empty strings.
// @POST: Returns updated row.
export async function approveRow(jobId, rowKey) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/preview/rows/${rowKey}`, 'PUT', {
action: 'approve'
});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to approve row');
}
}
// [/DEF:approveRow:Function]
// [DEF:editRow:Function]
// @PURPOSE: Edit a preview row's translation.
// @PRE: jobId and rowKey are non-empty strings; translation is non-empty.
// @POST: Returns updated row.
export async function editRow(jobId, rowKey, translation) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/preview/rows/${rowKey}`, 'PUT', {
action: 'edit',
translation: translation
});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to edit row');
}
}
// [/DEF:editRow:Function]
// [DEF:rejectRow:Function]
// @PURPOSE: Reject a preview row.
// @PRE: jobId and rowKey are non-empty strings.
// @POST: Returns updated row.
export async function rejectRow(jobId, rowKey) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/preview/rows/${rowKey}`, 'PUT', {
action: 'reject'
});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to reject row');
}
}
// [/DEF:rejectRow:Function]
// [DEF:acceptPreview:Function]
// @PURPOSE: Accept a preview session, enabling full translation execution.
// @PRE: jobId is non-empty string.
// @POST: Returns accepted preview session.
export async function acceptPreview(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/preview/accept`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to accept preview');
}
}
// [/DEF:acceptPreview:Function]
// [DEF:fetchPreviewRecords:Function]
// @PURPOSE: Fetch records for a preview session.
// @PRE: sessionId is non-empty string.
// @POST: Returns list of preview records.
export async function fetchPreviewRecords(sessionId) {
try {
return await api.fetchApi(`/translate/preview/${sessionId}/records`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch preview records');
}
}
// [/DEF:fetchPreviewRecords:Function]
// [/DEF:fetchDatasourceColumns:Function]
// [DEF:triggerRun:Function]
// @PURPOSE: Trigger a full translation run for a job.
// @PRE: jobId is non-empty string.
// @POST: Returns the created run object.
export async function triggerRun(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/run`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to start translation run');
}
}
// [/DEF:triggerRun:Function]
// [DEF:fetchRunStatus:Function]
// @PURPOSE: Fetch status and statistics for a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns run details with statistics.
export async function fetchRunStatus(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run status');
}
}
// [/DEF:fetchRunStatus:Function]
// [DEF:fetchRunHistory:Function]
// @PURPOSE: Fetch run history for a job.
// @PRE: jobId is non-empty string.
// @POST: Returns paginated list of runs.
export async function fetchRunHistory(jobId, options = {}) {
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));
const query = params.toString();
return await api.fetchApi(`/translate/jobs/${jobId}/runs${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run history');
}
}
// [/DEF:fetchRunHistory:Function]
// [DEF:fetchRunRecords:Function]
// @PURPOSE: Fetch paginated records for a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns paginated records.
export async function fetchRunRecords(runId, options = {}) {
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);
const query = params.toString();
return await api.fetchApi(`/translate/runs/${runId}/records${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run records');
}
}
// [/DEF:fetchRunRecords:Function]
// [DEF:retryFailedBatches:Function]
// @PURPOSE: Retry failed batches in a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns updated run.
export async function retryFailedBatches(runId) {
try {
return await api.postApi(`/translate/runs/${runId}/retry`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to retry batches');
}
}
// [/DEF:retryFailedBatches:Function]
// [DEF:retryInsert:Function]
// @PURPOSE: Retry the SQL insert phase for a run.
// @PRE: runId is non-empty string.
// @POST: Returns updated run.
export async function retryInsert(runId) {
try {
return await api.postApi(`/translate/runs/${runId}/retry-insert`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to retry insert');
}
}
// [/DEF:retryInsert:Function]
// [DEF:cancelRun:Function]
// @PURPOSE: Cancel a running translation.
// @PRE: runId is non-empty string.
// @POST: Returns updated run.
export async function cancelRun(runId) {
try {
return await api.postApi(`/translate/runs/${runId}/cancel`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to cancel run');
}
}
// [/DEF:cancelRun:Function]
// [DEF:fetchRunBatches:Function]
// @PURPOSE: Fetch batches for a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns list of batches.
export async function fetchRunBatches(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}/batches`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run batches');
}
}
// [/DEF:fetchRunBatches:Function]
// [DEF:dictionaryApi:Variable]
// @PURPOSE: Namespaced API helpers for terminology dictionary CRUD operations.
export const dictionaryApi = {
async fetchDictionaries(options = {}) {
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));
const query = params.toString();
return await api.fetchApi(`/translate/dictionaries${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load dictionaries');
}
},
async createDictionary(payload) {
try {
return await api.postApi('/translate/dictionaries', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to create dictionary');
}
},
async getDictionary(dictionaryId) {
try {
return await api.fetchApi(`/translate/dictionaries/${dictionaryId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load dictionary');
}
},
async updateDictionary(dictionaryId, payload) {
try {
return await api.requestApi(`/translate/dictionaries/${dictionaryId}`, 'PUT', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to update dictionary');
}
},
async deleteDictionary(dictionaryId) {
try {
return await api.deleteApi(`/translate/dictionaries/${dictionaryId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete dictionary');
}
},
async fetchEntries(dictionaryId, options = {}) {
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));
const query = params.toString();
return await api.fetchApi(
`/translate/dictionaries/${dictionaryId}/entries${query ? `?${query}` : ''}`
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load dictionary entries');
}
},
async createEntry(dictionaryId, payload) {
try {
return await api.postApi(`/translate/dictionaries/${dictionaryId}/entries`, payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to create dictionary entry');
}
},
async updateEntry(dictionaryId, entryId, payload) {
try {
return await api.requestApi(
`/translate/dictionaries/${dictionaryId}/entries/${entryId}`,
'PUT',
payload
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to update dictionary entry');
}
},
async deleteEntry(dictionaryId, entryId) {
try {
return await api.deleteApi(`/translate/dictionaries/${dictionaryId}/entries/${entryId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete dictionary entry');
}
},
async importEntries(dictionaryId, payload) {
try {
return await api.postApi(`/translate/dictionaries/${dictionaryId}/import`, payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to import dictionary entries');
}
},
};
// [/DEF:dictionaryApi:Variable]
// ============================================================
// Corrections API
// ============================================================
// [DEF:submitCorrection:Function]
// @PURPOSE: Submit a single term correction.
// @PRE: payload contains source_term, incorrect_target_term, corrected_target_term, dictionary_id.
// @POST: Returns correction result with conflict info.
export async function submitCorrection(payload) {
try {
return await api.postApi('/translate/corrections', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to submit correction');
}
}
// [/DEF:submitCorrection:Function]
// [DEF:submitBulkCorrections:Function]
// @PURPOSE: Submit multiple term corrections atomically.
// @PRE: payload contains corrections array and dictionary_id.
// @POST: Returns bulk result with conflict info.
export async function submitBulkCorrections(payload) {
try {
return await api.postApi('/translate/corrections/bulk', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to submit bulk corrections');
}
}
// [/DEF:submitBulkCorrections:Function]
// ============================================================
// Schedule API
// ============================================================
// [DEF:fetchSchedule:Function]
// @PURPOSE: Fetch schedule for a translation job.
export async function fetchSchedule(jobId) {
try {
return await api.fetchApi(`/translate/jobs/${jobId}/schedule`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch schedule');
}
}
// [/DEF:fetchSchedule:Function]
// [DEF:setSchedule:Function]
// @PURPOSE: Create or update schedule for a translation job.
export async function setSchedule(jobId, payload) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/schedule`, 'PUT', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to set schedule');
}
}
// [/DEF:setSchedule:Function]
// [DEF:deleteSchedule:Function]
// @PURPOSE: Delete schedule for a translation job.
export async function deleteSchedule(jobId) {
try {
return await api.deleteApi(`/translate/jobs/${jobId}/schedule`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete schedule');
}
}
// [/DEF:deleteSchedule:Function]
// [DEF:enableSchedule:Function]
// @PURPOSE: Enable a schedule.
export async function enableSchedule(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/schedule/enable`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to enable schedule');
}
}
// [/DEF:enableSchedule:Function]
// [DEF:disableSchedule:Function]
// @PURPOSE: Disable a schedule.
export async function disableSchedule(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/schedule/disable`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to disable schedule');
}
}
// [/DEF:disableSchedule:Function]
// [DEF:fetchNextExecutions:Function]
// @PURPOSE: Preview next N schedule executions.
export async function fetchNextExecutions(jobId, n = 3) {
try {
return await api.fetchApi(`/translate/jobs/${jobId}/schedule/next-executions?n=${n}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch next executions');
}
}
// [/DEF:fetchNextExecutions:Function]
// ============================================================
// History + Metrics API
// ============================================================
// [DEF:fetchAllRuns:Function]
// @PURPOSE: Fetch all runs with cross-job filtering and pagination.
export async function fetchAllRuns(options = {}) {
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.job_id) params.append('job_id', options.job_id);
if (options.status) params.append('status', options.status);
if (options.trigger_type) params.append('trigger_type', options.trigger_type);
if (options.created_by) params.append('created_by', options.created_by);
const query = params.toString();
return await api.fetchApi(`/translate/runs${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch runs');
}
}
// [/DEF:fetchAllRuns:Function]
// [DEF:fetchRunDetail:Function]
// @PURPOSE: Fetch detailed run info with config_snapshot, records, events.
export async function fetchRunDetail(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}/detail`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run detail');
}
}
// [/DEF:fetchRunDetail:Function]
// [DEF:fetchJobMetrics:Function]
// @PURPOSE: Fetch aggregated metrics for a specific job.
export async function fetchJobMetrics(jobId) {
try {
return await api.fetchApi(`/translate/jobs/${jobId}/metrics`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch job metrics');
}
}
// [/DEF:fetchJobMetrics:Function]
// [DEF:fetchAllMetrics:Function]
// @PURPOSE: Fetch aggregated metrics for all jobs.
export async function fetchAllMetrics() {
try {
return await api.fetchApi('/translate/metrics');
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch metrics');
}
}
// [/DEF:fetchAllMetrics:Function]
// [DEF:downloadSkippedCsv:Function]
// @PURPOSE: Download skipped records CSV for a run.
export async function downloadSkippedCsv(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}/skipped.csv`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to download skipped CSV');
}
}
// [/DEF:downloadSkippedCsv:Function]

View File

@@ -102,6 +102,35 @@ export function buildSidebarCategories(i18nState, user, features = {}) {
},
],
},
{
id: "translation",
label: nav.translation,
icon: "translate",
tone: "from-cyan-100 to-teal-100 text-teal-700 ring-teal-200",
path: "/translate",
requiredPermission: "translate.job",
requiredAction: "VIEW",
subItems: [
{
label: nav.translation_jobs,
path: "/translate",
requiredPermission: "translate.job",
requiredAction: "VIEW",
},
{
label: nav.translation_dictionaries,
path: "/translate/dictionaries",
requiredPermission: "translate.dictionary",
requiredAction: "VIEW",
},
{
label: nav.translation_history,
path: "/translate/history",
requiredPermission: "translate.job",
requiredAction: "VIEW",
},
],
},
{
id: "reports",
label: nav.reports,

View File

@@ -0,0 +1,292 @@
<!-- [DEF:BulkCorrectionSidebar:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Sidebar for collecting multiple incorrect terms across different rows,
* per-term correction inputs, submit atomically to dictionary.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @RELATION: DEPENDS_ON -> [dictionaryApi]
*
* @UX_STATE: closed -> Sidebar hidden
* @UX_STATE: collecting -> Items being collected, user can edit corrections
* @UX_STATE: reviewing -> User reviews collected corrections before submit
* @UX_STATE: submitting -> API call in progress
* @UX_STATE: submitted -> Success feedback
*
* @UX_FEEDBACK: Toast on success/error; count badge on toggle button
* @UX_RECOVERY: Remove individual items; retry submission
*/
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import { submitBulkCorrections, dictionaryApi } from '$lib/api/translate.js';
let {
runId = '',
/** @type {Array<{sourceTerm: string, incorrectTarget: string, rowKey?: string}>} */
initialItems = [],
onClose = () => {},
onSubmitted = () => {}
} = $props();
/** @type {'closed'|'collecting'|'reviewing'|'submitting'|'submitted'} */
let uxState = $state('closed');
let items = $state([]);
let dictionaries = $state([]);
let selectedDictId = $state('');
let submitResult = $state(null);
let isOpen = $state(false);
// Open sidebar when items are provided
$effect(() => {
if (initialItems.length > 0) {
// Merge new items into existing collection, deduplicate by sourceTerm+incorrectTarget
for (const newItem of initialItems) {
const exists = items.some(
i => i.sourceTerm === newItem.sourceTerm && i.incorrectTarget === newItem.incorrectTarget
);
if (!exists) {
items = [...items, { ...newItem, correctedTarget: '' }];
}
}
if (!isOpen) {
openSidebar();
}
}
});
async function openSidebar() {
if (items.length === 0) return;
isOpen = true;
uxState = 'collecting';
await loadDictionaries();
}
function closeSidebar() {
isOpen = false;
uxState = 'closed';
onClose();
}
async function loadDictionaries() {
try {
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
dictionaries = Array.isArray(result) ? result : (result?.items || []);
if (dictionaries.length === 1) {
selectedDictId = dictionaries[0].id;
}
} catch (err) {
addToast($t.translate?.common?.error + ': ' + ($t.translate?.corrections?.loading || 'Failed to load dictionaries'), 'error');
}
}
function updateCorrection(index, value) {
items = items.map((item, i) => i === index ? { ...item, correctedTarget: value } : item);
}
function removeItem(index) {
items = items.filter((_, i) => i !== index);
if (items.length === 0) {
closeSidebar();
}
}
async function handleSubmitAll() {
if (!selectedDictId || items.length === 0) return;
// Validate all items have corrected targets
const invalidItems = items.filter(i => !i.correctedTarget.trim());
if (invalidItems.length > 0) {
addToast($t.translate?.corrections?.submit_failed || 'All items must have a corrected target', 'error');
return;
}
uxState = 'submitting';
try {
const corrections = items.map(item => ({
source_term: item.sourceTerm,
incorrect_target_term: item.incorrectTarget,
corrected_target_term: item.correctedTarget.trim(),
origin_run_id: runId || undefined,
origin_row_key: item.rowKey || undefined,
}));
const result = await submitBulkCorrections({
corrections,
dictionary_id: selectedDictId,
});
submitResult = result;
uxState = 'submitted';
const successCount = result.created_count || result.updated_count || corrections.length;
const totalCount = corrections.length;
if (successCount === totalCount) {
addToast($t.translate?.corrections?.submit_success || 'All corrections submitted', 'success');
} else {
addToast(
($t.translate?.corrections?.submit_partial || '{success} of {total} submitted')
.replace('{success}', successCount)
.replace('{total}', totalCount),
'info'
);
}
onSubmitted(result);
} catch (err) {
addToast(err?.message || $t.translate?.corrections?.submit_failed || 'Failed to submit corrections', 'error');
uxState = 'collecting';
}
}
function resetAndClose() {
items = [];
submitResult = null;
closeSidebar();
}
// Derived
let validItemCount = $derived(items.filter(i => i.correctedTarget.trim()).length);
</script>
<!-- Toggle button -->
<button
onclick={openSidebar}
class="fixed right-0 top-1/3 z-40 flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-l-lg shadow-lg hover:bg-blue-700 transition-all translate-x-0"
class:translate-x-[360px]={isOpen}
title={$t.translate?.corrections?.title}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span class="text-xs font-medium">{$t.translate?.corrections?.title}</span>
{#if items.length > 0}
<span class="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
{items.length}
</span>
{/if}
</button>
<!-- Sidebar overlay -->
{#if isOpen}
<div class="fixed inset-0 z-30 bg-black/20" onclick={closeSidebar}></div>
<div class="fixed right-0 top-0 z-40 h-full w-[360px] bg-white shadow-2xl border-l border-gray-200 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<div>
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.corrections?.title}</h3>
<p class="text-xs text-gray-500">{$t.translate?.corrections?.subtitle}</p>
</div>
<button onclick={closeSidebar} class="p-1 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100">
<svg class="w-5 h-5" 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>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{#if uxState === 'collecting'}
{#if items.length === 0}
<div class="text-center py-8">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-sm text-gray-500">{$t.translate?.corrections?.no_items}</p>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.corrections?.add_hint}</p>
</div>
{:else}
{#each items as item, idx}
<div class="border border-gray-200 rounded-lg p-3 space-y-2">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-gray-500">{$t.translate?.corrections?.source_term}</p>
<p class="text-sm font-mono text-gray-800 truncate">{item.sourceTerm}</p>
</div>
<button
onclick={() => removeItem(idx)}
class="ml-2 p-0.5 text-gray-400 hover:text-red-500 rounded"
title={$t.translate?.corrections?.remove}
>
<svg class="w-4 h-4" 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>
<div>
<p class="text-xs font-medium text-red-500">{$t.translate?.corrections?.incorrect_target}</p>
<p class="text-sm font-mono text-red-600 truncate">{item.incorrectTarget}</p>
</div>
<div>
<label class="text-xs font-medium text-green-600">{$t.translate?.corrections?.corrected_target}</label>
<input
type="text"
value={item.correctedTarget}
oninput={(e) => updateCorrection(idx, e.target.value)}
placeholder={$t.translate?.corrections?.corrected_placeholder}
class="w-full mt-1 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
</div>
</div>
{/each}
{/if}
{:else if uxState === 'submitting'}
<div class="flex flex-col items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mb-3"></div>
<p class="text-sm text-gray-500">{$t.translate?.corrections?.submitting}</p>
</div>
{:else if uxState === 'submitted'}
<div class="text-center py-8">
<svg class="w-12 h-12 text-green-500 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<p class="text-sm font-medium text-gray-900">{$t.translate?.corrections?.submit_success}</p>
{#if submitResult}
<p class="text-xs text-gray-500 mt-1">
{$t.translate?.corrections?.submit_partial
?.replace('{success}', submitResult.created_count || submitResult.updated_count || items.length)
?.replace('{total}', items.length)}
</p>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="border-t border-gray-200 px-4 py-3 space-y-3">
{#if uxState === 'collecting' && items.length > 0}
<!-- Dictionary selector -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">{$t.translate?.corrections?.dictionary}</label>
<select
bind:value={selectedDictId}
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm"
>
<option value="">{$t.translate?.corrections?.select_dictionary}</option>
{#each dictionaries as dict}
<option value={dict.id}>{dict.name}</option>
{/each}
</select>
</div>
<button
onclick={handleSubmitAll}
disabled={!selectedDictId || validItemCount === 0}
class="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{$t.translate?.corrections?.submit_all?.replace('{count}', validItemCount)}
</button>
{:else if uxState === 'submitted'}
<button
onclick={resetAndClose}
class="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
{$t.translate?.common?.close}
</button>
{/if}
</div>
</div>
{/if}
<!-- [/DEF:BulkCorrectionSidebar:Component] -->

View File

@@ -0,0 +1,260 @@
<!-- [DEF:ScheduleConfig:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Schedule configuration for a translation job — cron/interval/once triggers, timezone, enable/disable.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
*
* @UX_STATE: idle -> No schedule configured
* @UX_STATE: editing -> Configuring schedule
* @UX_STATE: enabled -> Schedule active
* @UX_STATE: disabled -> Schedule inactive
* @UX_STATE: no_prior_run_warning -> No prior manual run, show warning
*
* @UX_FEEDBACK: Next-3-executions preview; toast on save/error
* @UX_RECOVERY: Enable/disable toggle; delete schedule
*/
import { addToast } from '$lib/toasts.js';
import {
fetchSchedule,
setSchedule,
deleteSchedule,
enableSchedule,
disableSchedule,
fetchNextExecutions
} from '$lib/api/translate.js';
let { jobId = '' } = $props();
/** @type {'idle'|'editing'|'enabled'|'disabled'|'no_prior_run_warning'} */
let uxState = $state('idle');
let cronExpression = $state('0 2 * * *');
let timezone = $state('UTC');
let isEnabled = $state(false);
let isLoading = $state(true);
let nextExecutions = $state([]);
let hasPriorRun = $state(true);
let scheduleExists = $state(false);
$effect(() => {
if (jobId) loadSchedule();
});
async function loadSchedule() {
isLoading = true;
try {
const sch = await fetchSchedule(jobId);
if (sch && sch.cron_expression) {
cronExpression = sch.cron_expression;
timezone = sch.timezone || 'UTC';
isEnabled = sch.is_active !== false;
scheduleExists = true;
uxState = isEnabled ? 'enabled' : 'disabled';
loadNextExecutions();
} else {
uxState = 'idle';
}
} catch (err) {
// 404 = no schedule
uxState = 'idle';
} finally {
isLoading = false;
}
}
async function loadNextExecutions() {
try {
const result = await fetchNextExecutions(jobId, 3);
nextExecutions = result?.next_executions || [];
} catch (err) {
nextExecutions = [];
}
}
async function handleSave() {
try {
const payload = {
cron_expression: cronExpression,
timezone: timezone,
is_active: isEnabled,
};
await setSchedule(jobId, payload);
addToast('Schedule saved', 'success');
scheduleExists = true;
uxState = isEnabled ? 'enabled' : 'disabled';
loadNextExecutions();
} catch (err) {
addToast(err?.message || 'Failed to save schedule', 'error');
}
}
async function handleDelete() {
try {
await deleteSchedule(jobId);
addToast('Schedule deleted', 'success');
scheduleExists = false;
uxState = 'idle';
cronExpression = '0 2 * * *';
nextExecutions = [];
} catch (err) {
addToast(err?.message || 'Failed to delete schedule', 'error');
}
}
async function handleToggle() {
try {
if (isEnabled) {
await disableSchedule(jobId);
isEnabled = false;
uxState = 'disabled';
addToast('Schedule disabled', 'info');
} else {
if (!scheduleExists) {
await handleSave();
return;
}
await enableSchedule(jobId);
isEnabled = true;
uxState = 'enabled';
addToast('Schedule enabled', 'success');
loadNextExecutions();
}
} catch (err) {
addToast(err?.message || 'Failed to toggle schedule', 'error');
}
}
function getCronDescription(cron) {
const parts = cron.split(' ');
if (parts.length !== 5) return cron;
if (parts[1] === '*' && parts[2] === '*' && parts[4] === '*') {
const hour = parts[0] === '*' ? 'every hour' : `at :${parts[0]} past the hour`;
return `Daily at ${parts[0]}:${parts[1]}`;
}
return `Cron: ${cron}`;
}
const commonTimezones = [
'UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific',
'Europe/London', 'Europe/Berlin', 'Europe/Moscow', 'Europe/Paris',
'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Sydney',
];
</script>
<div class="bg-white border border-gray-200 rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Schedule Configuration</h3>
{#if !isLoading && scheduleExists}
<button
onclick={handleToggle}
class="inline-flex items-center px-3 py-1.5 text-sm rounded-full {isEnabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}"
>
<span class="w-2 h-2 rounded-full mr-2 {isEnabled ? 'bg-green-500' : 'bg-gray-400'}"></span>
{isEnabled ? 'Enabled' : 'Disabled'}
</button>
{/if}
</div>
{#if isLoading}
<div class="h-24 bg-gray-50 rounded-lg animate-pulse"></div>
{:else if uxState === 'idle'}
<div class="text-center py-6">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm text-gray-500 mb-1">No schedule configured</p>
<p class="text-xs text-gray-400 mb-4">Set up a recurring schedule for automatic translations</p>
<button onclick={() => (uxState = 'editing')} class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700">
Create Schedule
</button>
</div>
{:else if uxState === 'editing' || uxState === 'enabled' || uxState === 'disabled'}
<div class="space-y-4">
<!-- Cron expression -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Cron Expression</label>
<div class="flex gap-2">
<input
type="text"
bind:value={cronExpression}
placeholder="0 2 * * *"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500"
/>
<select
bind:value={cronExpression}
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="0 2 * * *">Daily at 02:00</option>
<option value="0 */6 * * *">Every 6 hours</option>
<option value="30 1 * * 0">Weekly (Sun 01:30)</option>
<option value="0 3 1 * *">Monthly (1st at 03:00)</option>
<option value="*/15 * * * *">Every 15 min</option>
</select>
</div>
<p class="text-xs text-gray-400 mt-1">
{getCronDescription(cronExpression)}
</p>
</div>
<!-- Timezone -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
<select
bind:value={timezone}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{#each commonTimezones as tz}
<option value={tz}>{tz}</option>
{/each}
</select>
</div>
<!-- Next executions preview -->
{#if nextExecutions.length > 0}
<div class="bg-gray-50 rounded-lg p-3">
<p class="text-xs font-medium text-gray-600 mb-2">Next Executions</p>
{#each nextExecutions as dt, i}
<p class="text-xs text-gray-500 font-mono">{i + 1}. {new Date(dt).toLocaleString()}</p>
{/each}
</div>
{/if}
<!-- Prior run warning -->
{#if uxState === 'no_prior_run_warning'}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p class="text-xs text-yellow-700">
⚠ No prior successful manual run. Schedule may run with default settings.
Consider running manually first.
</p>
</div>
{/if}
<!-- Actions -->
<div class="flex justify-between items-center pt-2">
{#if scheduleExists}
<button onclick={handleDelete} class="px-3 py-1.5 text-sm text-red-600 hover:text-red-700">
Delete Schedule
</button>
{:else}
<div></div>
{/if}
<div class="flex gap-2">
{#if scheduleExists && (uxState === 'enabled' || uxState === 'disabled')}
<button onclick={() => (uxState = 'editing')} class="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">
Edit
</button>
{/if}
{#if uxState === 'editing'}
<button onclick={handleSave} class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700">
{scheduleExists ? 'Update' : 'Create'}
</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- [/DEF:ScheduleConfig:Component] -->

View File

@@ -0,0 +1,226 @@
<!-- [DEF:TermCorrectionPopup:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Popup for submitting a term correction from a run result row.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @RELATION: BINDS_TO -> [dictionaryApi]
*
* @UX_STATE: closed -> Hidden
* @UX_STATE: selecting -> User selects target dictionary
* @UX_STATE: editing -> User enters corrected term
* @UX_STATE: submitting -> API call in progress
* @UX_STATE: conflict_detected -> Existing entry found, user chooses overwrite/keep
* @UX_STATE: submitted -> Success feedback
*
* @UX_FEEDBACK: Toast on success/error; conflict dialog with action buttons
* @UX_RECOVERY: Cancel button returns to previous state
*/
import { addToast } from '$lib/toasts.js';
import { submitCorrection, dictionaryApi } from '$lib/api/translate.js';
let {
sourceTerm = '',
incorrectTarget = '',
runId = '',
rowKey = '',
onClose = () => {},
onSubmitted = () => {}
} = $props();
/** @type {'closed'|'selecting'|'editing'|'submitting'|'conflict_detected'|'submitted'} */
let uxState = $state('closed');
let correctedTarget = $state('');
let dictionaries = $state([]);
let selectedDictId = $state('');
let conflictInfo = $state(null);
let submitResult = $state(null);
$effect(() => {
if (sourceTerm && incorrectTarget) {
uxState = 'selecting';
loadDictionaries();
}
});
async function loadDictionaries() {
try {
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
dictionaries = Array.isArray(result) ? result : (result?.items || []);
if (dictionaries.length === 1) {
selectedDictId = dictionaries[0].id;
}
uxState = dictionaries.length > 0 ? 'editing' : 'selecting';
} catch (err) {
addToast('Failed to load dictionaries', 'error');
uxState = 'editing';
}
}
async function handleSubmit() {
if (!selectedDictId || !correctedTarget.trim()) return;
uxState = 'submitting';
try {
const result = await submitCorrection({
source_term: sourceTerm,
incorrect_target_term: incorrectTarget,
corrected_target_term: correctedTarget.trim(),
dictionary_id: selectedDictId,
origin_run_id: runId || undefined,
origin_row_key: rowKey || undefined,
});
if (result.action === 'conflict_detected') {
conflictInfo = result.conflict;
uxState = 'conflict_detected';
} else if (result.action === 'created' || result.action === 'updated') {
submitResult = result;
uxState = 'submitted';
addToast(result.message || 'Correction submitted', 'success');
onSubmitted(result);
}
} catch (err) {
addToast(err?.message || 'Failed to submit correction', 'error');
uxState = 'editing';
}
}
async function handleConflictOverwrite() {
try {
const result = await submitCorrection({
source_term: sourceTerm,
incorrect_target_term: incorrectTarget,
corrected_target_term: correctedTarget.trim(),
dictionary_id: selectedDictId,
origin_run_id: runId || undefined,
origin_row_key: rowKey || undefined,
});
submitResult = result;
uxState = 'submitted';
addToast(result.message || 'Correction overwritten', 'success');
onSubmitted(result);
} catch (err) {
addToast(err?.message || 'Failed to overwrite', 'error');
uxState = 'conflict_detected';
}
}
function handleConflictKeep() {
uxState = 'submitted';
addToast('Existing entry kept', 'info');
onSubmitted({ action: 'kept' });
}
function handleClose() {
uxState = 'closed';
onClose();
}
</script>
{#if uxState !== 'closed'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={handleClose}>
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md mx-4" onclick={(e) => e.stopPropagation()}>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Submit Term Correction</h3>
<button onclick={handleClose} class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" 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>
<!-- Selecting state -->
{#if uxState === 'selecting'}
<p class="text-sm text-gray-500">Loading dictionaries...</p>
<!-- Editing state -->
{:else if uxState === 'editing'}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Source Term</label>
<input type="text" readonly value={sourceTerm} class="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Incorrect Target</label>
<input type="text" readonly value={incorrectTarget} class="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Corrected Target</label>
<input
type="text"
bind:value={correctedTarget}
placeholder="Enter corrected translation..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Dictionary</label>
<select
bind:value={selectedDictId}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Select dictionary...</option>
{#each dictionaries as dict}
<option value={dict.id}>{dict.name} ({dict.source_dialect} {dict.target_dialect})</option>
{/each}
</select>
</div>
<div class="flex justify-end gap-2 pt-2">
<button onclick={handleClose} class="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">
Cancel
</button>
<button
onclick={handleSubmit}
disabled={!selectedDictId || !correctedTarget.trim()}
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Submit Correction
</button>
</div>
</div>
<!-- Submitting state -->
{:else if uxState === 'submitting'}
<div class="text-center py-6">
<div class="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-3"></div>
<p class="text-sm text-gray-500">Submitting correction...</p>
</div>
<!-- Conflict state -->
{:else if uxState === 'conflict_detected'}
<div class="space-y-4">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p class="text-sm font-medium text-yellow-800">Conflict Detected</p>
<p class="text-xs text-yellow-600 mt-1">
"{conflictInfo?.source_term}" already exists in this dictionary
with target "{conflictInfo?.existing_target_term}".
</p>
</div>
<div class="flex justify-end gap-2">
<button onclick={handleConflictKeep} class="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">
Keep Existing
</button>
<button onclick={handleConflictOverwrite} class="px-4 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
Overwrite
</button>
</div>
</div>
<!-- Submitted state -->
{:else if uxState === 'submitted'}
<div class="text-center py-6">
<svg class="w-12 h-12 text-green-500 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<p class="text-sm font-medium text-gray-900">Correction Submitted</p>
<p class="text-xs text-gray-500 mt-1">{submitResult?.message || ''}</p>
<button onclick={handleClose} class="mt-4 px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700">
Close
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- [/DEF:TermCorrectionPopup:Component] -->

View File

@@ -0,0 +1,191 @@
<!-- [DEF:TranslationMetricsDashboard:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Admin metrics dashboard for translation — per-job run counts, success/failure ratio,
* cumulative tokens, cumulative cost, average latency.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
*
* @UX_STATE: loading -> Initial load
* @UX_STATE: loaded -> Metrics data displayed
* @UX_STATE: error -> Failed to load metrics
* @UX_STATE: empty -> No metrics data available
*
* @UX_FEEDBACK: Spinner during load; card grid for totals; table for per-job breakdown
* @UX_RECOVERY: Retry button on error
*/
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import { fetchAllMetrics } from '$lib/api/translate.js';
/** @type {'loading'|'loaded'|'error'|'empty'} */
let uxState = $state('loading');
let metrics = $state([]);
let errorMessage = $state('');
// Aggregated
let totalRuns = $derived(metrics.reduce((sum, m) => sum + (m.total_runs || 0), 0));
let successfulRuns = $derived(metrics.reduce((sum, m) => sum + (m.successful_runs || 0), 0));
let failedRuns = $derived(metrics.reduce((sum, m) => sum + (m.failed_runs || 0), 0));
let totalRecords = $derived(metrics.reduce((sum, m) => sum + (m.total_records || 0), 0));
let totalTokens = $derived(metrics.reduce((sum, m) => sum + (m.total_tokens || 0), 0));
let totalCost = $derived(metrics.reduce((sum, m) => sum + (m.total_cost || 0), 0));
let successRate = $derived(
totalRuns > 0 ? Math.round((successfulRuns / totalRuns) * 100) : 0
);
let avgLatency = $derived.by(() => {
const withLatency = metrics.filter(m => m.avg_latency_seconds);
if (withLatency.length === 0) return null;
const sum = withLatency.reduce((s, m) => s + (m.avg_latency_seconds || 0), 0);
return (sum / withLatency.length).toFixed(1);
});
$effect(() => {
loadMetrics();
});
async function loadMetrics() {
uxState = 'loading';
errorMessage = '';
try {
const data = await fetchAllMetrics();
const items = Array.isArray(data) ? data : (data?.items || []);
if (items.length === 0) {
uxState = 'empty';
} else {
metrics = items;
uxState = 'loaded';
}
} catch (err) {
errorMessage = err?.message || $t.translate?.metrics?.load_failed;
uxState = 'error';
}
}
async function handleRefresh() {
await loadMetrics();
}
</script>
<div class="translation-metrics-dashboard space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900">{$t.translate?.metrics?.title}</h2>
<p class="text-sm text-gray-500 mt-1">{$t.translate?.metrics?.subtitle}</p>
</div>
<button
onclick={handleRefresh}
class="px-3 py-1.5 text-sm border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors flex items-center gap-1.5"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{$t.translate?.metrics?.refresh}
</button>
</div>
<!-- Loading -->
{#if uxState === 'loading'}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{#each Array(3) as _}
<div class="h-24 bg-gray-100 rounded-lg animate-pulse" />
{/each}
</div>
<!-- Error -->
{:else if uxState === 'error'}
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-700 mb-3">{errorMessage}</p>
<button
onclick={handleRefresh}
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
{$t.translate?.common?.retry}
</button>
</div>
<!-- Empty -->
{:else if uxState === 'empty'}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<svg class="w-16 h-16 text-gray-300 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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-gray-500">{$t.translate?.metrics?.no_data}</p>
</div>
<!-- Loaded -->
{:else if uxState === 'loaded'}
<!-- Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.metrics?.total_runs}</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{totalRuns}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.metrics?.success_rate}</p>
<p class="text-2xl font-bold text-green-600 mt-1">{successRate}%</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.metrics?.failed_runs}</p>
<p class="text-2xl font-bold text-red-600 mt-1">{failedRuns}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.metrics?.total_records}</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{totalRecords.toLocaleString()}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.metrics?.total_tokens}</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{totalTokens.toLocaleString()}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.metrics?.total_cost}</p>
<p class="text-2xl font-bold text-gray-900 mt-1">${totalCost.toFixed(2)}</p>
</div>
</div>
<!-- Per-Job Breakdown Table -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.metrics?.runs_per_job}</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">{$t.translate?.metrics?.job}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">{$t.translate?.metrics?.runs}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">{$t.translate?.metrics?.successful}</th>
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">{$t.translate?.metrics?.failed}</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">{$t.translate?.metrics?.tokens}</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">{$t.translate?.metrics?.cost}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each metrics as m}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 font-medium text-gray-900">{m.job_name || m.job_id}</td>
<td class="px-4 py-2 text-center text-gray-700">{m.total_runs || 0}</td>
<td class="px-4 py-2 text-center text-green-600">{m.successful_runs || 0}</td>
<td class="px-4 py-2 text-center text-red-600">{m.failed_runs || 0}</td>
<td class="px-4 py-2 text-right text-gray-700">{(m.total_tokens || 0).toLocaleString()}</td>
<td class="px-4 py-2 text-right text-gray-700">${(m.total_cost || 0).toFixed(2)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Additional stats row -->
{#if avgLatency !== null}
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.metrics?.avg_latency}</p>
<p class="text-lg font-semibold text-gray-900 mt-1">{avgLatency}s</p>
</div>
{/if}
{/if}
</div>
<!-- [/DEF:TranslationMetricsDashboard:Component] -->

View File

@@ -0,0 +1,409 @@
<!-- [DEF:TranslationPreview:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Side-by-side translation preview component with approve/edit/reject, cost estimate, and accept gate.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @RELATION: DEPENDS_ON -> [api_module]
* @PRE: jobId is a valid translation job ID.
* @POST: User can preview, approve/edit/reject rows, and accept the session to gate full execution.
*
* @UX_STATE: idle -> Initial state, no preview loaded
* @UX_STATE: loading -> Preview API call in progress
* @UX_STATE: preview_loaded -> Preview data displayed side-by-side
* @UX_STATE: preview_error -> Preview API call failed
* @UX_STATE: accepted -> Preview session accepted
* @UX_STATE: stale_config -> Configuration changed since preview was generated
*
* @UX_FEEDBACK: spinner during preview; visual distinction for LLM vs edited translations; cost reactivity.
* @UX_RECOVERY: retry preview; re-fetch with updated config.
*/
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import {
fetchPreview,
approveRow,
editRow,
rejectRow,
acceptPreview,
fetchPreviewRecords
} from '$lib/api/translate.js';
/** @type {{ jobId: string, onAccept?: () => void }} */
let { jobId, onAccept = () => {} } = $props();
/** @type {'idle'|'loading'|'preview_loaded'|'preview_error'|'accepted'|'stale_config'} */
let uxState = $state('idle');
let session = $state(null);
let records = $state([]);
let costEstimate = $state(null);
let errorMessage = $state('');
let sampleSize = $state(10);
let isAccepting = $state(false);
let editingRowId = $state(null);
let editValue = $state('');
// Derived
let approvedCount = $derived(records.filter(r => r.status === 'APPROVED').length);
let rejectedCount = $derived(records.filter(r => r.status === 'REJECTED').length);
let pendingCount = $derived(records.filter(r => r.status === 'PENDING').length);
let isAllApproved = $derived(pendingCount === 0 && records.length > 0);
/** @returns {Promise<void>} */
async function handlePreview() {
uxState = 'loading';
errorMessage = '';
try {
const result = await fetchPreview(jobId, sampleSize);
session = result;
records = (result.records || []).map(r => ({ ...r, _isEdited: false }));
costEstimate = result.cost_estimate || null;
uxState = 'preview_loaded';
addToast($t.translate?.preview?.preview_loaded?.replace('{count}', records.length), 'success');
} catch (err) {
errorMessage = err?.message || $t.translate?.preview?.preview_failed;
uxState = 'preview_error';
addToast(errorMessage, 'error');
}
}
/** @param {string} rowId */
async function handleApprove(rowId) {
try {
const updated = await approveRow(jobId, rowId);
records = records.map(r => r.id === rowId ? { ...r, status: 'APPROVED', _isEdited: false, target_sql: updated.target_sql } : r);
} catch (err) {
addToast(err?.message || $t.translate?.preview?.approve_failed, 'error');
}
}
/** @param {string} rowId */
async function handleReject(rowId) {
try {
const updated = await rejectRow(jobId, rowId);
records = records.map(r => r.id === rowId ? { ...r, status: 'REJECTED', _isEdited: false } : r);
} catch (err) {
addToast(err?.message || $t.translate?.preview?.reject_failed, 'error');
}
}
/** @param {string} rowId */
function startEdit(rowId) {
const row = records.find(r => r.id === rowId);
if (row) {
editingRowId = rowId;
editValue = row.target_sql || '';
}
}
/** @param {string} rowId */
async function saveEdit(rowId) {
if (!editValue.trim()) return;
try {
const updated = await editRow(jobId, rowId, editValue);
records = records.map(r =>
r.id === rowId
? { ...r, status: 'APPROVED', target_sql: updated.target_sql || editValue, _isEdited: true }
: r
);
editingRowId = null;
editValue = '';
addToast($t.translate?.preview?.row_updated, 'success');
} catch (err) {
addToast(err?.message || $t.translate?.preview?.edit_failed, 'error');
}
}
function cancelEdit() {
editingRowId = null;
editValue = '';
}
async function handleBulkApprove() {
for (const row of records) {
if (row.status === 'PENDING') {
await handleApprove(row.id);
}
}
}
async function handleAccept() {
isAccepting = true;
try {
const result = await acceptPreview(jobId);
uxState = 'accepted';
onAccept();
addToast($t.translate?.preview?.accepted_body, 'success');
} catch (err) {
addToast(err?.message || $t.translate?.preview?.accept_failed, 'error');
} finally {
isAccepting = false;
}
}
</script>
<div class="translation-preview">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">{$t.translate?.preview?.title}</h3>
{#if uxState === 'preview_loaded'}
<span class="px-2.5 py-0.5 rounded-full text-xs font-medium {session?.status === 'APPLIED' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'}">
{session?.status === 'APPLIED' ? $t.translate?.preview?.accepted_badge : $t.translate?.preview?.active_badge}
</span>
{/if}
</div>
<!-- Idle State -->
{#if uxState === 'idle'}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<p class="text-sm text-gray-500 mb-4">
{$t.translate?.preview?.title} &mdash; {$t.translate?.preview?.sample_size} {sampleSize}
</p>
<div class="flex items-center justify-center gap-3 mb-4">
<label class="text-sm text-gray-600">{$t.translate?.preview?.sample_size}</label>
<input
type="number"
bind:value={sampleSize}
min="1"
max="100"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
/>
</div>
<button
onclick={handlePreview}
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
{$t.translate?.preview?.run_preview}
</button>
</div>
<!-- Loading State -->
{:else if uxState === 'loading'}
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="flex items-center gap-3 mb-4">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
<span class="text-sm text-gray-500">{$t.translate?.preview?.running_preview}</span>
</div>
<div class="space-y-3">
{#each Array(3) as _}
<div class="h-12 bg-gray-100 rounded animate-pulse" />
{/each}
</div>
</div>
<!-- Error State -->
{:else if uxState === 'preview_error'}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-sm text-red-700 mb-3">{errorMessage}</p>
<div class="flex items-center gap-3">
<button
onclick={handlePreview}
class="px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
{$t.translate?.preview?.retry_preview}
</button>
<label class="text-sm text-gray-500">
{$t.translate?.preview?.sample_size}
<input
type="number"
bind:value={sampleSize}
min="1"
max="100"
class="w-16 ml-1 px-1 py-0.5 border border-gray-300 rounded text-sm text-center"
/>
</label>
</div>
</div>
<!-- Preview Loaded State -->
{:else if uxState === 'preview_loaded'}
<!-- Cost Estimate Card -->
{#if costEstimate}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4 mb-4">
<h4 class="text-sm font-medium text-blue-800 mb-2">{$t.translate?.preview?.cost_estimate}</h4>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div>
<span class="text-blue-600">{$t.translate?.preview?.sample_rows}</span>
<span class="ml-1 font-semibold">{costEstimate.sample_size}</span>
</div>
<div>
<span class="text-blue-600">{$t.translate?.preview?.sample_tokens}</span>
<span class="ml-1 font-semibold">{costEstimate.sample_total_tokens?.toLocaleString()}</span>
</div>
<div>
<span class="text-blue-600">{$t.translate?.preview?.sample_cost}</span>
<span class="ml-1 font-semibold">${costEstimate.sample_cost?.toFixed(6)}</span>
</div>
<div>
<span class="text-blue-600">{$t.translate?.preview?.est_total_cost}</span>
<span class="ml-1 font-semibold">${costEstimate.estimated_cost?.toFixed(6)}</span>
</div>
</div>
<p class="text-xs text-blue-500 mt-2">
{$t.translate?.preview?.est_total_cost?.replace('{count}', '')} {costEstimate.estimated_total_rows?.toLocaleString()} {costEstimate.estimated_tokens?.toLocaleString()} {$t.translate?.preview?.sample_tokens}
</p>
</div>
{/if}
<!-- Stats Bar -->
<div class="flex items-center justify-between mb-3 text-sm">
<div class="flex gap-4">
<span class="text-gray-500">{$t.translate?.preview?.rows_count?.replace('{count}', records.length)}</span>
<span class="text-green-600">{$t.translate?.preview?.approved_count?.replace('{count}', approvedCount)}</span>
<span class="text-red-600">{$t.translate?.preview?.rejected_count?.replace('{count}', rejectedCount)}</span>
{#if pendingCount > 0}
<span class="text-yellow-600">{$t.translate?.preview?.pending_count?.replace('{count}', pendingCount)}</span>
{/if}
</div>
<div class="flex gap-2">
{#if pendingCount > 0}
<button
onclick={handleBulkApprove}
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
>
{$t.translate?.preview?.approve_all}
</button>
{/if}
<button
onclick={handlePreview}
class="px-3 py-1 text-xs border border-gray-300 text-gray-600 rounded hover:bg-gray-50 transition-colors"
>
{$t.translate?.preview?.re_run}
</button>
</div>
</div>
<!-- Side-by-side Table -->
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-16">{$t.translate?.preview?.table_number}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">{$t.translate?.preview?.table_source}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">{$t.translate?.preview?.table_translation}</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-32">{$t.translate?.preview?.table_status}</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-40">{$t.translate?.preview?.table_actions}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each records as row, idx}
<tr class="{row.status === 'REJECTED' ? 'bg-red-50' : row._isEdited ? 'bg-yellow-50' : idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}">
<td class="px-3 py-2 text-xs text-gray-400 align-top">{idx + 1}</td>
<td class="px-3 py-2 align-top">
<code class="text-xs text-gray-800 break-all">{row.source_sql || $t.translate?.preview?.empty_placeholder}</code>
</td>
<td class="px-3 py-2 align-top">
{#if editingRowId === row.id}
<textarea
bind:value={editValue}
class="w-full px-2 py-1 border border-blue-300 rounded text-xs focus:ring-2 focus:ring-blue-500 min-h-[60px]"
/>
<div class="flex gap-1 mt-1">
<button
onclick={() => saveEdit(row.id)}
class="px-2 py-0.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
{$t.translate?.preview?.save_edit}
</button>
<button
onclick={cancelEdit}
class="px-2 py-0.5 text-xs border border-gray-300 text-gray-600 rounded hover:bg-gray-50"
>
{$t.translate?.preview?.cancel_edit}
</button>
</div>
{:else}
<code class="text-xs {row._isEdited ? 'text-yellow-800' : 'text-green-800'} break-all">
{row.target_sql || $t.translate?.preview?.pending_placeholder}
</code>
{#if row._isEdited}
<span class="ml-1 text-xs text-yellow-600 italic">{$t.translate?.preview?.edited_label}</span>
{/if}
{/if}
</td>
<td class="px-3 py-2 text-center align-top">
<span class="inline-flex px-2 py-0.5 text-xs rounded-full font-medium
{row.status === 'APPROVED' ? 'bg-green-100 text-green-700' : ''}
{row.status === 'REJECTED' ? 'bg-red-100 text-red-700' : ''}
{row.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : ''}
">
{row.status}
</span>
</td>
<td class="px-3 py-2 text-center align-top">
{#if editingRowId !== row.id}
<div class="flex gap-1 justify-center">
{#if row.status !== 'APPROVED'}
<button
onclick={() => handleApprove(row.id)}
class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
title={$t.translate?.preview?.approve}
>
{$t.translate?.preview?.approve}
</button>
{/if}
<button
onclick={() => startEdit(row.id)}
class="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
title={$t.translate?.preview?.edit}
>
{$t.translate?.preview?.edit}
</button>
{#if row.status !== 'REJECTED'}
<button
onclick={() => handleReject(row.id)}
class="px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
title={$t.translate?.preview?.reject}
>
{$t.translate?.preview?.reject}
</button>
{/if}
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Accept Gate -->
<div class="mt-6 flex items-center justify-between p-4 bg-gray-50 border border-gray-200 rounded-lg">
<div>
<p class="text-sm font-medium text-gray-900">{$t.translate?.preview?.quality_gate}</p>
<p class="text-xs text-gray-500">
{isAllApproved ? $t.translate?.preview?.quality_gate_ready : $t.translate?.preview?.quality_gate_pending}
</p>
</div>
<button
onclick={handleAccept}
disabled={!isAllApproved || isAccepting}
class="px-6 py-2 rounded-lg text-sm font-medium transition-colors
{isAllApproved && !isAccepting
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'}"
>
{isAccepting ? $t.translate?.preview?.accepting : $t.translate?.preview?.accept_preview}
</button>
</div>
<!-- Accepted State -->
{:else if uxState === 'accepted'}
<div class="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<div class="text-3xl mb-2">&#10003;</div>
<h4 class="text-lg font-semibold text-green-800 mb-1">{$t.translate?.preview?.accepted_title}</h4>
<p class="text-sm text-green-600">
{$t.translate?.preview?.accepted_body}
</p>
<button
onclick={handlePreview}
class="mt-4 px-4 py-1.5 text-sm border border-green-300 text-green-700 rounded-lg hover:bg-green-100 transition-colors"
>
{$t.translate?.preview?.run_preview_again}
</button>
</div>
{/if}
</div>
<!-- [/DEF:TranslationPreview:Component] -->

View File

@@ -0,0 +1,290 @@
<!-- [DEF:TranslationRunProgress:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: WebSocket-driven progress bar with batch counter, success/failure/skip counts, two-phase display, and cancel button.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @RELATION: DEPENDS_ON -> [api_module]
* @PRE: runId is a valid translation run ID.
* @POST: Real-time progress display with two-phase (translation + insert) and state transitions.
*
* @UX_STATE: idle -> Initial state, no run loaded
* @UX_STATE: running -> Translation phase in progress
* @UX_STATE: inserting -> Insert phase in progress
* @UX_STATE: completed -> All phases completed successfully
* @UX_STATE: partial -> Translation completed with some failures
* @UX_STATE: failed -> Translation phase failed
* @UX_STATE: insert_failed -> Insert phase failed
* @UX_STATE: cancelled -> Run was cancelled
*
* @UX_FEEDBACK: progress bar with percentage; batch counter; per-phase counts; cancel button during running/inserting.
* @UX_RECOVERY: retry on failure states.
*/
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import { fetchRunStatus, cancelRun } from '$lib/api/translate.js';
/** @type {{ runId: string, onRetry?: () => void, onRetryInsert?: () => void }} */
let { runId, onRetry = () => {}, onRetryInsert = () => {} } = $props();
/**
* @type {'idle'|'running'|'inserting'|'completed'|'partial'|'failed'|'insert_failed'|'cancelled'}
*/
let uxState = $state('idle');
let status = $state(null);
let pollingInterval = $state(null);
let isCancelling = $state(false);
// Derived
let totalRecords = $derived(status?.total_records || 0);
let successfulRecords = $derived(status?.successful_records || 0);
let failedRecords = $derived(status?.failed_records || 0);
let skippedRecords = $derived(status?.skipped_records || 0);
let progressPct = $derived.by(() => {
if (totalRecords === 0) return 0;
return Math.round(((successfulRecords + failedRecords + skippedRecords) / totalRecords) * 100);
});
let batchCount = $derived(status?.batch_count || 0);
let insertStatus = $derived(status?.insert_status || null);
let isRunning = $derived(uxState === 'running' || uxState === 'inserting');
$effect(() => {
if (runId && uxState === 'idle') {
startPolling();
}
return () => {
stopPolling();
};
});
function startPolling() {
stopPolling();
pollingInterval = setInterval(pollStatus, 2000);
pollStatus();
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
/** @returns {Promise<void>} */
async function pollStatus() {
try {
const data = await fetchRunStatus(runId);
status = data;
// Determine state from status
const s = data.status;
const insertS = data.insert_status;
if (s === 'PENDING' || s === 'RUNNING') {
if (insertS === 'started' || insertS === 'pending' || insertS === 'running') {
uxState = 'inserting';
} else {
uxState = 'running';
}
} else if (s === 'COMPLETED') {
if (insertS === 'failed' || insertS === 'timeout') {
uxState = 'insert_failed';
} else if (data.failed_records > 0) {
uxState = 'partial';
} else {
uxState = 'completed';
}
stopPolling();
} else if (s === 'FAILED') {
uxState = 'failed';
stopPolling();
} else if (s === 'CANCELLED') {
uxState = 'cancelled';
stopPolling();
}
} catch (err) {
console.warn('[TranslationRunProgress] Poll error:', err);
}
}
/** @returns {Promise<void>} */
async function handleCancel() {
isCancelling = true;
try {
await cancelRun(runId);
uxState = 'cancelled';
stopPolling();
addToast($t.translate?.run?.run_cancelled, 'info');
} catch (err) {
addToast(err?.message || $t.translate?.run?.cancel_failed, 'error');
} finally {
isCancelling = false;
}
}
</script>
<div class="translation-run-progress">
{#if uxState === 'idle'}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<p class="text-sm text-gray-500">{$t.translate?.run?.loading}</p>
</div>
{:else if isRunning}
<div class="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
<!-- Phase indicator -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
<span class="text-sm font-medium text-gray-900">
{uxState === 'inserting' ? $t.translate?.run?.insert_phase : $t.translate?.run?.translate_phase}
</span>
</div>
<button
onclick={handleCancel}
disabled={isCancelling}
class="px-3 py-1 text-xs border border-red-300 text-red-600 rounded hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{isCancelling ? $t.translate?.run?.cancelling : $t.translate?.run?.cancel}
</button>
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div
class="bg-blue-600 h-2.5 rounded-full transition-all duration-500"
style="width: {Math.min(progressPct, 100)}%"
/>
</div>
<!-- Statistics -->
<div class="grid grid-cols-4 gap-3 text-sm">
<div>
<span class="text-gray-500">{$t.translate?.run?.total}</span>
<p class="font-semibold">{totalRecords}</p>
</div>
<div>
<span class="text-green-600">{$t.translate?.run?.success}</span>
<p class="font-semibold text-green-700">{successfulRecords}</p>
</div>
<div>
<span class="text-red-600">{$t.translate?.run?.failed}</span>
<p class="font-semibold text-red-700">{failedRecords}</p>
</div>
<div>
<span class="text-yellow-600">{$t.translate?.run?.skipped}</span>
<p class="font-semibold text-yellow-700">{skippedRecords}</p>
</div>
</div>
<!-- Batch counter -->
<p class="text-xs text-gray-400">
{$t.translate?.run?.batches_progress?.replace('{count}', batchCount).replace('{pct}', progressPct)}
</p>
</div>
<!-- Completed -->
{:else if uxState === 'completed'}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-green-600 text-lg">&#10003;</span>
<span class="text-sm font-semibold text-green-800">{$t.translate?.run?.completed}</span>
</div>
<div class="grid grid-cols-3 gap-3 text-sm">
<div>
<span class="text-gray-500">{$t.translate?.run?.total}</span>
<p class="font-semibold text-green-700">{successfulRecords} / {totalRecords}</p>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.skipped}</span>
<p class="font-semibold">{skippedRecords}</p>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.insert_status?.replace(':', '')}</span>
<p class="font-semibold text-green-700">{insertStatus || $t.translate?.run?.insert_completed}</p>
</div>
</div>
</div>
<!-- Partial (completed with errors) -->
{:else if uxState === 'partial'}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-yellow-600 text-lg">&#9888;</span>
<span class="text-sm font-semibold text-yellow-800">{$t.translate?.run?.completed_with_errors}</span>
</div>
<button
onclick={onRetry}
class="px-3 py-1 text-xs bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 transition-colors"
>
{$t.translate?.run?.retry_failed}
</button>
</div>
<div class="grid grid-cols-3 gap-3 text-sm">
<div>
<span class="text-gray-500">{$t.translate?.run?.success}</span>
<p class="font-semibold text-green-700">{successfulRecords}</p>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.failed}</span>
<p class="font-semibold text-red-700">{failedRecords}</p>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.skipped}</span>
<p class="font-semibold">{skippedRecords}</p>
</div>
</div>
</div>
<!-- Failed -->
{:else if uxState === 'failed'}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-red-600 text-lg">&#10007;</span>
<span class="text-sm font-semibold text-red-800">{$t.translate?.run?.translation_failed}</span>
</div>
<button
onclick={onRetry}
class="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
>
{$t.translate?.common?.retry}
</button>
</div>
<p class="text-xs text-red-600">{status?.error_message || $t.translate?.common?.unknown}</p>
</div>
<!-- Insert Failed -->
{:else if uxState === 'insert_failed'}
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-orange-600 text-lg">&#9888;</span>
<span class="text-sm font-semibold text-orange-800">{$t.translate?.run?.insert_failed}</span>
</div>
<button
onclick={onRetryInsert}
class="px-3 py-1 text-xs bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition-colors"
>
{$t.translate?.run?.retry_insert}
</button>
</div>
<p class="text-xs text-gray-600 mt-1">
{$t.translate?.run?.completed} ({successfulRecords}) &mdash; {$t.translate?.run?.insert_failed}
</p>
</div>
<!-- Cancelled -->
{:else if uxState === 'cancelled'}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center gap-2">
<span class="text-gray-500 text-lg">&#9632;</span>
<span class="text-sm font-semibold text-gray-700">{$t.translate?.run?.cancelled}</span>
</div>
</div>
{/if}
</div>
<!-- [/DEF:TranslationRunProgress:Component] -->

View File

@@ -0,0 +1,287 @@
<!-- [DEF:TranslationRunResult:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Run result display with statistics, insert status badge, Superset query reference, SQL audit block, and retry buttons.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @RELATION: DEPENDS_ON -> [api_module]
* @PRE: runId is a valid completed/failed/pending run.
* @POST: Results displayed with actions for retry, SQL audit, and Superset reference.
*
* @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_RECOVERY: Retry failed batches; retry insert.
*/
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import {
fetchRunStatus,
fetchRunRecords,
retryFailedBatches,
retryInsert,
fetchRunBatches
} from '$lib/api/translate.js';
/** @type {{ runId: string, onRefresh?: () => void }} */
let { runId, onRefresh = () => {} } = $props();
/**
* @type {'completed'|'partial'|'failed'|'insert_failed'}
*/
let uxState = $state('completed');
let status = $state(null);
let records = $state([]);
let batches = $state([]);
let showRecords = $state(false);
let showSqlAudit = $state(false);
let isRetrying = $state(false);
let isRetryingInsert = $state(false);
let sqlAuditLines = $state([]);
// Derived
let insertStatusBadge = $derived.by(() => {
const s = status?.insert_status;
if (!s || s === 'success') return { label: $t.translate?.run?.success, class: 'bg-green-100 text-green-700' };
if (s === 'failed' || s === 'timeout') return { label: $t.translate?.run?.failed, class: 'bg-red-100 text-red-700' };
return { label: s, class: 'bg-yellow-100 text-yellow-700' };
});
$effect(() => {
if (runId) {
loadData();
}
});
/** @returns {Promise<void>} */
async function loadData() {
try {
const [statusData, recordsData, batchesData] = await Promise.all([
fetchRunStatus(runId),
fetchRunRecords(runId, { page_size: 10 }),
fetchRunBatches(runId),
]);
status = statusData;
records = recordsData?.items || [];
batches = Array.isArray(batchesData) ? batchesData : [];
// Determine state
const s = statusData.status;
const insertS = statusData.insert_status;
if (s === 'COMPLETED' && insertS === 'failed') {
uxState = 'insert_failed';
} else if (s === 'COMPLETED' && (statusData.failed_records || 0) > 0) {
uxState = 'partial';
} else if (s === 'FAILED') {
uxState = 'failed';
} else {
uxState = 'completed';
}
} catch (err) {
addToast(err?.message || $t.translate?.run?.load_failed, 'error');
}
}
/** @returns {Promise<void>} */
async function handleRetry() {
isRetrying = true;
try {
await retryFailedBatches(runId);
addToast($t.translate?.run?.retry_success, 'success');
await loadData();
onRefresh();
} catch (err) {
addToast(err?.message || $t.translate?.run?.retry_failed_msg, 'error');
} finally {
isRetrying = false;
}
}
/** @returns {Promise<void>} */
async function handleRetryInsert() {
isRetryingInsert = true;
try {
await retryInsert(runId);
addToast($t.translate?.run?.insert_retry_success, 'success');
await loadData();
onRefresh();
} catch (err) {
addToast(err?.message || $t.translate?.run?.insert_retry_failed_msg, 'error');
} finally {
isRetryingInsert = false;
}
}
/** @param {string} text */
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
addToast($t.translate?.run?.copy_success, 'success');
}).catch(() => {
addToast($t.translate?.run?.copy_failed, 'error');
});
}
/** @returns {Promise<void>} */
async function toggleSqlAudit() {
showSqlAudit = !showSqlAudit;
if (showSqlAudit && sqlAuditLines.length === 0) {
// Load all records with successful translation for SQL audit
try {
const allRecords = await fetchRunRecords(runId, { page_size: 500, status: 'SUCCESS' });
const items = allRecords?.items || [];
sqlAuditLines = items.slice(0, 20).map(r => r.target_sql).filter(Boolean);
} catch (err) {
sqlAuditLines = [$t.translate?.run?.no_sql_audit];
}
}
}
</script>
<div class="translation-run-result">
{#if !status}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<p class="text-sm text-gray-500">{$t.translate?.run?.loading_result}</p>
</div>
{:else}
<div class="bg-white border border-gray-200 rounded-lg p-4 space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">{$t.translate?.run?.result_title}</h3>
<div class="flex items-center gap-2">
<!-- Retry buttons -->
{#if uxState === 'failed' || uxState === 'partial'}
<button
onclick={handleRetry}
disabled={isRetrying}
class="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isRetrying ? $t.translate?.run?.retrying : $t.translate?.run?.retry_failed}
</button>
{/if}
{#if uxState === 'insert_failed'}
<button
onclick={handleRetryInsert}
disabled={isRetryingInsert}
class="px-3 py-1.5 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 transition-colors"
>
{isRetryingInsert ? $t.translate?.run?.retrying_insert : $t.translate?.run?.retry_insert}
</button>
{/if}
</div>
</div>
<!-- Statistics Grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="bg-gray-50 rounded-lg p-3">
<p class="text-xs text-gray-500 uppercase">{$t.translate?.run?.total_records}</p>
<p class="text-xl font-bold text-gray-900">{status.total_records || 0}</p>
</div>
<div class="bg-green-50 rounded-lg p-3">
<p class="text-xs text-green-600 uppercase">{$t.translate?.run?.success}</p>
<p class="text-xl font-bold text-green-700">{status.successful_records || 0}</p>
</div>
<div class="bg-red-50 rounded-lg p-3">
<p class="text-xs text-red-600 uppercase">{$t.translate?.run?.failed}</p>
<p class="text-xl font-bold text-red-700">{status.failed_records || 0}</p>
</div>
<div class="bg-yellow-50 rounded-lg p-3">
<p class="text-xs text-yellow-600 uppercase">{$t.translate?.run?.skipped}</p>
<p class="text-xl font-bold text-yellow-700">{status.skipped_records || 0}</p>
</div>
</div>
<!-- Status Details -->
<div class="border-t border-gray-200 pt-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">{$t.translate?.run?.run_id}</span>
<span class="ml-1 font-mono text-xs text-gray-700">{status.id}</span>
<button
onclick={() => copyToClipboard(status.id)}
class="ml-1 text-blue-500 hover:text-blue-700 text-xs"
title={$t.translate?.run?.copy_id}
>
{$t.translate?.run?.copy_id}
</button>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.insert_status}</span>
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertStatusBadge.class}">
{insertStatusBadge.label}
</span>
</div>
{#if status.superset_execution_id}
<div>
<span class="text-gray-500">{$t.translate?.run?.superset_query}</span>
<span class="ml-1 font-mono text-xs text-gray-700">{status.superset_execution_id}</span>
<button
onclick={() => copyToClipboard(status.superset_execution_id)}
class="ml-1 text-blue-500 hover:text-blue-700 text-xs"
title={$t.translate?.run?.copy_id}
>
{$t.translate?.run?.copy_id}
</button>
</div>
{/if}
{#if status.error_message}
<div class="col-span-2">
<span class="text-red-600 text-xs">{$t.translate?.run?.error_label?.replace('{error}', status.error_message)}</span>
</div>
{/if}
</div>
</div>
<!-- SQL Audit Block (collapsed) -->
<div class="border-t border-gray-200 pt-3">
<button
onclick={toggleSqlAudit}
class="flex items-center gap-2 text-sm text-gray-700 hover:text-gray-900"
>
<svg
class="w-4 h-4 transition-transform {showSqlAudit ? '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?.sql_audit?.replace('{count}', sqlAuditLines.length)}
</button>
{#if showSqlAudit}
<div class="mt-2 bg-gray-900 rounded-lg p-3 max-h-60 overflow-y-auto">
{#if sqlAuditLines.length === 0}
<p class="text-xs text-gray-400">{$t.translate?.run?.no_sql_audit}</p>
{:else}
{#each sqlAuditLines as line, idx}
<pre class="text-xs text-green-400 mb-1 font-mono whitespace-pre-wrap break-all">
<code>{idx + 1}. {line}</code>
</pre>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Event Invariants -->
{#if status.event_invariants}
<div class="border-t border-gray-200 pt-3">
<div class="flex items-center gap-2 text-xs text-gray-500">
<span>{$t.translate?.run?.event_invariants}</span>
<span class="{status.event_invariants.invariant_valid ? 'text-green-600' : 'text-red-600'}">
{status.event_invariants.invariant_valid ? $t.translate?.run?.valid : $t.translate?.run?.violated}
</span>
<span class="text-gray-400">|</span>
<span>{$t.translate?.run?.superset_query_id_label} {status.event_invariants.has_run_started ? $t.translate?.run?.started_yes : $t.translate?.run?.started_no}</span>
<span class="text-gray-400">|</span>
<span>{$t.translate?.run?.terminal_events?.replace('{count}', status.event_invariants.terminal_event_count)}</span>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- [/DEF:TranslationRunResult:Component] -->

View File

@@ -0,0 +1,201 @@
// [DEF:TranslationPreviewComponentTest:Module]
// @COMPLEXITY: 3
// @PURPOSE: Contract-focused unit tests for TranslationPreview.svelte component.
// @LAYER: UI
// @RELATION: DEPENDS_ON -> [TranslationPreview:Component]
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COMPONENT_PATH = path.resolve(
process.cwd(),
'src/lib/components/translate/TranslationPreview.svelte'
);
// [DEF:TestComponentFileExists:Class]
// @PURPOSE: Verify the TranslationPreview component file exists.
describe('TranslationPreview Component', () => {
// [DEF:test_component_file_exists:Function]
// @PURPOSE: Verify component file exists and has required contracts
it('contains required UX tags and contract annotations', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(source).toContain('@COMPLEXITY: 4');
expect(source).toContain('@UX_STATE: idle');
expect(source).toContain('@UX_STATE: loading');
expect(source).toContain('@UX_STATE: preview_loaded');
expect(source).toContain('@UX_STATE: preview_error');
expect(source).toContain('@UX_STATE: accepted');
expect(source).toContain('@UX_STATE: stale_config');
expect(source).toContain('@UX_FEEDBACK');
expect(source).toContain('@UX_RECOVERY');
expect(source).toContain('fetchPreview');
expect(source).toContain('approveRow');
expect(source).toContain('editRow');
expect(source).toContain('rejectRow');
expect(source).toContain('acceptPreview');
});
// [/DEF:test_component_file_exists:Function]
});
// [DEF:TranslateApiTests:Class]
// @PURPOSE: Unit tests for translate API preview functions.
describe('Translate API Preview Functions', () => {
// Hoisted mock functions so vi.mock() factories (also hoisted) can reference them.
const mockPostApi = vi.hoisted(() => vi.fn());
const mockRequestApi = vi.hoisted(() => vi.fn());
const mockFetchApi = vi.hoisted(() => vi.fn());
vi.mock('$lib/api.js', () => ({
api: {
postApi: mockPostApi,
requestApi: mockRequestApi,
fetchApi: mockFetchApi,
}
}));
vi.mock('$lib/toasts.js', () => ({ addToast: vi.fn() }));
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
// [DEF:test_fetchPreview:Function]
// @PURPOSE: Verify fetchPreview calls postApi with correct endpoint and payload.
it('fetchPreview calls postApi with correct args', async () => {
mockPostApi.mockResolvedValue({
id: 'session-1',
job_id: 'job-123',
status: 'ACTIVE',
records: [
{ id: 'r1', source_sql: 'Hello', target_sql: 'Привет', status: 'PENDING' }
],
cost_estimate: {
sample_size: 1,
sample_total_tokens: 100,
sample_cost: 0.0002
}
});
const { fetchPreview } = await import('$lib/api/translate.js');
const result = await fetchPreview('job-123', 5);
expect(mockPostApi).toHaveBeenCalledWith('/translate/jobs/job-123/preview', { sample_size: 5 });
expect(result.records).toHaveLength(1);
expect(result.records[0].source_sql).toBe('Hello');
expect(result.cost_estimate.sample_cost).toBe(0.0002);
});
// [/DEF:test_fetchPreview:Function]
// [DEF:test_approveRow:Function]
// @PURPOSE: Verify approveRow calls requestApi with correct action.
it('approveRow calls requestApi with approve action', async () => {
mockRequestApi.mockResolvedValue({
id: 'r1',
status: 'APPROVED'
});
const { approveRow } = await import('$lib/api/translate.js');
const result = await approveRow('job-123', 'r1');
expect(mockRequestApi).toHaveBeenCalledWith(
'/translate/jobs/job-123/preview/rows/r1',
'PUT',
{ action: 'approve' }
);
expect(result.status).toBe('APPROVED');
});
// [/DEF:test_approveRow:Function]
// [DEF:test_editRow:Function]
// @PURPOSE: Verify editRow calls requestApi with edit action and translation.
it('editRow calls requestApi with edit action', async () => {
mockRequestApi.mockResolvedValue({
id: 'r1',
target_sql: 'Edited translation',
status: 'APPROVED'
});
const { editRow } = await import('$lib/api/translate.js');
const result = await editRow('job-123', 'r1', 'Edited translation');
expect(mockRequestApi).toHaveBeenCalledWith(
'/translate/jobs/job-123/preview/rows/r1',
'PUT',
{ action: 'edit', translation: 'Edited translation' }
);
expect(result.target_sql).toBe('Edited translation');
});
// [/DEF:test_editRow:Function]
// [DEF:test_rejectRow:Function]
// @PURPOSE: Verify rejectRow calls requestApi with reject action.
it('rejectRow calls requestApi with reject action', async () => {
mockRequestApi.mockResolvedValue({
id: 'r1',
status: 'REJECTED'
});
const { rejectRow } = await import('$lib/api/translate.js');
const result = await rejectRow('job-123', 'r1');
expect(mockRequestApi).toHaveBeenCalledWith(
'/translate/jobs/job-123/preview/rows/r1',
'PUT',
{ action: 'reject' }
);
expect(result.status).toBe('REJECTED');
});
// [/DEF:test_rejectRow:Function]
// [DEF:test_acceptPreview:Function]
// @PURPOSE: Verify acceptPreview calls postApi with correct endpoint.
it('acceptPreview calls postApi with correct endpoint', async () => {
mockPostApi.mockResolvedValue({
id: 'session-1',
job_id: 'job-123',
status: 'APPLIED',
records: [{ id: 'r1', status: 'APPROVED' }]
});
const { acceptPreview } = await import('$lib/api/translate.js');
const result = await acceptPreview('job-123');
expect(mockPostApi).toHaveBeenCalledWith('/translate/jobs/job-123/preview/accept', {});
expect(result.status).toBe('APPLIED');
});
// [/DEF:test_acceptPreview:Function]
// [DEF:test_fetchPreviewRecords:Function]
// @PURPOSE: Verify fetchPreviewRecords calls fetchApi with correct endpoint.
it('fetchPreviewRecords calls fetchApi with correct endpoint', async () => {
mockFetchApi.mockResolvedValue([
{ id: 'r1', source_sql: 'Hello', target_sql: 'Привет', status: 'APPROVED' }
]);
const { fetchPreviewRecords } = await import('$lib/api/translate.js');
const result = await fetchPreviewRecords('session-1');
expect(mockFetchApi).toHaveBeenCalledWith('/translate/preview/session-1/records');
expect(result).toHaveLength(1);
expect(result[0].status).toBe('APPROVED');
});
// [/DEF:test_fetchPreviewRecords:Function]
// [DEF:test_normalizeTranslateError:Function]
// @PURPOSE: Verify API errors are normalized consistently.
it('normalizes API errors for preview functions', async () => {
const error = new Error('API unavailable');
mockPostApi.mockRejectedValue(error);
const { fetchPreview } = await import('$lib/api/translate.js');
await expect(fetchPreview('job-123')).rejects.toThrow();
});
// [/DEF:test_normalizeTranslateError:Function]
});
// [/DEF:TranslateApiTests:Class]
// [/DEF:TranslationPreviewComponentTest:Module]

View File

@@ -62,6 +62,10 @@
"tools_debug": "System Debug",
"tools_storage": "File Storage",
"tools_llm": "LLM Tools",
"translation": "Translation",
"translation_jobs": "Jobs",
"translation_dictionaries": "Dictionaries",
"translation_history": "History",
"settings_general": "General Settings",
"settings_connections": "Connections",
"settings_git": "Git Integration",
@@ -1377,5 +1381,134 @@
"saved": "Saved",
"ad_group_placeholder": "e.g. CN=SS_ADMINS,OU=Groups,DC=org"
}
},
"translate": {
"common": {
"loading": "Loading...",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"back": "Back",
"delete": "Delete",
"retry": "Retry",
"create": "Create",
"edit": "Edit",
"next": "Next",
"previous": "Previous",
"unknown_error": "An unknown error occurred",
"error": "Error",
"success": "Success"
},
"config": {
"new_title": "New Translation Job",
"edit_title": "Edit Translation Job",
"basic_info": "Basic Info",
"name": "Name",
"name_placeholder": "Enter job name",
"description": "Description",
"description_placeholder": "Optional description",
"datasource": "Data Source",
"environment": "Superset Environment",
"select_environment": "Select environment...",
"datasource_id": "Source Datasource ID",
"datasource_search_placeholder": "Search datasets...",
"datasource_unavailable": "Datasource unavailable",
"select_datasource_hint": "Select a datasource to view its columns",
"detected_dialect": "Detected dialect: {dialect}",
"column_mapping": "Column Mapping",
"source_table": "Source Table",
"source_table_placeholder": "e.g. public.users",
"source_dialect": "Source Dialect",
"target_dialect": "Target Dialect",
"key_columns": "Key Columns (composite key)",
"add_key_column": "+ Add Key Column",
"translation_column": "Column to Translate",
"context_columns": "Context Columns (optional)",
"select_column": "Select a column...",
"available_columns": "Available columns ({count})",
"virtual": "virtual",
"virtual_column_warning": "Virtual columns cannot be used for translation: {col}",
"warnings_title": "Warnings",
"clear_all": "Clear all",
"target_table": "Target Table",
"target_table_title": "Target Table",
"target_schema": "Target Schema",
"target_schema_placeholder": "e.g. public",
"target_table_placeholder": "e.g. users_translated",
"target_language": "Target Language",
"batch_size": "Batch Size",
"upsert_strategy": "Strategy",
"upsert_merge": "UPSERT (MERGE)",
"upsert_insert": "INSERT only",
"upsert_update": "UPDATE only",
"llm_settings": "LLM Settings",
"provider": "Provider",
"select_provider": "Select a provider...",
"terminology_dictionaries": "Terminology Dictionaries",
"no_dictionaries": "No dictionaries configured",
"run_translation": "Run Translation",
"run_started": "Translation run started",
"running": "Running...",
"run_failed": "Translation run failed",
"cancel": "Cancel Run",
"back_to_jobs": "← Back to Jobs",
"recent_runs": "Recent Runs",
"id_label": "ID: {id}",
"save": "Save",
"cancel": "Cancel"
},
"preview": {
"title": "Preview",
"correct": "Correct",
"skip": "Skip",
"accept_all": "Accept All",
"applying": "Applying...",
"apply_success": "Corrections applied",
"apply_failed": "Failed to apply corrections",
"no_records": "No preview records available",
"loading": "Loading preview...",
"fetch_failed": "Failed to load preview"
},
"jobs": {
"title": "Translation Jobs",
"subtitle": "Manage your translation jobs",
"new_job": "New Job",
"create_job": "Create Job",
"status_draft": "Draft",
"status_ready": "Ready",
"status_running": "Running",
"status_completed": "Completed",
"status_failed": "Failed",
"no_jobs": "No translation jobs created yet. Create your first job to get started.",
"no_jobs_filtered": "No {status} jobs found.",
"load_failed": "Failed to load jobs",
"an_error_occurred": "An error occurred while loading jobs",
"clear_filter": "Clear Filter",
"confirm_delete": "Are you sure you want to delete this translation job?",
"cancel_delete": "Cancel",
"by_label": "by {by}",
"updated_label": "Updated {date}",
"column_label": "Column: {column}",
"language_label": "Language: {language}"
},
"dictionaries": {
"title": "Dictionaries",
"new_title": "New Dictionary",
"name": "Name",
"name_placeholder": "Enter dictionary name",
"desc": "Description",
"desc_placeholder": "Optional description",
"source_dialect": "Source Dialect",
"source_dialect_placeholder": "e.g. postgresql",
"target_dialect": "Target Dialect",
"target_dialect_placeholder": "e.g. clickhouse",
"entry_count": "{count} entries",
"create": "Create Dictionary",
"create_first": "Create your first dictionary",
"confirm_delete": "Delete this dictionary?",
"no_dicts": "No dictionaries yet",
"inactive": "Inactive",
"page_info": "Page {current} of {total}"
}
}
}

View File

@@ -62,6 +62,10 @@
"tools_debug": "Диагностика системы",
"tools_storage": "Хранилище файлов",
"tools_llm": "Инструменты LLM",
"translation": "Переводы",
"translation_jobs": "Задания",
"translation_dictionaries": "Словари",
"translation_history": "История",
"settings_general": "Общие настройки",
"settings_connections": "Подключения",
"settings_git": "Интеграция Git",
@@ -1377,5 +1381,134 @@
"saved": "Сохранено",
"ad_group_placeholder": "например, CN=SS_ADMINS,OU=Groups,DC=org"
}
},
"translate": {
"common": {
"loading": "Загрузка...",
"save": "Сохранить",
"saving": "Сохранение...",
"cancel": "Отмена",
"back": "Назад",
"delete": "Удалить",
"retry": "Повторить",
"create": "Создать",
"edit": "Редактировать",
"next": "Вперёд",
"previous": "Назад",
"unknown_error": "Произошла неизвестная ошибка",
"error": "Ошибка",
"success": "Успешно"
},
"config": {
"new_title": "Новое задание перевода",
"edit_title": "Редактирование задания перевода",
"basic_info": "Основная информация",
"name": "Название",
"name_placeholder": "Введите название задания",
"description": "Описание",
"description_placeholder": "Описание (необязательно)",
"datasource": "Источник данных",
"environment": "Окружение Superset",
"select_environment": "Выберите окружение...",
"datasource_id": "ID источника данных",
"datasource_search_placeholder": "Поиск датасетов...",
"datasource_unavailable": "Источник данных недоступен",
"select_datasource_hint": "Выберите источник данных для просмотра колонок",
"detected_dialect": "Обнаружен диалект: {dialect}",
"column_mapping": "Маппинг колонок",
"source_table": "Исходная таблица",
"source_table_placeholder": "например, public.users",
"source_dialect": "Диалект исходной БД",
"target_dialect": "Диалект целевой БД",
"key_columns": "Ключевые колонки (составной ключ)",
"add_key_column": "+ Добавить ключевую колонку",
"translation_column": "Колонка для перевода",
"context_columns": "Контекстные колонки (необязательно)",
"select_column": "Выберите колонку...",
"available_columns": "Доступные колонки ({count})",
"virtual": "виртуальная",
"virtual_column_warning": "Виртуальные колонки нельзя использовать для перевода: {col}",
"warnings_title": "Предупреждения",
"clear_all": "Очистить всё",
"target_table": "Целевая таблица",
"target_table_title": "Целевая таблица",
"target_schema": "Целевая схема",
"target_schema_placeholder": "например, public",
"target_table_placeholder": "например, users_translated",
"target_language": "Язык перевода",
"batch_size": "Размер пакета",
"upsert_strategy": "Стратегия",
"upsert_merge": "UPSERT (MERGE)",
"upsert_insert": "Только INSERT",
"upsert_update": "Только UPDATE",
"llm_settings": "Настройки LLM",
"provider": "Провайдер",
"select_provider": "Выберите провайдера...",
"terminology_dictionaries": "Терминологические словари",
"no_dictionaries": "Нет настроенных словарей",
"run_translation": "Запустить перевод",
"run_started": "Запуск перевода начат",
"running": "Выполняется...",
"run_failed": "Ошибка запуска перевода",
"cancel": "Отменить запуск",
"back_to_jobs": "← К списку заданий",
"recent_runs": "Последние запуски",
"id_label": "ID: {id}",
"save": "Сохранить",
"cancel": "Отмена"
},
"preview": {
"title": "Предпросмотр",
"correct": "Исправить",
"skip": "Пропустить",
"accept_all": "Принять все",
"applying": "Применение...",
"apply_success": "Исправления применены",
"apply_failed": "Не удалось применить исправления",
"no_records": "Нет записей для предпросмотра",
"loading": "Загрузка предпросмотра...",
"fetch_failed": "Не удалось загрузить предпросмотр"
},
"jobs": {
"title": "Задания перевода",
"subtitle": "Управление заданиями перевода",
"new_job": "Новое задание",
"create_job": "Создать задание",
"status_draft": "Черновик",
"status_ready": "Готово",
"status_running": "Выполняется",
"status_completed": "Завершено",
"status_failed": "Ошибка",
"no_jobs": "Заданий перевода ещё нет. Создайте первое задание чтобы начать.",
"no_jobs_filtered": "Задания со статусом {status} не найдены.",
"load_failed": "Не удалось загрузить задания",
"an_error_occurred": "Произошла ошибка при загрузке заданий",
"clear_filter": "Сбросить фильтр",
"confirm_delete": "Вы уверены, что хотите удалить это задание перевода?",
"cancel_delete": "Отмена",
"by_label": "{by}",
"updated_label": "Обновлено {date}",
"column_label": "Колонка: {column}",
"language_label": "Язык: {language}"
},
"dictionaries": {
"title": "Словари",
"new_title": "Новый словарь",
"name": "Название",
"name_placeholder": "Введите название словаря",
"desc": "Описание",
"desc_placeholder": "Описание (необязательно)",
"source_dialect": "Диалект источника",
"source_dialect_placeholder": "например, postgresql",
"target_dialect": "Диалект цели",
"target_dialect_placeholder": "например, clickhouse",
"entry_count": "{count} записей",
"create": "Создать словарь",
"create_first": "Создайте первый словарь",
"confirm_delete": "Удалить этот словарь?",
"no_dicts": "Словарей пока нет",
"inactive": "Неактивен",
"page_info": "Страница {current} из {total}"
}
}
}

View File

@@ -30,6 +30,15 @@
],
storage: ["M3 8l9-4 9 4-9 4-9-4z", "M3 13l9 4 9-4", "M3 17l9 4 9-4"],
reports: ["M5 5h14v14H5z", "M8 9h8", "M8 13h8", "M8 17h5"],
translate: [
"M2 12h20",
"M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10",
"M12 2a15.3 15.3 0 00-4 10 15.3 15.3 0 004 10",
"M4 6H2",
"M22 6h-2",
"M9 18l-3 4",
"M15 18l3 4",
],
admin: [
"M12 3l8 4v5c0 5.2-3.4 8.6-8 9.9C7.4 20.6 4 17.2 4 12V7l8-4z",
"M9 12l2 2 4-4",

View File

@@ -0,0 +1,286 @@
<!-- [DEF:TranslateJobList:Page] -->
<script>
/**
* @COMPLEXITY: 3
* @PURPOSE: Translation job list page showing all jobs with status and schedule indicators.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @PRE: Valid auth context before fetching job list.
* @POST: Renders job list with status badges and navigation to config.
*
* @UX_STATE: idle -> Initial state before data load
* @UX_STATE: loading -> Shows loading skeleton
* @UX_STATE: empty -> Shows empty state with create button
* @UX_STATE: populated -> Shows job cards with status indicators
* @UX_STATE: error -> Shows error message with retry button
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { t, _ } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import { fetchJobs, deleteJob, duplicateJob } from '$lib/api/translate.js';
/** @type {string} idle | loading | empty | populated | error */
let uxState = $state('idle');
let jobs = $state([]);
let error = $state(null);
let isLoading = $state(true);
let showDeleteConfirm = $state(null);
let statusFilter = $state('');
let currentPage = $state(1);
let pageSize = $state(20);
// Count jobs by status for filter pills
let statusCounts = $derived.by(() => {
const counts = { DRAFT: 0, READY: 0, RUNNING: 0, COMPLETED: 0, FAILED: 0, CANCELLED: 0 };
for (const job of jobs) {
if (counts[job.status] !== undefined) counts[job.status]++;
}
return counts;
});
let statusPills = $derived([
{ label: 'All', value: '', count: jobs.length },
{ label: $t.translate?.jobs?.status_draft, value: 'DRAFT', count: statusCounts.DRAFT },
{ label: $t.translate?.jobs?.status_ready, value: 'READY', count: statusCounts.READY },
{ label: $t.translate?.jobs?.status_running, value: 'RUNNING', count: statusCounts.RUNNING },
{ label: $t.translate?.jobs?.status_completed, value: 'COMPLETED', count: statusCounts.COMPLETED },
{ label: $t.translate?.jobs?.status_failed, value: 'FAILED', count: statusCounts.FAILED },
]);
onMount(() => {
loadJobs();
});
/** @returns {Promise<void>} */
async function loadJobs() {
isLoading = true;
uxState = 'loading';
error = null;
try {
const result = await fetchJobs({ page: currentPage, page_size: pageSize, status: statusFilter || undefined });
jobs = Array.isArray(result) ? result : (result?.results || result?.items || []);
uxState = jobs.length === 0 ? 'empty' : 'populated';
} catch (err) {
error = err?.message || $t.translate?.jobs?.load_failed;
uxState = 'error';
} finally {
isLoading = false;
}
}
/** @param {string} status */
function filterByStatus(status) {
statusFilter = status;
currentPage = 1;
loadJobs();
}
/** @param {string} jobId */
function navigateToConfig(jobId) {
goto(`/translate/${jobId}`);
}
function navigateToCreate() {
goto('/translate/new');
}
/** @param {string} jobId */
async function handleDuplicate(jobId) {
try {
const result = await duplicateJob(jobId);
addToast(`${_('translate.jobs.job_duplicated').replace('{name}', result.name)}`, 'success');
loadJobs();
} catch (err) {
addToast(err?.message || _('translate.jobs.duplicate_failed'), 'error');
}
}
/** @param {string} jobId */
async function handleDelete(jobId) {
try {
await deleteJob(jobId);
addToast(_('translate.jobs.job_deleted'), 'success');
showDeleteConfirm = null;
loadJobs();
} catch (err) {
addToast(err?.message || _('translate.jobs.delete_failed'), 'error');
showDeleteConfirm = null;
}
}
/** @param {string} status */
function getStatusBadgeClass(status) {
const map = {
DRAFT: 'bg-gray-100 text-gray-700',
READY: 'bg-blue-100 text-blue-700',
RUNNING: 'bg-yellow-100 text-yellow-700',
COMPLETED: 'bg-green-100 text-green-700',
FAILED: 'bg-red-100 text-red-700',
CANCELLED: 'bg-gray-100 text-gray-500',
};
return map[status] || 'bg-gray-100 text-gray-700';
}
</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-gray-900">{$t.translate?.jobs?.title}</h1>
<p class="text-sm text-gray-500 mt-1">
{$t.translate?.jobs?.subtitle}
</p>
</div>
<button
onclick={navigateToCreate}
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{$t.translate?.jobs?.new_job}
</button>
</div>
<!-- Filter Pills -->
<div class="flex flex-wrap gap-2 mb-4">
{#each statusPills as pill}
<button
onclick={() => filterByStatus(pill.value)}
class="px-3 py-1.5 text-sm rounded-full border transition-colors
{statusFilter === pill.value
? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
>
{pill.label}
<span class="ml-1 text-xs opacity-70">({pill.count})</span>
</button>
{/each}
</div>
<!-- Loading State -->
{#if isLoading}
<div class="space-y-3">
{#each Array(5) as _}
<div class="h-20 bg-gray-100 rounded-lg animate-pulse" />
{/each}
</div>
<!-- Error State -->
{:else if uxState === 'error'}
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-700 mb-3">{error || $t.translate?.jobs?.an_error_occurred}</p>
<button
onclick={loadJobs}
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
{$t.translate?.common?.retry}
</button>
</div>
<!-- Empty State -->
{:else if uxState === 'empty'}
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{#if statusFilter}
<p class="text-gray-500 mb-2">{$t.translate?.jobs?.no_jobs_filtered.replace('{status}', statusFilter)}</p>
<button
onclick={() => filterByStatus('')}
class="text-blue-600 hover:text-blue-700 text-sm"
>
{$t.translate?.jobs?.clear_filter}
</button>
{:else}
<p class="text-gray-500 mb-4">{$t.translate?.jobs?.no_jobs}</p>
<button
onclick={navigateToCreate}
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{$t.translate?.jobs?.create_job}
</button>
{/if}
</div>
<!-- Populated State -->
{:else if uxState === 'populated'}
<div class="grid gap-4">
{#each jobs as job}
<div
onclick={() => navigateToConfig(job.id)}
class="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md hover:border-gray-300 transition-all cursor-pointer"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-lg font-semibold text-gray-900 truncate">{job.name}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getStatusBadgeClass(job.status)}">
{job.status}
</span>
</div>
{#if job.description}
<p class="text-sm text-gray-500 truncate mb-2">{job.description}</p>
{/if}
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-400">
{#if job.source_dialect && job.target_dialect}
<span>{job.source_dialect} &rarr; {job.target_dialect}</span>
{/if}
{#if job.target_language}
<span>{$t.translate?.jobs?.language_label.replace('{language}', job.target_language)}</span>
{/if}
{#if job.translation_column}
<span>{$t.translate?.jobs?.column_label.replace('{column}', job.translation_column)}</span>
{/if}
{#if job.created_by}
<span>{$t.translate?.jobs?.by_label.replace('{by}', job.created_by)}</span>
{/if}
{#if job.updated_at}
<span>{$t.translate?.jobs?.updated_label.replace('{date}', new Date(job.updated_at).toLocaleDateString())}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-2 ml-4" onclick={(e) => e.stopPropagation()}>
<button
onclick={() => handleDuplicate(job.id)}
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
title={$t.translate?.jobs?.duplicate}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
{#if showDeleteConfirm === job.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(job.id)}
class="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
>
{$t.translate?.jobs?.confirm_delete}
</button>
<button
onclick={() => (showDeleteConfirm = null)}
class="px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
>
{$t.translate?.jobs?.cancel_delete}
</button>
</div>
{:else}
<button
onclick={() => (showDeleteConfirm = job.id)}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
title={$t.translate?.common?.delete}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,295 @@
<!-- [DEF:DictionaryList:Component] -->
<!--
@COMPLEXITY: 4
@SEMANTICS: translate, dictionaries, list, crud
@PURPOSE: Terminology dictionary list page with CRUD actions and delete-blocked handling.
@LAYER: UI
@RELATION: DEPENDS_ON -> [TranslateApi]
@RELATION: DEPENDS_ON -> [PageHeader]
@RELATION: DEPENDS_ON -> [Card]
@UX_STATE: idle -> Initial state before loading.
@UX_STATE: loading -> Skeleton shown while fetching dictionaries.
@UX_STATE: empty -> No dictionaries exist yet; create CTA shown.
@UX_STATE: populated -> Dictionary list rendered with actions.
@UX_STATE: delete_blocked -> Toast/alert when deletion is blocked by active job attachments.
@PRE: Translate API is reachable before list hydration.
@POST: Dictionary list is rendered. Create, edit, delete actions are available.
@SIDE_EFFECT: Fetches dictionary list on mount; performs create and delete mutations.
-->
<script>
/**
* @UX_STATE: idle, loading, empty, populated, delete_blocked
* @UX_FEEDBACK: Toast on create/delete success/failure; confirmation dialog on delete.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { t, _ } from '$lib/i18n';
import { PageHeader } from '$lib/ui';
import { Button } from '$lib/ui';
import { Card } from '$lib/ui';
import { addToast } from '$lib/toasts.js';
import { dictionaryApi } from '$lib/api/translate.js';
let state = $state('loading'); // idle | loading | empty | populated | delete_blocked
let dictionaries = $state([]);
let total = $state(0);
let page_size = 20;
let currentPage = $state(1);
let showCreateForm = $state(false);
let createForm = $state({ name: '', source_dialect: '', target_dialect: '', description: '' });
let isCreating = $state(false);
let deleteConfirmId = $state(null);
async function loadDictionaries() {
state = 'loading';
try {
const res = await dictionaryApi.fetchDictionaries({ page: currentPage, page_size });
dictionaries = res.items || [];
total = res.total || 0;
state = dictionaries.length > 0 ? 'populated' : 'empty';
} catch (e) {
state = 'empty';
addToast(e?.message || _('translate.dictionaries.load_failed'), 'error');
}
}
async function handleCreate() {
if (!createForm.name || !createForm.source_dialect || !createForm.target_dialect) {
addToast(_('translate.dictionaries.name_required'), 'error');
return;
}
isCreating = true;
try {
await dictionaryApi.createDictionary(createForm);
addToast(_('translate.dictionaries.dict_created'), 'success');
showCreateForm = false;
createForm = { name: '', source_dialect: '', target_dialect: '', description: '' };
currentPage = 1;
await loadDictionaries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.create_failed'), 'error');
} finally {
isCreating = false;
}
}
async function handleDelete(id) {
deleteConfirmId = null;
try {
await dictionaryApi.deleteDictionary(id);
addToast(_('translate.dictionaries.delete_success'), 'success');
await loadDictionaries();
} catch (e) {
const msg = e?.message || '';
if (msg.includes('active/scheduled')) {
state = 'delete_blocked';
addToast(msg, 'error');
} else {
addToast(msg || _('translate.dictionaries.delete_failed'), 'error');
}
}
}
function openDeleteConfirm(id) {
deleteConfirmId = id;
}
function cancelDelete() {
deleteConfirmId = null;
}
function goToEditor(id) {
goto(`/translate/dictionaries/${id}`);
}
function goToCreate() {
showCreateForm = true;
}
onMount(loadDictionaries);
</script>
<div class="mx-auto w-full max-w-5xl space-y-6">
{#snippet pageActions()}
{#if !showCreateForm}
<Button onclick={goToCreate} variant="primary">
{$t.translate?.dictionaries?.create}
</Button>
{/if}
{/snippet}
<PageHeader
title={$t.translate?.dictionaries?.title}
actions={pageActions}
/>
{#if showCreateForm}
<Card title={$t.translate?.dictionaries?.new_title} padding="md">
<div class="space-y-4">
<div>
<label for="dict-name" class="text-sm font-medium text-gray-700">{$t.translate?.dictionaries?.name} *</label>
<input
id="dict-name"
bind:value={createForm.name}
placeholder={$t.translate?.dictionaries?.name_placeholder}
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="dict-source" class="text-sm font-medium text-gray-700">{$t.translate?.dictionaries?.source_dialect} *</label>
<input
id="dict-source"
bind:value={createForm.source_dialect}
placeholder={$t.translate?.dictionaries?.source_dialect_placeholder}
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm"
/>
</div>
<div>
<label for="dict-target" class="text-sm font-medium text-gray-700">{$t.translate?.dictionaries?.target_dialect} *</label>
<input
id="dict-target"
bind:value={createForm.target_dialect}
placeholder={$t.translate?.dictionaries?.target_dialect_placeholder}
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label for="dict-desc" class="text-sm font-medium text-gray-700">{$t.translate?.dictionaries?.desc}</label>
<textarea
id="dict-desc"
bind:value={createForm.description}
placeholder={$t.translate?.dictionaries?.desc_placeholder}
class="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm min-h-[60px]"
></textarea>
</div>
<div class="flex gap-3">
<Button onclick={handleCreate} isLoading={isCreating}>{$t.translate?.common?.create}</Button>
<Button
variant="secondary"
onclick={() => {
showCreateForm = false;
createForm = { name: '', source_dialect: '', target_dialect: '', description: '' };
}}
>
{$t.translate?.common?.cancel}
</Button>
</div>
</div>
</Card>
{/if}
{#if state === 'loading'}
<Card padding="md">
<div class="space-y-3">
{#each [1, 2, 3] as _}
<div class="h-16 animate-pulse rounded-md bg-gray-100"></div>
{/each}
</div>
</Card>
{:else if state === 'empty'}
<Card padding="lg">
<div class="text-center py-8">
<p class="text-gray-500 mb-4">{$t.translate?.dictionaries?.no_dicts}</p>
<Button onclick={goToCreate} variant="primary">{$t.translate?.dictionaries?.create_first}</Button>
</div>
</Card>
{:else if state === 'populated'}
<Card padding="none">
<div class="divide-y divide-gray-100">
{#each dictionaries as dict}
<div class="flex items-center justify-between p-4 hover:bg-gray-50 transition-colors">
<div class="flex-1 min-w-0">
<button
class="text-left"
onclick={() => goToEditor(dict.id)}
>
<h3 class="text-sm font-semibold text-gray-900 hover:text-primary truncate">{dict.name}</h3>
<p class="text-xs text-gray-500 mt-0.5">
{dict.source_dialect} &rarr; {dict.target_dialect}
{#if dict.description}
&mdash; {dict.description}
{/if}
</p>
<p class="text-xs text-gray-400 mt-0.5">
{$t.translate?.dictionaries?.entry_count.replace('{count}', dict.entry_count ?? 0)}
{#if !dict.is_active}
<span class="ml-2 text-amber-600">{$t.translate?.dictionaries?.inactive}</span>
{/if}
</p>
</button>
</div>
<div class="flex items-center gap-2 ml-4 shrink-0">
<Button
variant="ghost"
size="sm"
onclick={() => goToEditor(dict.id)}
>
{$t.translate?.common?.edit}
</Button>
{#if deleteConfirmId === dict.id}
<div class="flex items-center gap-1">
<span class="text-xs text-red-600">{$t.translate?.dictionaries?.confirm_delete}</span>
<Button
variant="danger"
size="sm"
onclick={() => handleDelete(dict.id)}
>
{$t.translate?.common?.delete}
</Button>
<Button
variant="ghost"
size="sm"
onclick={cancelDelete}
>
{$t.translate?.common?.cancel}
</Button>
</div>
{:else}
<Button
variant="ghost"
size="sm"
onclick={() => openDeleteConfirm(dict.id)}
>
{$t.translate?.common?.delete}
</Button>
{/if}
</div>
</div>
{/each}
</div>
</Card>
{#if total > page_size}
<div class="flex justify-center gap-2 pt-4">
<Button
variant="secondary"
size="sm"
disabled={currentPage <= 1}
onclick={() => { currentPage--; loadDictionaries(); }}
>
{$t.translate?.common?.previous}
</Button>
<span class="text-sm text-gray-500 self-center">
{$t.translate?.dictionaries?.page_info.replace('{current}', currentPage).replace('{total}', Math.ceil(total / page_size))}
</span>
<Button
variant="secondary"
size="sm"
disabled={currentPage >= Math.ceil(total / page_size)}
onclick={() => { currentPage++; loadDictionaries(); }}
>
{$t.translate?.common?.next}
</Button>
</div>
{/if}
{/if}
</div>
<!-- [/DEF:DictionaryList:Component] -->

View File

@@ -0,0 +1,568 @@
<!-- [DEF:DictionaryEditor:Component] -->
<!--
@COMPLEXITY: 4
@SEMANTICS: translate, dictionary, editor, entries, import, csv, tsv
@PURPOSE: Inline term editor for a single dictionary with add/delete rows, CSV/TSV import with conflict preview, and export.
@LAYER: UI
@RELATION: DEPENDS_ON -> [TranslateApi]
@RELATION: DEPENDS_ON -> [Card]
@RELATION: DEPENDS_ON -> [Button]
@RELATION: DEPENDS_ON -> [PageHeader]
@UX_STATE: idle -> Initial state before loading.
@UX_STATE: loading -> Skeleton shown while fetching dictionary.
@UX_STATE: editing -> Dictionary loaded, entries shown, editable.
@UX_STATE: importing -> Import modal/form is open.
@UX_STATE: import_preview -> Showing import preview with flags.
@UX_STATE: import_conflict -> Resolution needed for import conflicts.
@UX_STATE: saving -> Entry mutations in progress.
@UX_FEEDBACK: Import preview with duplicate flags; toast on save.
@PRE: Dictionary ID is provided in route params and API is reachable.
@POST: Dictionary entries are displayed, editable, and persistable.
@SIDE_EFFECT: Fetches dictionary and entries on mount; performs entry CRUD and import mutations.
-->
<script>
/**
* @UX_STATE: idle, loading, editing, importing, import_preview, import_conflict, saving
* @UX_FEEDBACK: import preview with duplicate flags; toast on save.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { t, _ } from '$lib/i18n';
import { PageHeader, Button, Card } from '$lib/ui';
import { addToast } from '$lib/toasts.js';
import { dictionaryApi } from '$lib/api/translate.js';
let dictionaryId = $derived(page.params.id);
let dictionary = $state(null);
let entries = $state([]);
let totalEntries = $state(0);
let entryPage = $state(1);
let entryPageSize = 100;
let state = $state('loading'); // idle | loading | editing | importing | import_preview | import_conflict | saving
// Edit form for entries
let editEntryId = $state(null);
let editForm = $state({ source_term: '', target_term: '', context_notes: '' });
// Add new entry form
let showAddForm = $state(false);
let addForm = $state({ source_term: '', target_term: '', context_notes: '' });
let isAdding = $state(false);
// Import
let showImportForm = $state(false);
let importContent = $state('');
let importDelimiter = $state('');
let importOnConflict = $state('overwrite');
let importPreview = $state([]);
let importErrors = $state([]);
let importResult = $state(null);
let isImporting = $state(false);
// Delete confirmation
let deleteEntryId = $state(null);
async function loadDictionary() {
state = 'loading';
try {
dictionary = await dictionaryApi.fetchDictionary(dictionaryId);
await loadEntries();
state = 'editing';
} catch (e) {
state = 'idle';
addToast(e?.message || _('translate.dictionaries.load_failed'), 'error');
}
}
async function loadEntries() {
try {
const res = await dictionaryApi.fetchDictionaryEntries(dictionaryId, {
page: entryPage,
page_size: entryPageSize,
});
entries = res.items || [];
totalEntries = res.total || 0;
} catch (e) {
addToast(e?.message || _('translate.dictionaries.load_entries_failed'), 'error');
}
}
// --- Entry CRUD ---
function startAddEntry() {
showAddForm = true;
addForm = { source_term: '', target_term: '', context_notes: '' };
}
async function handleAddEntry() {
if (!addForm.source_term || !addForm.target_term) {
addToast(_('translate.dictionaries.required_terms'), 'error');
return;
}
isAdding = true;
state = 'saving';
try {
await dictionaryApi.addEntry(dictionaryId, addForm);
addToast(_('translate.dictionaries.entry_added'), 'success');
showAddForm = false;
addForm = { source_term: '', target_term: '', context_notes: '' };
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.entry_add_failed'), 'error');
} finally {
isAdding = false;
state = 'editing';
}
}
function startEditEntry(entry) {
editEntryId = entry.id;
editForm = {
source_term: entry.source_term,
target_term: entry.target_term,
context_notes: entry.context_notes || '',
};
}
function cancelEditEntry() {
editEntryId = null;
editForm = { source_term: '', target_term: '', context_notes: '' };
}
async function handleEditEntry() {
if (!editForm.source_term || !editForm.target_term) {
addToast(_('translate.dictionaries.required_terms'), 'error');
return;
}
state = 'saving';
try {
await dictionaryApi.editEntry(dictionaryId, editEntryId, editForm);
addToast(_('translate.dictionaries.entry_updated'), 'success');
editEntryId = null;
editForm = { source_term: '', target_term: '', context_notes: '' };
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.entry_update_failed'), 'error');
} finally {
state = 'editing';
}
}
async function handleDeleteEntry(entryId) {
deleteEntryId = null;
state = 'saving';
try {
await dictionaryApi.deleteEntry(dictionaryId, entryId);
addToast(_('translate.dictionaries.entry_deleted'), 'success');
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.entry_delete_failed'), 'error');
} finally {
state = 'editing';
}
}
function confirmDeleteEntry(entryId) {
deleteEntryId = entryId;
}
function cancelDeleteEntry() {
deleteEntryId = null;
}
// --- Import ---
function startImport() {
showImportForm = true;
importContent = '';
importDelimiter = '';
importOnConflict = 'overwrite';
importPreview = [];
importErrors = [];
importResult = null;
state = 'importing';
}
function cancelImport() {
showImportForm = false;
importContent = '';
importPreview = [];
importErrors = [];
importResult = null;
state = 'editing';
}
async function handlePreviewImport() {
if (!importContent.trim()) {
addToast(_('translate.dictionaries.paste_content_first'), 'error');
return;
}
isImporting = true;
try {
const res = await dictionaryApi.importDictionary(dictionaryId, {
content: importContent,
delimiter: importDelimiter || null,
on_conflict: importOnConflict,
preview_only: true,
});
importPreview = res.preview || [];
importErrors = res.errors || [];
state = 'import_preview';
} catch (e) {
addToast(e?.message || _('translate.dictionaries.preview_failed'), 'error');
} finally {
isImporting = false;
}
}
async function handleExecuteImport() {
isImporting = true;
try {
const res = await dictionaryApi.importDictionary(dictionaryId, {
content: importContent,
delimiter: importDelimiter || null,
on_conflict: importOnConflict,
preview_only: false,
});
importResult = res;
importPreview = res.preview || [];
importErrors = res.errors || [];
addToast(_('translate.dictionaries.import_complete').replace('{created}', res.created).replace('{updated}', res.updated).replace('{skipped}', res.skipped), 'success');
state = 'editing';
showImportForm = false;
await loadEntries();
} catch (e) {
addToast(e?.message || _('translate.dictionaries.import_failed'), 'error');
} finally {
isImporting = false;
}
}
// --- Export ---
function handleExport() {
const header = 'source_term,target_term,context_notes';
const rows = entries.map(e =>
[
`"${(e.source_term || '').replace(/"/g, '""')}"`,
`"${(e.target_term || '').replace(/"/g, '""')}"`,
`"${(e.context_notes || '').replace(/"/g, '""')}"`,
].join(',')
);
const csv = [header, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${dictionary?.name || 'dictionary'}-entries.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
addToast(_('translate.dictionaries.export_success'), 'info');
}
onMount(loadDictionary);
</script>
<div class="mx-auto w-full max-w-6xl space-y-6">
{#snippet subtitleContent()}
{#if dictionary}
<p class="text-sm text-gray-500">
{dictionary.source_dialect} &rarr; {dictionary.target_dialect}
{#if dictionary.description}
&mdash; {dictionary.description}
{/if}
&middot; {$t.translate?.dictionaries?.entry_count.replace('{count}', totalEntries)}
</p>
{/if}
{/snippet}
{#snippet dictActions()}
{#if state === 'editing'}
<div class="flex gap-2">
<Button variant="secondary" size="sm" onclick={startImport}>
{$t.translate?.dictionaries?.import_csv_tsv}
</Button>
<Button variant="secondary" size="sm" onclick={handleExport}>
{$t.translate?.dictionaries?.export_csv}
</Button>
<Button variant="primary" size="sm" onclick={startAddEntry}>
{$t.translate?.dictionaries?.add_entry}
</Button>
</div>
{/if}
{/snippet}
<PageHeader
title={dictionary?.name || $t.translate?.dictionaries?.loading}
subtitle={subtitleContent}
actions={dictActions}
/>
{#if state === 'loading'}
<Card padding="md">
<div class="space-y-3">
{#each [1, 2, 3] as _}
<div class="h-12 animate-pulse rounded-md bg-gray-100"></div>
{/each}
</div>
</Card>
{:else if state === 'importing' || state === 'import_preview'}
<Card title={$t.translate?.dictionaries?.import_title} padding="md">
<div class="space-y-4">
{#if state === 'importing'}
<div>
<label for="import-content" class="text-sm font-medium text-gray-700">
CSV/TSV Content (columns: source_term, target_term, context_notes)
</label>
<textarea
id="import-content"
bind:value={importContent}
placeholder="source_term,target_term,context_notes&#10;hello,hola,&#10;world,mundo,"
class="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono min-h-[200px] mt-1"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="import-delimiter" class="text-sm font-medium text-gray-700">Delimiter</label>
<select
id="import-delimiter"
bind:value={importDelimiter}
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm mt-1"
>
<option value="">Auto-detect</option>
<option value=",">Comma (CSV)</option>
<option value="\t">Tab (TSV)</option>
</select>
</div>
<div>
<label for="import-conflict" class="text-sm font-medium text-gray-700">On Conflict</label>
<select
id="import-conflict"
bind:value={importOnConflict}
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm mt-1"
>
<option value="overwrite">Overwrite existing</option>
<option value="keep_existing">Keep existing</option>
<option value="cancel">Cancel on conflict</option>
</select>
</div>
</div>
<div class="flex gap-3">
<Button onclick={handlePreviewImport} isLoading={isImporting}>
Preview Import
</Button>
<Button variant="secondary" onclick={cancelImport}>
Cancel
</Button>
</div>
{/if}
{#if state === 'import_preview'}
{#if importPreview.length > 0}
<div class="max-h-64 overflow-y-auto border rounded-md">
<table class="w-full text-xs">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="text-left p-2">Line</th>
<th class="text-left p-2">Source</th>
<th class="text-left p-2">Target</th>
<th class="text-left p-2">Conflict</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each importPreview as row}
<tr class={row.is_conflict ? 'bg-amber-50' : ''}>
<td class="p-2 text-gray-400">{row.line}</td>
<td class="p-2">{row.source_term}</td>
<td class="p-2">{row.target_term}</td>
<td class="p-2">
{#if row.is_conflict}
<span class="text-amber-600">
Existing: {row.existing_target_term}
({importOnConflict === 'overwrite' ? 'will overwrite' : 'will skip'})
</span>
{:else}
<span class="text-green-600">New</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if importErrors.length > 0}
<div class="bg-red-50 border border-red-200 text-red-700 rounded-md p-3 text-xs">
<p class="font-medium mb-1">Errors ({importErrors.length}):</p>
{#each importErrors as err}
<p>Line {err.line}: {err.error}</p>
{/each}
</div>
{/if}
<div class="flex gap-3">
<Button onclick={handleExecuteImport} isLoading={isImporting}>
Execute Import ({importPreview.length} rows)
</Button>
<Button variant="secondary" onclick={cancelImport}>
Cancel
</Button>
</div>
{/if}
</div>
</Card>
{:else if state === 'editing'}
{#if showAddForm}
<Card title="Add Entry" padding="md">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="add-source" class="text-sm font-medium text-gray-700">Source Term *</label>
<input
id="add-source"
bind:value={addForm.source_term}
placeholder="Original term"
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm mt-1"
/>
</div>
<div>
<label for="add-target" class="text-sm font-medium text-gray-700">Target Term *</label>
<input
id="add-target"
bind:value={addForm.target_term}
placeholder="Translated term"
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm mt-1"
/>
</div>
</div>
<div>
<label for="add-notes" class="text-sm font-medium text-gray-700">Context Notes</label>
<input
id="add-notes"
bind:value={addForm.context_notes}
placeholder="Optional"
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm mt-1"
/>
</div>
<div class="flex gap-3">
<Button onclick={handleAddEntry} isLoading={isAdding}>Add Entry</Button>
<Button variant="secondary" onclick={() => { showAddForm = false; }}>Cancel</Button>
</div>
</div>
</Card>
{/if}
{#if entries.length === 0}
<Card padding="lg">
<div class="text-center py-8">
<p class="text-gray-500 mb-4">No entries yet.</p>
<Button onclick={startAddEntry} variant="primary">Add First Entry</Button>
</div>
</Card>
{:else}
<Card padding="none">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left p-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-[35%]">Source Term</th>
<th class="text-left p-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-[35%]">Target Term</th>
<th class="text-left p-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-[20%]">Context</th>
<th class="text-right p-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-[10%]">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each entries as entry}
{#if editEntryId === entry.id}
<tr class="bg-blue-50">
<td class="p-2">
<input
bind:value={editForm.source_term}
class="flex h-9 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm"
/>
</td>
<td class="p-2">
<input
bind:value={editForm.target_term}
class="flex h-9 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm"
/>
</td>
<td class="p-2">
<input
bind:value={editForm.context_notes}
class="flex h-9 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm"
/>
</td>
<td class="p-2 text-right">
<div class="flex gap-1 justify-end">
<Button variant="primary" size="sm" onclick={handleEditEntry}>Save</Button>
<Button variant="ghost" size="sm" onclick={cancelEditEntry}>Cancel</Button>
</div>
</td>
</tr>
{:else}
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-3 font-medium text-gray-900">{entry.source_term}</td>
<td class="p-3 text-gray-700">{entry.target_term}</td>
<td class="p-3 text-xs text-gray-400">{entry.context_notes || ''}</td>
<td class="p-3 text-right">
{#if deleteEntryId === entry.id}
<div class="flex gap-1 justify-end items-center">
<span class="text-xs text-red-600">Confirm?</span>
<Button variant="danger" size="sm" onclick={() => handleDeleteEntry(entry.id)}>Delete</Button>
<Button variant="ghost" size="sm" onclick={cancelDeleteEntry}>No</Button>
</div>
{:else}
<div class="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onclick={() => startEditEntry(entry)}>Edit</Button>
<Button variant="ghost" size="sm" onclick={() => confirmDeleteEntry(entry.id)}>Delete</Button>
</div>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</Card>
{#if totalEntries > entryPageSize}
<div class="flex justify-center gap-2 pt-4">
<Button
variant="secondary"
size="sm"
disabled={entryPage <= 1}
onclick={() => { entryPage--; loadEntries(); }}
>
Previous
</Button>
<span class="text-sm text-gray-500 self-center">
Page {entryPage} of {Math.ceil(totalEntries / entryPageSize)}
</span>
<Button
variant="secondary"
size="sm"
disabled={entryPage >= Math.ceil(totalEntries / entryPageSize)}
onclick={() => { entryPage++; loadEntries(); }}
>
Next
</Button>
</div>
{/if}
{/if}
{/if}
</div>
<!-- [/DEF:DictionaryEditor:Component] -->

View File

@@ -0,0 +1,418 @@
<!-- [DEF:TranslationHistoryPage:Page] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Filterable run history page with click-to-expand detail view, metrics summary.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
*
* @UX_STATE: idle -> Initial state
* @UX_STATE: loading -> Loading runs
* @UX_STATE: empty -> No runs found
* @UX_STATE: populated -> Run list displayed
* @UX_STATE: detail_open -> Detail panel open for a run
* @UX_STATE: pruned -> Data older than 90 days; MetricSnapshot referenced
*/
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts.js';
import {
fetchAllRuns,
fetchRunDetail,
fetchAllMetrics,
fetchJobs,
downloadSkippedCsv
} from '$lib/api/translate.js';
/** @type {'idle'|'loading'|'empty'|'populated'|'detail_open'|'pruned'} */
let uxState = $state('idle');
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 || 'Failed to load runs', '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 || 'Failed to load run detail', '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-yellow-100 text-yellow-700',
RUNNING: 'bg-blue-100 text-blue-700',
COMPLETED: 'bg-green-100 text-green-700',
FAILED: 'bg-red-100 text-red-700',
CANCELLED: 'bg-gray-100 text-gray-500',
};
return map[status] || 'bg-gray-100 text-gray-700';
}
function getJobName(jobId) {
const job = jobs.find(j => j.id === jobId);
return job ? job.name : jobId?.substring(0, 8) + '...';
}
async function handleDownloadSkipped(runId) {
try {
await downloadSkippedCsv(runId);
} catch (err) {
addToast(err?.message || 'Failed to download', '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;
}
</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-gray-900">Translation History</h1>
<p class="text-sm text-gray-500 mt-1">Filterable run history with metrics aggregation</p>
</div>
<button onclick={() => { currentPage = 1; applyFilters(); }} class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700">
Refresh
</button>
</div>
<!-- Metrics Summary -->
{#if totalMetric()}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div class="bg-gray-50 rounded-lg p-3">
<p class="text-xs text-gray-500 uppercase">Total Runs</p>
<p class="text-xl font-bold text-gray-900">{totalMetric().total_runs}</p>
</div>
<div class="bg-green-50 rounded-lg p-3">
<p class="text-xs text-green-600 uppercase">Successful</p>
<p class="text-xl font-bold text-green-700">{totalMetric().successful_runs}</p>
</div>
<div class="bg-red-50 rounded-lg p-3">
<p class="text-xs text-red-600 uppercase">Failed</p>
<p class="text-xl font-bold text-red-700">{totalMetric().failed_runs}</p>
</div>
<div class="bg-blue-50 rounded-lg p-3">
<p class="text-xs text-blue-600 uppercase">Records</p>
<p class="text-xl font-bold text-blue-700">{totalMetric().total_records}</p>
</div>
</div>
{/if}
<!-- Filters -->
<div class="bg-white border border-gray-200 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-gray-300 rounded-lg text-sm">
<option value="">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-gray-300 rounded-lg text-sm">
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="RUNNING">Running</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
<option value="CANCELLED">Cancelled</option>
</select>
<select bind:value={filterTrigger} class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">All Triggers</option>
<option value="manual">Manual</option>
<option value="scheduled">Scheduled</option>
<option value="baseline_expired">Baseline Expired</option>
</select>
<input type="date" bind:value={filterDateFrom} class="px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="From" />
<button onclick={applyFilters} class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Filter
</button>
</div>
</div>
<!-- Loading state -->
{#if isLoading}
<div class="space-y-3">
{#each Array(5) as _}
<div class="h-16 bg-gray-100 rounded-lg animate-pulse" />
{/each}
</div>
<!-- Empty state -->
{:else if uxState === 'empty'}
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
<svg class="w-16 h-16 text-gray-300 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-gray-500 mb-2">No translation runs found</p>
<p class="text-xs text-gray-400">Runs appear here after executing a translation job.</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-white border border-gray-200 rounded-lg p-3 hover:shadow-sm hover:border-gray-300 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-gray-900">{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-gray-400 bg-gray-100 px-2 py-0.5 rounded">{run.trigger_type}</span>
{/if}
</div>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span>{run.created_at ? new Date(run.created_at).toLocaleString() : ''}</span>
<span>{run.total_records || 0} records</span>
<span>{run.successful_records || 0} ok</span>
<span>{run.failed_records || 0} fail</span>
{#if run.created_by}
<span>by {run.created_by}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-2 ml-2" onclick={(e) => e.stopPropagation()}>
{#if run.status === 'COMPLETED' && (run.skipped_records || 0) > 0}
<button
onclick={() => handleDownloadSkipped(run.id)}
class="text-xs text-blue-600 hover:text-blue-700"
>
Skipped CSV
</button>
{/if}
<svg class="w-4 h-4 text-gray-400" 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-gray-500">Showing {runs.length} of {total} runs</p>
<div class="flex gap-2">
<button
onclick={() => { currentPage--; loadRuns(); }}
disabled={currentPage <= 1}
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50"
>
Previous
</button>
<button
onclick={() => { currentPage++; loadRuns(); }}
disabled={currentPage * pageSize >= total}
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50"
>
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-white 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-gray-900">Run Detail</h2>
<button onclick={closeDetail} class="text-gray-400 hover:text-gray-600">
<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-gray-500 uppercase">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-gray-500 uppercase">Trigger</p>
<p class="text-sm font-medium text-gray-900">{selectedRunDetail.trigger_type || 'manual'}</p>
</div>
<div>
<p class="text-xs text-gray-500 uppercase">Started</p>
<p class="text-sm text-gray-900">{selectedRunDetail.started_at ? new Date(selectedRunDetail.started_at).toLocaleString() : '-'}</p>
</div>
<div>
<p class="text-xs text-gray-500 uppercase">Completed</p>
<p class="text-sm text-gray-900">{selectedRunDetail.completed_at ? new Date(selectedRunDetail.completed_at).toLocaleString() : '-'}</p>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-4 gap-3 mb-6">
<div class="bg-gray-50 rounded p-3 text-center">
<p class="text-lg font-bold text-gray-900">{selectedRunDetail.total_records || 0}</p>
<p class="text-xs text-gray-500">Total</p>
</div>
<div class="bg-green-50 rounded p-3 text-center">
<p class="text-lg font-bold text-green-700">{selectedRunDetail.successful_records || 0}</p>
<p class="text-xs text-green-600">Success</p>
</div>
<div class="bg-red-50 rounded p-3 text-center">
<p class="text-lg font-bold text-red-700">{selectedRunDetail.failed_records || 0}</p>
<p class="text-xs text-red-600">Failed</p>
</div>
<div class="bg-yellow-50 rounded p-3 text-center">
<p class="text-lg font-bold text-yellow-700">{selectedRunDetail.skipped_records || 0}</p>
<p class="text-xs text-yellow-600">Skipped</p>
</div>
</div>
<!-- Config Snapshot -->
{#if selectedRunDetail.config_snapshot}
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Config Snapshot</h3>
<div class="bg-gray-50 rounded-lg p-3">
<pre class="text-xs text-gray-600 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-gray-700 mb-2">Snapshots</h3>
<div class="grid grid-cols-3 gap-2 text-xs font-mono text-gray-500">
{#if selectedRunDetail.config_hash}
<div class="bg-gray-50 rounded p-2">Config: {selectedRunDetail.config_hash}</div>
{/if}
{#if selectedRunDetail.dict_snapshot_hash}
<div class="bg-gray-50 rounded p-2">Dict: {selectedRunDetail.dict_snapshot_hash}</div>
{/if}
{#if selectedRunDetail.key_hash}
<div class="bg-gray-50 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-gray-700 mb-2">Events ({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-gray-600 bg-gray-50 rounded px-2 py-1">
<span class="font-medium text-gray-800">{evt.event_type}</span>
<span class="text-gray-400">|</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-red-50 border border-red-200 rounded-lg p-3 mb-6">
<p class="text-xs font-medium text-red-700">Error</p>
<p class="text-xs text-red-600 mt-1">{selectedRunDetail.error_message}</p>
</div>
{/if}
<!-- Superset ref -->
{#if selectedRunDetail.superset_execution_id}
<div class="bg-blue-50 rounded-lg p-3 mb-6">
<p class="text-xs font-medium text-blue-700">Superset Query ID</p>
<p class="text-xs font-mono text-blue-600 mt-1">{selectedRunDetail.superset_execution_id}</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- [/DEF:TranslationHistoryPage:Page] -->