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:
@@ -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]
|
||||
|
||||
583
frontend/src/lib/api/translate.js
Normal file
583
frontend/src/lib/api/translate.js
Normal 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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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] -->
|
||||
260
frontend/src/lib/components/translate/ScheduleConfig.svelte
Normal file
260
frontend/src/lib/components/translate/ScheduleConfig.svelte
Normal 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] -->
|
||||
226
frontend/src/lib/components/translate/TermCorrectionPopup.svelte
Normal file
226
frontend/src/lib/components/translate/TermCorrectionPopup.svelte
Normal 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] -->
|
||||
@@ -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] -->
|
||||
409
frontend/src/lib/components/translate/TranslationPreview.svelte
Normal file
409
frontend/src/lib/components/translate/TranslationPreview.svelte
Normal 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} — {$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">✓</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] -->
|
||||
@@ -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">✓</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">⚠</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">✗</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">⚠</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}) — {$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">■</span>
|
||||
<span class="text-sm font-semibold text-gray-700">{$t.translate?.run?.cancelled}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/DEF:TranslationRunProgress:Component] -->
|
||||
@@ -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] -->
|
||||
@@ -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]
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
286
frontend/src/routes/translate/+page.svelte
Normal file
286
frontend/src/routes/translate/+page.svelte
Normal 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} → {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>
|
||||
1042
frontend/src/routes/translate/[id]/+page.svelte
Normal file
1042
frontend/src/routes/translate/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
295
frontend/src/routes/translate/dictionaries/+page.svelte
Normal file
295
frontend/src/routes/translate/dictionaries/+page.svelte
Normal 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} → {dict.target_dialect}
|
||||
{#if dict.description}
|
||||
— {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] -->
|
||||
568
frontend/src/routes/translate/dictionaries/[id]/+page.svelte
Normal file
568
frontend/src/routes/translate/dictionaries/[id]/+page.svelte
Normal 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} → {dictionary.target_dialect}
|
||||
{#if dictionary.description}
|
||||
— {dictionary.description}
|
||||
{/if}
|
||||
· {$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 hello,hola, 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] -->
|
||||
418
frontend/src/routes/translate/history/+page.svelte
Normal file
418
frontend/src/routes/translate/history/+page.svelte
Normal 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] -->
|
||||
Reference in New Issue
Block a user