From 8e2f393267d6d22c756b96e2851c436c22911dea Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 16 Jul 2026 07:40:54 +0300 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20remove=20addToast=20bridg?= =?UTF-8?q?e=20=E2=80=94=20migrate=20all=20call=20sites=20to=20notificatio?= =?UTF-8?q?ns=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: addToast() removed. Use notify() or notifications facade instead. - Replace addToast(msg, type, duration?) → notify({ message, type, duration? }) - Introduce notifications.success/info/warning/error/show() semantic facade - Add dismissAllToasts(), timer management (clearTimeout on remove) - Unify Toast component: single viewport, a11y (role/aria-live by type) - Migrate 16 models, 2 stores, 38 components, 23 pages (76 files total) - 147 test files / 3152 tests pass with updated mocks and assertions --- frontend/src/lib/__tests__/toasts.test.ts | 252 ++++++++++++++---- frontend/src/lib/api.ts | 14 +- frontend/src/lib/api/__tests__/api.test.ts | 80 +++--- .../src/lib/api/__tests__/reports_api.test.ts | 2 +- frontend/src/lib/api/translate.ts | 2 +- .../lib/api/translate/__tests__/runs.test.ts | 29 ++ frontend/src/lib/api/translate/runs.ts | 43 ++- .../src/lib/api/translate/target-schema.ts | 2 +- .../components/StartMaintenanceForm.svelte | 15 +- .../src/lib/components/agent/AgentChat.svelte | 14 +- .../assistant/AssistantChatPanel.svelte | 10 +- .../__tests__/ConfirmationCard.ux.test.ts | 2 +- ...assistant_confirmation.integration.test.ts | 2 +- ...ssistant_first_message.integration.test.ts | 2 +- .../components/backups/BackupManager.svelte | 26 +- .../components/dashboard/DashboardGrid.svelte | 6 +- .../dashboard/RepositoryDashboardGrid.svelte | 16 +- .../lib/components/git/CommitHistory.svelte | 8 +- .../components/git/ConflictResolver.svelte | 5 +- .../components/git/CreateBranchDialog.svelte | 6 +- .../components/git/GitFeatureWorkflow.svelte | 4 +- .../src/lib/components/git/GitManager.svelte | 10 +- .../src/lib/components/git/MergeDialog.svelte | 9 +- .../git/__tests__/GitFeatureWorkflow.test.ts | 2 +- .../lib/components/layout/TaskDrawer.svelte | 14 +- .../lib/components/llm/ProviderConfig.svelte | 30 +-- .../src/lib/components/llm/UrlParser.svelte | 6 +- .../components/llm/ValidationTaskForm.svelte | 16 +- .../lib/components/settings/ApiKeysTab.svelte | 18 +- .../lib/components/storage/FileList.svelte | 6 +- .../lib/components/storage/FileUpload.svelte | 6 +- .../lib/components/tasks/TaskRunner.svelte | 12 +- .../src/lib/components/tools/DebugTool.svelte | 14 +- .../lib/components/tools/MapperTool.svelte | 40 +-- .../translate/BulkCorrectionSidebar.svelte | 15 +- .../translate/BulkReplaceModal.svelte | 2 +- .../translate/CorrectionCell.svelte | 14 +- .../translate/InsertMethodSelector.svelte | 8 +- .../components/translate/RunTabContent.svelte | 125 +++++---- .../translate/ScheduleConfig.svelte | 16 +- .../translate/TargetSchemaHint.svelte | 6 +- .../translate/TermCorrectionPopup.svelte | 14 +- .../translate/TranslationPreview.svelte | 6 +- .../TranslationRunGlobalIndicator.svelte | 6 +- .../translate/TranslationRunProgress.svelte | 8 +- .../translate/TranslationRunResult.svelte | 8 +- .../__tests__/TargetSchemaHint.test.ts | 7 +- .../ui/DatabaseSearchCombobox.svelte | 4 +- .../ui/DatasetSearchCombobox.svelte | 4 +- .../ui/StartupEnvironmentWizard.svelte | 4 +- frontend/src/lib/components/ui/Toast.svelte | 106 +++----- .../src/lib/i18n/locales/en/translate.json | 12 + .../src/lib/i18n/locales/ru/translate.json | 12 + .../src/lib/models/AgentChatModel.svelte.ts | 6 +- frontend/src/lib/models/BranchModel.svelte.ts | 26 +- .../models/BulkReplaceModalModel.svelte.ts | 16 +- frontend/src/lib/models/CommitModel.svelte.ts | 14 +- .../lib/models/DashboardDetailModel.svelte.ts | 6 +- .../lib/models/DashboardHubModel.svelte.ts | 2 +- .../Dashboards.GitActionsModel.svelte.ts | 28 +- .../src/lib/models/DeploymentModel.svelte.ts | 10 +- .../models/DictionaryDetailModel.svelte.ts | 24 +- .../src/lib/models/GitConfigModel.svelte.ts | 33 ++- .../src/lib/models/GitManagerModel.svelte.ts | 66 ++--- .../src/lib/models/GitStatusModel.svelte.ts | 22 +- .../lib/models/HealthCenterModel.svelte.ts | 4 +- .../src/lib/models/TaskCenterModel.svelte.ts | 6 +- .../models/TranslateHistoryModel.svelte.ts | 18 +- .../lib/models/TranslationJobModel.svelte.ts | 111 ++++++-- .../TranslationRunResultModel.svelte.ts | 16 +- .../models/ValidationTasksListModel.svelte.ts | 12 +- .../models/__tests__/AgentChatModel.2.test.ts | 2 +- .../__tests__/AgentChatModel.context.test.ts | 2 +- .../models/__tests__/AgentChatModel.test.ts | 2 +- .../lib/models/__tests__/BranchModel.test.ts | 2 +- .../__tests__/BulkReplaceModalModel.test.ts | 23 +- .../lib/models/__tests__/CommitModel.test.ts | 2 +- .../__tests__/DashboardDetailModel.test.ts | 8 +- .../__tests__/DashboardHubModel.test.ts | 20 +- .../models/__tests__/DeploymentModel.test.ts | 2 +- .../__tests__/DictionaryDetailModel.test.ts | 38 +-- .../models/__tests__/GitConfigModel.test.ts | 40 +-- .../models/__tests__/GitManagerModel.test.ts | 20 +- .../models/__tests__/GitStatusModel.test.ts | 8 +- .../__tests__/HealthCenterModel.test.ts | 6 +- .../models/__tests__/TaskCenterModel.test.ts | 2 +- .../__tests__/TranslateHistoryModel.test.ts | 26 +- .../__tests__/TranslationJobModel.test.ts | 40 ++- .../TranslationRunResultModel.test.ts | 26 +- .../ValidationTasksListModel.test.ts | 30 ++- .../lib/stores/__tests__/test_maintenance.ts | 20 +- frontend/src/lib/stores/maintenance.svelte.ts | 14 +- frontend/src/lib/toasts.svelte.ts | 62 ++++- frontend/src/lib/ui/ConfirmDialog.svelte | 32 ++- .../routes/admin/settings/llm/+page.svelte | 6 +- ...board-profile-override.integration.test.ts | 2 +- frontend/src/routes/git/+page.svelte | 8 +- frontend/src/routes/maintenance/+page.svelte | 2 +- frontend/src/routes/profile/+page.svelte | 8 +- .../profile-preferences.integration.test.ts | 7 +- ...profile-settings-state.integration.test.ts | 7 +- frontend/src/routes/settings/+page.svelte | 6 +- .../src/routes/settings/ConnectionsTab.svelte | 28 +- .../routes/settings/EnvironmentsTab.svelte | 23 +- .../routes/settings/MigrationSettings.svelte | 10 +- .../routes/settings/ReportsSettings.svelte | 6 +- .../settings_page.integration.test.ts | 6 +- .../__tests__/settings_page.ux.test.ts | 8 +- .../routes/settings/automation/+page.svelte | 14 +- frontend/src/routes/settings/git/+page.svelte | 6 +- .../__tests__/git_settings_page.ux.test.ts | 10 +- .../settings/notifications/+page.svelte | 6 +- .../src/routes/tools/storage/+page.svelte | 10 +- frontend/src/routes/translate/+page.svelte | 10 +- .../src/routes/translate/[id]/+page.svelte | 11 +- .../translate/dictionaries/+page.svelte | 16 +- .../validation-tasks/[policyId]/+page.svelte | 10 +- .../[policyId]/edit/+page.svelte | 6 +- .../routes/validation-tasks/new/+page.svelte | 6 +- frontend/tests/maintenance-form.test.ts | 9 +- frontend/tests/maintenance-store.test.ts | 26 +- frontend/tests/maintenance.test.ts | 2 +- 122 files changed, 1293 insertions(+), 923 deletions(-) diff --git a/frontend/src/lib/__tests__/toasts.test.ts b/frontend/src/lib/__tests__/toasts.test.ts index 772eb9f2d..d97cd3739 100644 --- a/frontend/src/lib/__tests__/toasts.test.ts +++ b/frontend/src/lib/__tests__/toasts.test.ts @@ -1,13 +1,17 @@ // #region ToastsModuleTest [C:3] [TYPE Module] [SEMANTICS test, toast, notification, store, dedup] -// @BRIEF Unit tests for the toast notification system — addToast, removeToast, subscribe, -// deduplication window, and auto-removal via setTimeout. +// @BRIEF Unit tests for the toast notification system — notify, notifications facade, +// removeToast, dismissAllToasts, subscribe, deduplication window, timer management. // @LAYER Tests // @RELATION BINDS_TO -> [ToastsModule] -// @TEST_CONTRACT: addToast -> Adds toast with auto-generated ID and correct type -// @TEST_CONTRACT: addToast -> Persistent toasts (duration=0) not auto-removed -// @TEST_CONTRACT: addToast -> Deduplication skips identical type+message within 1200ms +// @TEST_CONTRACT: notify -> Adds toast with auto-generated ID and correct type +// @TEST_CONTRACT: notify -> Persistent toasts (duration=0) not auto-removed +// @TEST_CONTRACT: notify -> Deduplication skips identical type+message within 1200ms // @TEST_CONTRACT: removeToast -> Removes toast by ID // @TEST_CONTRACT: toasts.subscribe -> Receives snapshot on add/remove +// @TEST_CONTRACT: notifications.success/info/warning/error -> Facade methods delegate to notify +// @TEST_CONTRACT: dismissAllToasts -> Removes all toasts from the store +// @TEST_CONTRACT: removeToast -> Clears auto-dismiss timer +// @TEST_CONTRACT: notify -> Returns id for successful inserts, undefined for dedupes import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -23,12 +27,12 @@ describe('Toasts Module', () => { vi.restoreAllMocks(); }); - it('addToast adds a toast to the store and notifies subscribers', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + it('notify adds a toast to the store and notifies subscribers', async () => { + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); const unsub = toasts.subscribe(subFn); - addToast('Hello world', 'info', 3000); + notify({ message: 'Hello world', type: 'info', duration: 3000 }); // Subscribe receives the snapshot expect(subFn).toHaveBeenCalledWith( @@ -50,13 +54,13 @@ describe('Toasts Module', () => { unsub(); }); - it('addToast with duration=0 creates persistent toast (no auto-removal)', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + it('notify with duration=0 creates persistent toast (no auto-removal)', async () => { + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - // subFn is called once during subscribe (initial snapshot), then again on addToast - addToast('Persistent error', 'error', 0); + // subFn is called once during subscribe (initial snapshot), then again on notify + notify({ message: 'Persistent error', type: 'error', duration: 0 }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; const persistent = lastCall.find((t: any) => t.message === 'Persistent error'); @@ -72,8 +76,8 @@ describe('Toasts Module', () => { expect(stillPresent).toBeDefined(); }); - it('addToast auto-removes non-persistent toasts after duration', async () => { - const { addToast, removeToast } = await import('$lib/toasts.svelte.js'); + it('notify auto-removes non-persistent toasts after duration', async () => { + const { notify, removeToast } = await import('$lib/toasts.svelte.js'); const removeSpy = vi.spyOn({ removeToast }, 'removeToast'); // We need to watch if setTimeout causes removeToast to be called @@ -82,18 +86,18 @@ describe('Toasts Module', () => { // But removeToast is a function in the module scope, not easy to spy on directly // Instead, let's verify setTimeout was scheduled - addToast('Auto remove', 'success', 3000); + notify({ message: 'Auto remove', type: 'success', duration: 3000 }); // Verify the scheduled timer exists expect(vi.getTimerCount()).toBeGreaterThan(0); }); it('removeToast removes toast by ID and notifies', async () => { - const { addToast, removeToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, removeToast, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Remove me', 'info'); + notify({ message: 'Remove me', type: 'info' }); const addCalls = subFn.mock.calls.length; const latest = subFn.mock.calls[addCalls - 1][0]; const target = latest.find((t: any) => t.message === 'Remove me'); @@ -104,12 +108,12 @@ describe('Toasts Module', () => { }); it('deduplication skips identical message+type within 1200ms window', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Duplicate', 'warning'); - addToast('Duplicate', 'warning'); + notify({ message: 'Duplicate', type: 'warning' }); + notify({ message: 'Duplicate', type: 'warning' }); // Only one toast should have been added const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; @@ -118,12 +122,12 @@ describe('Toasts Module', () => { }); it('deduplication allows same message with different type', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Same message', 'info'); - addToast('Same message', 'error'); + notify({ message: 'Same message', type: 'info' }); + notify({ message: 'Same message', type: 'error' }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; const infoToasts = lastCall.filter( @@ -137,16 +141,16 @@ describe('Toasts Module', () => { }); it('deduplication window expires after 1200ms allowing duplicate', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Old message', 'info'); + notify({ message: 'Old message', type: 'info' }); // Advance past the dedup window vi.advanceTimersByTime(1500); - addToast('Old message', 'info'); + notify({ message: 'Old message', type: 'info' }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; const matches = lastCall.filter( @@ -170,11 +174,11 @@ describe('Toasts Module', () => { }); it('auto-removal actually removes the toast after duration expires', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Auto-remove test', 'info', 3000); + notify({ message: 'Auto-remove test', type: 'info', duration: 3000 }); // Toast should be present let lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; @@ -195,12 +199,12 @@ describe('Toasts Module', () => { }); it('buildToastKey handles empty message and type defaults', async () => { - // Access the internal function indirectly by calling addToast - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + // Access the internal function indirectly by calling notify + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('', 'info'); + notify({ message: '', type: 'info' }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; expect(lastCall.length).toBe(1); expect(lastCall[0].message).toBe(''); @@ -208,52 +212,65 @@ describe('Toasts Module', () => { }); it('should skip duplicate when called rapidly with same type and message', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); // First call — should add - addToast('Rapid duplicate', 'warning', 5000); + notify({ message: 'Rapid duplicate', type: 'warning', duration: 5000 }); const afterFirst = subFn.mock.calls.length; // Immediate second call — should be skipped (within 1200ms window) - addToast('Rapid duplicate', 'warning', 5000); + notify({ message: 'Rapid duplicate', type: 'warning', duration: 5000 }); expect(subFn.mock.calls.length).toBe(afterFirst); }); it('should allow duplicate after dedup window expires', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Expired dedup', 'info', 5000); + notify({ message: 'Expired dedup', type: 'info', duration: 5000 }); const afterFirst = subFn.mock.calls.length; // Advance past dedup window vi.advanceTimersByTime(1500); - addToast('Expired dedup', 'info', 5000); + notify({ message: 'Expired dedup', type: 'info', duration: 5000 }); expect(subFn.mock.calls.length).toBeGreaterThan(afterFirst); }); - it('addToast with success type works correctly', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + it('notify with success type works correctly', async () => { + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Success toast', 'success', 2000); + notify({ message: 'Success toast', type: 'success', duration: 2000 }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; const toast = lastCall.find((t: any) => t.message === 'Success toast'); expect(toast.type).toBe('success'); expect(toast.persistent).toBe(false); }); + it('notify supports typed metadata and dedupe keys', async () => { + const { notify, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + const action = vi.fn(); + const id = notify({ title: 'Retry', message: 'Request failed', type: 'error', dedupeKey: 'request:1', action: { label: 'Retry', onClick: action } }); + notify({ title: 'Retry', message: 'Different text', type: 'error', dedupeKey: 'request:1' }); + const toast = subFn.mock.calls.at(-1)?.[0].find((item: any) => item.id === id); + expect(toast).toMatchObject({ title: 'Retry', source: undefined, persistent: false }); + expect(toast.action.label).toBe('Retry'); + expect(subFn.mock.calls.at(-1)?.[0]).toHaveLength(1); + }); + it('sanitizeMessage masks git_repo paths in error messages', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('Error at /app/backend/git_repos/remote/repo.git', 'error'); + notify({ message: 'Error at /app/backend/git_repos/remote/repo.git', type: 'error' }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; const toast = lastCall.find((t: any) => t.message.includes(''), @@ -262,11 +279,11 @@ describe('Toasts Module', () => { }); it('sanitizeMessage converts fatal: prefix to friendly wrapper', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); - addToast('fatal: repository not found', 'error'); + notify({ message: 'fatal: repository not found', type: 'error' }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; const toast = lastCall.find((t: any) => t.message.startsWith('Git operation failed.'), @@ -276,15 +293,15 @@ describe('Toasts Module', () => { }); it('enforces MAX_TOASTS by dropping oldest non-persistent toast', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); subFn.mockClear(); - addToast('Toast 1', 'info'); - addToast('Toast 2', 'info'); - addToast('Toast 3', 'info'); - addToast('Toast 4', 'info'); // Should drop Toast 1 + notify({ message: 'Toast 1', type: 'info' }); + notify({ message: 'Toast 2', type: 'info' }); + notify({ message: 'Toast 3', type: 'info' }); + notify({ message: 'Toast 4', type: 'info' }); // Should drop Toast 1 const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; expect(lastCall.length).toBeLessThanOrEqual(3); @@ -293,17 +310,17 @@ describe('Toasts Module', () => { }); it('enforces MAX_TOASTS by dropping oldest non-persistent over persistent', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); subFn.mockClear(); // Add 3 persistent toasts - addToast('Persistent 1', 'warning', 0); - addToast('Persistent 2', 'warning', 0); - addToast('Persistent 3', 'warning', 0); + notify({ message: 'Persistent 1', type: 'warning', duration: 0 }); + notify({ message: 'Persistent 2', type: 'warning', duration: 0 }); + notify({ message: 'Persistent 3', type: 'warning', duration: 0 }); // Add 1 non-persistent — the non-persistent should be dropped - addToast('Non-persistent', 'info'); + notify({ message: 'Non-persistent', type: 'info' }); const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; expect(lastCall.length).toBeLessThanOrEqual(3); @@ -314,20 +331,137 @@ describe('Toasts Module', () => { }); it('enforces MAX_TOASTS by slicing when all are persistent', async () => { - const { addToast, toasts } = await import('$lib/toasts.svelte.js'); + const { notify, toasts } = await import('$lib/toasts.svelte.js'); const subFn = vi.fn(); toasts.subscribe(subFn); subFn.mockClear(); - addToast('Persistent 1', 'warning', 0); - addToast('Persistent 2', 'warning', 0); - addToast('Persistent 3', 'warning', 0); - addToast('Persistent 4', 'warning', 0); // All persistent — slice keeps last 3 + notify({ message: 'Persistent 1', type: 'warning', duration: 0 }); + notify({ message: 'Persistent 2', type: 'warning', duration: 0 }); + notify({ message: 'Persistent 3', type: 'warning', duration: 0 }); + notify({ message: 'Persistent 4', type: 'warning', duration: 0 }); // All persistent — slice keeps last 3 const lastCall = subFn.mock.calls[subFn.mock.calls.length - 1][0]; expect(lastCall.length).toBe(3); // First persistent should be dropped (slice -3 keeps last 3) expect(lastCall.find((t: any) => t.message === 'Persistent 1')).toBeUndefined(); }); + + // ── New API: notifications facade ───────────────────────────── + + it('notifications.success creates a success toast via notify', async () => { + const { notifications, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + notifications.success('Saved'); + const toast = subFn.mock.calls.at(-1)?.[0].find((t: any) => t.type === 'success'); + expect(toast).toMatchObject({ message: 'Saved', type: 'success', persistent: false }); + }); + + it('notifications.info creates an info toast', async () => { + const { notifications, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + notifications.info('Loading...'); + const toast = subFn.mock.calls.at(-1)?.[0].find((t: any) => t.type === 'info'); + expect(toast).toMatchObject({ message: 'Loading...', type: 'info' }); + }); + + it('notifications.warning creates a warning toast', async () => { + const { notifications, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + notifications.warning('Low disk space'); + const toast = subFn.mock.calls.at(-1)?.[0].find((t: any) => t.type === 'warning'); + expect(toast).toMatchObject({ message: 'Low disk space', type: 'warning' }); + }); + + it('notifications.error creates an error toast', async () => { + const { notifications, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + notifications.error('Request failed'); + const toast = subFn.mock.calls.at(-1)?.[0].find((t: any) => t.type === 'error'); + expect(toast).toMatchObject({ message: 'Request failed', type: 'error' }); + }); + + it('notifications.error accepts options (duration, source, action)', async () => { + const { notifications, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + const action = vi.fn(); + + notifications.error('Timeout', { duration: 0, source: 'api', action: { label: 'Retry', onClick: action } }); + const toast = subFn.mock.calls.at(-1)?.[0].find((t: any) => t.type === 'error'); + expect(toast).toMatchObject({ message: 'Timeout', persistent: true, source: 'api' }); + expect(toast.action.label).toBe('Retry'); + }); + + // ── dismissAllToasts ────────────────────────────────────────── + + it('dismissAllToasts removes all toasts', async () => { + const { notify, dismissAllToasts, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + notify({ message: 'Msg 1', type: 'info' }); + notify({ message: 'Msg 2', type: 'success' }); + expect(subFn.mock.calls.at(-1)?.[0].length).toBe(2); + + dismissAllToasts(); + expect(subFn.mock.calls.at(-1)?.[0].length).toBe(0); + }); + + // ── Timer management ────────────────────────────────────────── + + it('removeToast clears the auto-dismiss timer', async () => { + const { notify, removeToast, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + notify({ message: 'Ephemeral', type: 'info', duration: 3000 }); + let state = subFn.mock.calls.at(-1)?.[0]; + const toastId = state.find((t: any) => t.message === 'Ephemeral').id; + + // Remove before timer fires — toast should be gone immediately + removeToast(toastId); + state = subFn.mock.calls.at(-1)?.[0]; + expect(state.find((t: any) => t.id === toastId)).toBeUndefined(); + + // Advance timers — should not re-remove (timer was cleared) + vi.advanceTimersByTime(5000); + state = subFn.mock.calls.at(-1)?.[0]; + expect(state.length).toBe(0); + }); + + it('notify returns id for successful inserts, undefined for dedupe', async () => { + const { notify, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + const id = notify({ message: 'Unique', type: 'info' }); + expect(id).toBeDefined(); + expect(typeof id).toBe('string'); + + const id2 = notify({ message: 'Unique', type: 'info' }); // duplicate — deduped + expect(id2).toBeUndefined(); + }); + + it('notifications.show works as direct notify alias', async () => { + const { notifications, toasts } = await import('$lib/toasts.svelte.js'); + const subFn = vi.fn(); + toasts.subscribe(subFn); + + const id = notifications.show({ message: 'Custom', type: 'warning', title: 'Heads up' }); + expect(typeof id).toBe('string'); + const toast = subFn.mock.calls.at(-1)?.[0].find((t: any) => t.id === id); + expect(toast).toMatchObject({ message: 'Custom', type: 'warning', title: 'Heads up' }); + }); + + }); // #endregion ToastsModuleTest diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dd15cf9bd..dd20b6930 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -9,7 +9,7 @@ // @RELATION CALLED_BY -> [ValidationRunDetailPageLoad] // @PRE Auth token is available in localStorage under 'auth_token' after login. // @POST Every API call returns typed JSON response or throws typed ApiError with status/detail/error_code. -// @SIDE_EFFECT Reads localStorage for auth token on every request. Dispatches error toasts on non-suppressed failures. +// @SIDE_EFFECT Reads localStorage for auth token on every request. Dispatches error notifications on non-suppressed failures. // @INVARIANT Every fetch MUST go through fetchApi/requestApi/postApi/deleteApi — never native fetch(). // @INVARIANT Every response.json() is wrapped in try/catch that converts HTTP errors to ApiError. // @INVARIANT FetchOptions.signal is passed to native fetch() for cancellation/timeout support. @@ -25,7 +25,7 @@ // is simpler, tree-shakeable, and has zero bundle cost. import { log, setTraceId, getTraceId } from '$lib/cot-logger'; -import { addToast } from './toasts.svelte.js'; +import { notifications } from './toasts.svelte.js'; import type { FetchOptions, DashboardListParams } from '../types/api'; const API_BASE_URL = '/api'; @@ -81,17 +81,17 @@ async function buildApiError(response: Response): Promise { // @BRIEF Dispatch an error toast with severity-based messaging. // @PRE error is a structured ApiError (may have status). // @POST Toast is dispatched with appropriate message and error severity. -// @SIDE_EFFECT Calls addToast() which mutates the global toast store. -// @RELATION DEPENDS_ON -> [addToast:Function] +// @SIDE_EFFECT Calls notifications.error() which mutates the global toast store. +// @RELATION DEPENDS_ON -> [notifications:Function] // @RELATION CALLED_BY -> [fetchApi] // @RELATION CALLED_BY -> [postApi] // @RELATION CALLED_BY -> [deleteApi] // @RELATION CALLED_BY -> [requestApi] // @UX_FEEDBACK 401 → "401 Unauthorized" toast. 500+ → "Server error (N)" toast. Others → error.message toast. function notifyApiError(error: ApiError): void { - if (error?.status === 401) { addToast(`401 Unauthorized: ${error.message}`, 'error'); return; } - if (error?.status >= 500) { addToast(`Server error (${error.status}): ${error.message}`, 'error'); return; } - addToast(error.message, 'error'); + if (error?.status === 401) { notifications.error(`401 Unauthorized: ${error.message}`); return; } + if (error?.status >= 500) { notifications.error(`Server error (${error.status}): ${error.message}`); return; } + notifications.error(error.message); } // #endregion notifyApiError diff --git a/frontend/src/lib/api/__tests__/api.test.ts b/frontend/src/lib/api/__tests__/api.test.ts index aee2494c8..e5881d56b 100644 --- a/frontend/src/lib/api/__tests__/api.test.ts +++ b/frontend/src/lib/api/__tests__/api.test.ts @@ -22,9 +22,9 @@ vi.mock('$lib/cot-logger.js', () => ({ getTraceId: vi.fn(() => 'test-trace-id'), })); -// Mock toasts +// Mock toasts — uses notifications facade only vi.mock('$lib/toasts.svelte.js', () => ({ - addToast: vi.fn(), + notifications: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), show: vi.fn() }, })); describe('ApiModule — module-level exports', () => { @@ -214,11 +214,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Bad token' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.fetchApi('/secure')).rejects.toMatchObject({ status: 401 }); - expect(addToast).toHaveBeenCalledWith('401 Unauthorized: Bad token', 'error'); + expect(notifications.error).toHaveBeenCalledWith('401 Unauthorized: Bad token'); }); it('notifyApiError dispatches Server error toast for 500+', async () => { @@ -228,11 +228,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Service unavailable' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.fetchApi('/unavailable')).rejects.toMatchObject({ status: 503 }); - expect(addToast).toHaveBeenCalledWith('Server error (503): Service unavailable', 'error'); + expect(notifications.error).toHaveBeenCalledWith('Server error (503): Service unavailable'); }); it('fetchApi passes signal to native fetch', async () => { @@ -323,11 +323,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Server boom' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.fetchApiBlob('/fail')).rejects.toMatchObject({ status: 500 }); - expect(addToast).toHaveBeenCalled(); + expect(notifications.error).toHaveBeenCalled(); }); it('fetchApiBlob handles 202 with fallback message when response has no JSON', async () => { @@ -350,11 +350,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: { error_code: 'GIT_UNFINISHED_MERGE', message: 'Merge conflict' } }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.requestApi('/git/repositories/dash-1/pull', 'GET')).rejects.toMatchObject({ status: 409 }); - expect(addToast).not.toHaveBeenCalled(); + expect(notifications.error).not.toHaveBeenCalled(); }); it('requestApi suppression: git repo 400 env_id is required suppresses toast', async () => { @@ -363,11 +363,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'env_id is required' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.requestApi('/git/repositories/dash-1/status', 'GET')).rejects.toMatchObject({ status: 400 }); - expect(addToast).not.toHaveBeenCalled(); + expect(notifications.error).not.toHaveBeenCalled(); }); it('requestApi suppression: git config repos 409 already exists suppresses toast', async () => { @@ -376,11 +376,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Repository already exists' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.requestApi('/git/config/env-1/repositories', 'GET')).rejects.toMatchObject({ status: 409 }); - expect(addToast).not.toHaveBeenCalled(); + expect(notifications.error).not.toHaveBeenCalled(); }); it('requestApi suppression: non-matching endpoint still dispatches toast', async () => { @@ -389,11 +389,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Bad request' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.requestApi('/some-other-endpoint', 'GET')).rejects.toMatchObject({ status: 400 }); - expect(addToast).toHaveBeenCalled(); + expect(notifications.error).toHaveBeenCalled(); }); it('getAuthHeaders merges extra headers', async () => { @@ -490,11 +490,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Delete failed' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.deleteApi('/test/1')).rejects.toMatchObject({ status: 500 }); - expect(addToast).toHaveBeenCalled(); + expect(notifications.error).toHaveBeenCalled(); }); it('postApi dispatches error toast on failure without suppressToast', async () => { @@ -504,11 +504,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Bad request' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.postApi('/test', {})).rejects.toMatchObject({ status: 400 }); - expect(addToast).toHaveBeenCalled(); + expect(notifications.error).toHaveBeenCalled(); }); it('postApi suppresses toast when options.suppressToast is true', async () => { @@ -518,11 +518,11 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => { json: () => Promise.resolve({ detail: 'Hidden' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.postApi('/test', {}, { suppressToast: true })).rejects.toMatchObject({ status: 400 }); - expect(addToast).not.toHaveBeenCalled(); + expect(notifications.error).not.toHaveBeenCalled(); }); it('requestApi with PATCH sends correct method and body', async () => { @@ -1108,18 +1108,18 @@ describe('ApiModule — registry methods', () => { }); it('requestApi suppression: git 404 suppresses toast', async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404, json: () => Promise.resolve({ detail: 'Repository for dashboard x not found' }) } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { api } = await import('$lib/api.js'); await expect(api.requestApi('/git/repositories/dash-1/status', 'GET')).rejects.toMatchObject({ status: 404 }); - expect(addToast).not.toHaveBeenCalled(); + expect(notifications.error).not.toHaveBeenCalled(); }); it('requestApi suppression: non-git 404 dispatches toast', async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404, json: () => Promise.resolve({ detail: 'Not found' }) } as Response); const { api } = await import('$lib/api.js'); await expect(api.requestApi('/unknown', 'GET')).rejects.toMatchObject({ status: 404 }); - const { addToast } = await import('$lib/toasts.svelte.js'); - expect(addToast).toHaveBeenCalled(); + const { notifications } = await import('$lib/toasts.svelte.js'); + expect(notifications.error).toHaveBeenCalled(); }); it('getStorageFileBlob returns blob with encoded path', async () => { const blob = new Blob(['data']); @@ -1196,8 +1196,8 @@ describe('ApiModule — registry methods', () => { json: () => Promise.resolve({ detail: 'Only .xlsx files are accepted' }), } as Response); - const { addToast } = await import('$lib/toasts.svelte.js'); - addToast.mockClear(); + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); const { uploadFile } = await import('$lib/api.js'); const file = new File(['data'], 'bad.csv'); @@ -1205,7 +1205,7 @@ describe('ApiModule — registry methods', () => { status: 400, message: 'Only .xlsx files are accepted', }); - expect(addToast).toHaveBeenCalled(); + expect(notifications.error).toHaveBeenCalled(); }); it('includes X-Trace-ID header when trace ID is set', async () => { diff --git a/frontend/src/lib/api/__tests__/reports_api.test.ts b/frontend/src/lib/api/__tests__/reports_api.test.ts index 9d187ac53..ab97748c0 100644 --- a/frontend/src/lib/api/__tests__/reports_api.test.ts +++ b/frontend/src/lib/api/__tests__/reports_api.test.ts @@ -18,7 +18,7 @@ vi.mock('$env/static/public', () => ({ // Mock toasts to prevent import side-effects vi.mock('$lib/toasts.svelte.js', () => ({ - addToast: vi.fn() + notifications: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), show: vi.fn() }, })); // Mock the api module diff --git a/frontend/src/lib/api/translate.ts b/frontend/src/lib/api/translate.ts index 930f499f2..f4a445911 100644 --- a/frontend/src/lib/api/translate.ts +++ b/frontend/src/lib/api/translate.ts @@ -18,7 +18,7 @@ export { fetchJobs, createJob, updateJob, deleteJob, duplicateJob } from './tran export { fetchDatasourceColumns, fetchDatasources, fetchPreview, fetchPreviewRecords, approveRow, editRow, rejectRow, acceptPreview } from './translate/datasources'; // Runs -export { triggerRun, fetchRunStatus, fetchRunHistory, fetchRunRecords, retryFailedBatches, retryInsert, cancelRun, fetchRunBatches, fetchAllRuns, fetchRunDetail, fetchJobMetrics, fetchAllMetrics } from './translate/runs'; +export { triggerRun, calculateRunPreflight, fetchRunStatus, fetchRunHistory, fetchRunRecords, retryFailedBatches, retryInsert, cancelRun, fetchRunBatches, fetchAllRuns, fetchRunDetail, fetchJobMetrics, fetchAllMetrics } from './translate/runs'; // Dictionaries export { dictionaryApi } from './translate/dictionaries'; diff --git a/frontend/src/lib/api/translate/__tests__/runs.test.ts b/frontend/src/lib/api/translate/__tests__/runs.test.ts index 45f6f8a2e..a446eb1e1 100644 --- a/frontend/src/lib/api/translate/__tests__/runs.test.ts +++ b/frontend/src/lib/api/translate/__tests__/runs.test.ts @@ -52,6 +52,16 @@ describe('triggerRun', () => { ); }); + it('passes the Lingua fallback mode when requested', async () => { + mockApi.postApi.mockResolvedValue({ run_id: 'run-3' }); + const { triggerRun } = await import('$lib/api/translate/runs.js'); + await triggerRun('job-1', true, 'skip'); + expect(mockApi.postApi).toHaveBeenCalledWith( + '/translate/jobs/job-1/run?full_translation=true&language_detection=skip', + {}, + ); + }); + it('normalizes error on trigger failure', async () => { mockApi.postApi.mockRejectedValue(new Error('Job locked')); const { triggerRun } = await import('$lib/api/translate/runs.js'); @@ -62,6 +72,25 @@ describe('triggerRun', () => { }); }); +describe('calculateRunPreflight', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calculates incremental execution evidence', async () => { + const expected = { eligible_rows: 12, lingua_accepted: true }; + mockApi.postApi.mockResolvedValue(expected); + const { calculateRunPreflight } = await import('$lib/api/translate/runs.js'); + await expect(calculateRunPreflight('job-1')).resolves.toEqual(expected); + expect(mockApi.postApi).toHaveBeenCalledWith('/translate/jobs/job-1/run-preflight', {}); + }); + + it('calculates full execution evidence', async () => { + mockApi.postApi.mockResolvedValue({ eligible_rows: 100 }); + const { calculateRunPreflight } = await import('$lib/api/translate/runs.js'); + await calculateRunPreflight('job-1', true); + expect(mockApi.postApi).toHaveBeenCalledWith('/translate/jobs/job-1/run-preflight?full_translation=true', {}); + }); +}); + describe('fetchRunStatus', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/frontend/src/lib/api/translate/runs.ts b/frontend/src/lib/api/translate/runs.ts index 0f1571caa..a5a2433f4 100644 --- a/frontend/src/lib/api/translate/runs.ts +++ b/frontend/src/lib/api/translate/runs.ts @@ -39,9 +39,16 @@ export interface AllRunsQueryOptions extends RunQueryOptions { // @POST Returns created run response with status. // @SIDE_EFFECT Starts async translation processing on the backend. // @RELATION DEPENDS_ON -> [postApi] -export async function triggerRun(jobId: string, full: boolean = false): Promise { +export async function triggerRun( + jobId: string, + full: boolean = false, + languageDetection: "auto" | "skip" = "auto", +): Promise { try { - const query = full ? '?full_translation=true' : ''; + const params = new URLSearchParams(); + if (full) params.set('full_translation', 'true'); + if (languageDetection === 'skip') params.set('language_detection', 'skip'); + const query = params.toString() ? `?${params}` : ''; return await api.postApi(`/translate/jobs/${jobId}/run${query}`, {}); } catch (error) { throw normalizeTranslateError(error, 'Failed to start translation run'); @@ -49,6 +56,38 @@ export async function triggerRun(jobId: string, full: boolean = fal } // #endregion triggerRun +export interface RunPreflightResponse { + config_hash: string; + full_translation: boolean; + total_source_rows: number; + eligible_rows: number; + skipped_rows: number; + target_language_count: number; + estimated_tokens: number; + estimated_cost: number; + language_distribution: Record | null; + lingua_duration_ms: number; + lingua_rows_per_second: number; + lingua_seconds_per_1000: number; + lingua_accepted: boolean; + recommended_language_detection: "auto" | "skip"; +} + +// #region calculateRunPreflight [C:2] [TYPE Function] [SEMANTICS translate,run,preflight] +// @BRIEF Calculate actual run scope and local language distribution without creating a run. +export async function calculateRunPreflight( + jobId: string, + full: boolean = false, +): Promise { + try { + const query = full ? '?full_translation=true' : ''; + return await api.postApi(`/translate/jobs/${jobId}/run-preflight${query}`, {}); + } catch (error) { + throw normalizeTranslateError(error, 'Failed to calculate translation scope'); + } +} +// #endregion calculateRunPreflight + // #region fetchRunStatus [C:2] [TYPE Function] [SEMANTICS translate, runs, status] // @BRIEF Fetch the current status of a translation run. // @PRE runId is a non-empty string. diff --git a/frontend/src/lib/api/translate/target-schema.ts b/frontend/src/lib/api/translate/target-schema.ts index cf87c5232..e907c1059 100644 --- a/frontend/src/lib/api/translate/target-schema.ts +++ b/frontend/src/lib/api/translate/target-schema.ts @@ -8,7 +8,7 @@ import { api } from '$lib/api'; export interface TargetSchemaCheckPayload { environment_id: string; - target_database_id: string; + target_database_id: string | null; target_schema: string; target_table: string; target_key_cols: string[]; diff --git a/frontend/src/lib/components/StartMaintenanceForm.svelte b/frontend/src/lib/components/StartMaintenanceForm.svelte index b1beaed39..53665bc26 100644 --- a/frontend/src/lib/components/StartMaintenanceForm.svelte +++ b/frontend/src/lib/components/StartMaintenanceForm.svelte @@ -3,7 +3,7 @@ - + @@ -16,7 +16,7 @@ diff --git a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte index 7b87927b1..eab43bfcd 100644 --- a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte +++ b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte @@ -52,7 +52,7 @@ } from "$lib/api/assistant.js"; import { api } from "$lib/api.js"; import { gitService } from "../../../services/gitService.js"; - import { addToast } from "$lib/toasts.svelte.js"; + import { notifications } from "$lib/toasts.svelte.js"; import { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts"; import { Client } from "@gradio/client"; import { parseDateUTC } from "$lib/utils/dateFormat.js"; @@ -190,9 +190,9 @@ conversationId: null, })); } - addToast("Conversation deleted", "success"); + notifications.success("Conversation deleted"); } catch (err) { - addToast("Failed to delete conversation: " + err.message, "error"); + notifications.error("Failed to delete conversation: " + err.message); } } @@ -539,7 +539,7 @@ // Format validation if (!ALLOWED_FILE_TYPES.includes(ext)) { - addToast($t.assistant?.file_unsupported || "Unsupported format. Supported: PDF, XLSX, JSON, CSV, TXT, PNG, JPEG", "error"); + notifications.error($t.assistant?.file_unsupported || "Unsupported format. Supported: PDF, XLSX, JSON, CSV, TXT, PNG, JPEG"); target.value = ""; return; } @@ -547,7 +547,7 @@ // Size validation if (file.size > MAX_FILE_SIZE_BYTES) { const sizeMB = (file.size / 1024 / 1024).toFixed(1); - addToast(`${$t.assistant?.file_too_large || "File too large"} (${sizeMB} MB, max 10 MB)`, "error"); + notifications.error(`${$t.assistant?.file_too_large || "File too large"} (${sizeMB} MB, max 10 MB)`); target.value = ""; return; } diff --git a/frontend/src/lib/components/assistant/__tests__/ConfirmationCard.ux.test.ts b/frontend/src/lib/components/assistant/__tests__/ConfirmationCard.ux.test.ts index e4e4a02db..d3ce1d9f4 100644 --- a/frontend/src/lib/components/assistant/__tests__/ConfirmationCard.ux.test.ts +++ b/frontend/src/lib/components/assistant/__tests__/ConfirmationCard.ux.test.ts @@ -19,7 +19,7 @@ vi.mock("$lib/stores/assistantChat.svelte.js", () => ({ setAssistantConversationId: vi.fn(), })); -vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() })); +vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn(), notifications: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), show: vi.fn() } })); vi.mock("$lib/cot-logger", () => ({ log: vi.fn() })); const mockTranslations = { diff --git a/frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.ts b/frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.ts index 60f0dd815..d682404e5 100644 --- a/frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.ts +++ b/frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.ts @@ -22,7 +22,7 @@ vi.mock('$lib/api', () => ({ })); vi.mock('$lib/toasts.svelte.js', () => ({ - addToast: vi.fn() + notifications: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), show: vi.fn() }, })); const mockAssistantState = { isOpen: true, conversationId: 'conv-1', seedMessage: '', focusTarget: { target: 'mapping:m-1', source: 'test' } }; diff --git a/frontend/src/lib/components/assistant/__tests__/assistant_first_message.integration.test.ts b/frontend/src/lib/components/assistant/__tests__/assistant_first_message.integration.test.ts index 8f9fd8940..8501b5147 100644 --- a/frontend/src/lib/components/assistant/__tests__/assistant_first_message.integration.test.ts +++ b/frontend/src/lib/components/assistant/__tests__/assistant_first_message.integration.test.ts @@ -82,7 +82,7 @@ vi.mock('$lib/stores/assistantChat.svelte.js', () => ({ })); vi.mock('$lib/toasts.svelte.js', () => ({ - addToast: vi.fn(), + notifications: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), show: vi.fn() }, })); vi.mock('$lib/stores/taskDrawer.svelte.js', () => ({ diff --git a/frontend/src/lib/components/backups/BackupManager.svelte b/frontend/src/lib/components/backups/BackupManager.svelte index 1710e51eb..027cb3049 100644 --- a/frontend/src/lib/components/backups/BackupManager.svelte +++ b/frontend/src/lib/components/backups/BackupManager.svelte @@ -27,7 +27,7 @@ import { t } from '$lib/i18n/index.svelte.js'; import { log } from "$lib/cot-logger"; import { api, API_REQUEST_TIMEOUT } from '$lib/api'; - import { addToast } from '$lib/toasts.svelte.js'; + import { notifications } from '$lib/toasts.svelte.js'; import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js'; import { Button, Card, Select, Input, ConfirmDialog } from '$lib/ui'; import { listFiles, deleteFile, verifyBackup } from '../../../services/storageService'; @@ -139,9 +139,9 @@ } catch (error: any) { log("BackupManager", "EXPLORE", "Load failed", {}, error?.message || 'Unknown'); if (error instanceof DOMException && error.name === 'AbortError') { - addToast('Request timed out', 'error'); + notifications.error('Request timed out'); } else { - addToast(error?.message || $t.common.error, 'error'); + notifications.error(error?.message || $t.common.error); } } finally { loading = false; @@ -164,11 +164,11 @@ const result = normalizeBackupIntegrityResult(raw); selectedIntegrity = { file, result }; log("BackupManager", "REFLECT", "Backup integrity verified", { path: file.path, status: result.status }); - addToast(statusLabel(result.status), result.status === 'ok' || result.status === 'verified' ? 'success' : 'error'); + notifications.show({ message: statusLabel(result.status), type: result.status === 'ok' || result.status === 'verified' ? 'success' : 'error' }); } catch (error: unknown) { log("BackupManager", "EXPLORE", "Backup integrity verification failed", { path: file.path }, error instanceof Error ? error.message : 'Unknown error'); selectedIntegrity = { file, result: { status: 'unknown', error: error instanceof Error ? error.message : 'Unknown error' } }; - addToast($t.storage.integrity.verify_failed, 'error'); + notifications.error($t.storage.integrity.verify_failed); } finally { verifyingPath = null; } @@ -198,14 +198,14 @@ // ── Backup trigger ────────────────────────────────────────────── async function handleCreateBackup() { - if (!selectedEnvId) { addToast($t.tasks.select_env, 'error'); return; } + if (!selectedEnvId) { notifications.error($t.tasks.select_env); return; } log("BackupManager", "REASON", "Triggering backup", { selectedEnvId }); creating = true; try { const response = await api.createTask('superset-backup', { environment_id: selectedEnvId }, { signal: AbortSignal.timeout(API_REQUEST_TIMEOUT) } ); - addToast($t.common.success, 'success'); + notifications.success($t.common.success); log("BackupManager", "REFLECT", "Backup task triggered"); // Auto-open task drawer const taskId = typeof response === 'object' && response?.id @@ -214,9 +214,9 @@ } catch (error: any) { log("BackupManager", "EXPLORE", "Create backup failed", {}, error?.message || 'Unknown'); if (error instanceof DOMException && error.name === 'AbortError') { - addToast('Backup request timed out', 'error'); + notifications.error('Backup request timed out'); } else { - addToast(error?.message || $t.common.error, 'error'); + notifications.error(error?.message || $t.common.error); } } finally { creating = false; } } @@ -235,7 +235,7 @@ ? { ...e, backup_schedule: { enabled: scheduleEnabled, cron_expression: cronExpression } } : e); await api.updateEnvironmentSchedule(selectedEnvId, { enabled: scheduleEnabled, cron_expression: cronExpression }); - addToast($t.common.success, 'success'); + notifications.success($t.common.success); } catch (error: any) { log("BackupManager", "EXPLORE", "Schedule update failed", {}, error?.message || 'Unknown'); environments = environments.map(e => e.id === selectedEnvId @@ -243,7 +243,7 @@ : e); scheduleEnabled = prevEnabled; cronExpression = prevCron; - addToast($t.common.error, 'error'); + notifications.error($t.common.error); } finally { savingSchedule = false; } } @@ -282,8 +282,8 @@ for (const p of payloads) { try { await deleteFile(p.category, p.path); } catch { errors++; } } - if (errors === 0) addToast($t.common.success, 'success'); - else addToast($t.common.error, 'error'); + if (errors === 0) notifications.success($t.common.success); + else notifications.error($t.common.error); await loadData(); } diff --git a/frontend/src/lib/components/dashboard/DashboardGrid.svelte b/frontend/src/lib/components/dashboard/DashboardGrid.svelte index e1ea785be..df032b5bc 100644 --- a/frontend/src/lib/components/dashboard/DashboardGrid.svelte +++ b/frontend/src/lib/components/dashboard/DashboardGrid.svelte @@ -24,7 +24,7 @@ import { SvelteSet, SvelteDate } from "svelte/reactivity"; import { Button, Input } from "$lib/ui"; import GitManager from "$lib/components/git/GitManager.svelte"; import { api } from "$lib/api"; - import { addToast as toast } from "$lib/toasts.svelte.js"; + import { notifications } from "$lib/toasts.svelte.js"; // [/SECTION] // [SECTION: PROPS] @@ -66,9 +66,9 @@ import { SvelteSet, SvelteDate } from "svelte/reactivity"; }, }); - toast("Validation task started", "success"); + notifications.success("Validation task started"); } catch (e: any) { - toast(e.message || "Validation failed to start", "error"); + notifications.error(e.message || "Validation failed to start"); } finally { validatingIds.delete(dashboard.id); } diff --git a/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte b/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte index 713d24592..df53b1563 100644 --- a/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte +++ b/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte @@ -20,7 +20,7 @@ import { gitService } from "../../../services/gitService"; import { resolveGitStatusToken } from "../../../services/git-utils"; import { formatDateTime } from "$lib/utils/dateFormat"; - import { addToast } from "$lib/toasts.svelte.js"; + import { notifications } from "$lib/toasts.svelte.js"; // [/SECTION] // [SECTION: PROPS] @@ -158,7 +158,7 @@ async function runBulkGitAction(actionToken: string, action: (_id: number) => Promise): Promise { if (bulkActionRunning) return; const readyIds = selectedIds.filter((id) => isRepositoryReady(id)); - if (readyIds.length === 0) { addToast($t.git?.no_repositories_selected, "error"); return; } + if (readyIds.length === 0) { notifications.error($t.git?.no_repositories_selected); return; } bulkActionRunning = true; const concurrency = 3; const queue = [...readyIds]; @@ -175,8 +175,10 @@ await Promise.all(Array.from({ length: Math.min(concurrency, readyIds.length) }, () => worker())); invalidateRepositoryStatuses(readyIds); const al = $t.git?.[`bulk_action_${actionToken}`] || actionToken; - addToast($t.git?.bulk_result.replace("{action}", al).replace("{success}", String(successCount)).replace("{failed}", String(failedCount)), - failedCount > 0 ? "warning" : "success"); + notifications.show({ + message: $t.git?.bulk_result.replace("{action}", al).replace("{success}", String(successCount)).replace("{failed}", String(failedCount)), + type: failedCount > 0 ? "warning" : "success" + }); } finally { bulkActionRunning = false; } } // #endregion runBulkGitAction:Function @@ -213,7 +215,7 @@ // #region handleManageSelected:Function [TYPE Function] function handleManageSelected(): void { - if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; } + if (selectedIds.length !== 1) { notifications.warning($t.git?.select_single_for_manage); return; } const d = dashboards.find(dash => dash.id === selectedIds[0]); openGitManagerForDashboard(d || null); } @@ -221,7 +223,7 @@ // #region handleInitializeRepositories:Function [TYPE Function] function handleInitializeRepositories(): void { - if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; } + if (selectedIds.length !== 1) { notifications.warning($t.git?.select_single_for_manage); return; } const d = dashboards.find(dash => dash.id === selectedIds[0]) || null; openGitManagerForDashboard(d); } @@ -242,7 +244,7 @@ function openGitManagerForDashboard(dashboard: DashboardMetadata | null): void { if (!dashboard) return; const ref = resolveDashboardRef(dashboard); - if (!ref) { addToast($t.git?.select_dashboard_with_slug || "Dashboard slug required", "error"); return; } + if (!ref) { notifications.error($t.git?.select_dashboard_with_slug || "Dashboard slug required"); return; } gitDashboardId = ref; gitDashboardTitle = dashboard.title || ""; showGitManager = true; diff --git a/frontend/src/lib/components/git/CommitHistory.svelte b/frontend/src/lib/components/git/CommitHistory.svelte index e971be36e..713df25f8 100644 --- a/frontend/src/lib/components/git/CommitHistory.svelte +++ b/frontend/src/lib/components/git/CommitHistory.svelte @@ -15,7 +15,7 @@ import { gitService } from '../../../services/gitService'; import { t } from '$lib/i18n/index.svelte.js'; import { Button, Input } from '$lib/ui'; - import { addToast } from '$lib/toasts.svelte.js'; + import { notifications } from '$lib/toasts.svelte.js'; import { log } from "$lib/cot-logger"; import { parseDateUTC } from "$lib/utils/dateFormat.js"; import { appTimezone } from "$lib/stores/timezone.svelte.js"; @@ -68,7 +68,7 @@ log("CommitHistory", "REFLECT", "Commit history loaded", { count: history.length }); } catch (e) { log("CommitHistory", "EXPLORE", "Failed to load commit history", { dashboardId }, e instanceof Error ? e.message : String(e)); - addToast('Failed to load commit history', 'error'); + notifications.error('Failed to load commit history'); } finally { loading = false; } @@ -90,11 +90,11 @@ showRollbackConfirm = false; try { await gitService.rollbackCommit(dashboardId, rollbackConfirmHash, rollbackReason.trim(), envId); - addToast(($t.git?.rollback_success || 'Rollback commit created'), 'success'); + notifications.success(($t.git?.rollback_success || 'Rollback commit created')); await loadHistory(); } catch (e) { log("CommitHistory", "EXPLORE", "Rollback failed", { dashboardId, hash: rollbackConfirmHash }, e instanceof Error ? e.message : String(e)); - addToast(($t.git?.rollback_failed || 'Rollback failed'), 'error'); + notifications.error(($t.git?.rollback_failed || 'Rollback failed')); } finally { rollingBackHash = ''; rollbackConfirmHash = ''; diff --git a/frontend/src/lib/components/git/ConflictResolver.svelte b/frontend/src/lib/components/git/ConflictResolver.svelte index 545cbaf49..956962b97 100644 --- a/frontend/src/lib/components/git/ConflictResolver.svelte +++ b/frontend/src/lib/components/git/ConflictResolver.svelte @@ -13,7 +13,7 @@ diff --git a/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte b/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte index a7895c866..62c3ea2a7 100644 --- a/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte +++ b/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte @@ -35,7 +35,7 @@ import { getT } from '$lib/i18n/index.svelte.js'; import { onDestroy } from 'svelte'; import { cancelRun } from '$lib/api/translate.js'; - import { addToast } from '$lib/toasts.svelte.js'; + import { notifications } from '$lib/toasts.svelte.js'; import { Icon } from '$lib/ui'; // Subscribe to store via $effect + subscribe (NOT fromStore + $derived). @@ -127,9 +127,9 @@ isCancelling = true; try { await cancelRun(runId); - addToast(getT()?.translate?.run?.run_cancelled || 'Run cancelled', 'info'); + notifications.info(getT()?.translate?.run?.run_cancelled || 'Run cancelled'); } catch (err) { - addToast(err?.message || getT()?.translate?.run?.cancel_failed || 'Cancel failed', 'error'); + notifications.error(err?.message || getT()?.translate?.run?.cancel_failed || 'Cancel failed'); } finally { isCancelling = false; } diff --git a/frontend/src/lib/components/translate/TranslationRunProgress.svelte b/frontend/src/lib/components/translate/TranslationRunProgress.svelte index b88efba85..eb3ad848d 100644 --- a/frontend/src/lib/components/translate/TranslationRunProgress.svelte +++ b/frontend/src/lib/components/translate/TranslationRunProgress.svelte @@ -35,7 +35,7 @@ subscription per component lifecycle — no accumulating render_effects. --> - -
- {#each $toasts.filter(t => t.persistent) as toast (toast.id)} - - {/each} + +
+ {#if toast.title}
{toast.title}
{/if} +
{toast.message}
+ {#if toast.action} + + {/if} +
+ +
+ {/each} + - - -
- {#each $toasts.filter(t => !t.persistent) as toast (toast.id)} -
- {toast.message} -
- {/each} -
- + diff --git a/frontend/src/lib/i18n/locales/en/translate.json b/frontend/src/lib/i18n/locales/en/translate.json index 880bcf0c7..4517bb805 100644 --- a/frontend/src/lib/i18n/locales/en/translate.json +++ b/frontend/src/lib/i18n/locales/en/translate.json @@ -515,6 +515,18 @@ "page_next": "Next" }, "run": { + "preflight_title": "Execution estimate", + "preflight_hint": "Calculate actual source scope, estimated cost, and source-language distribution.", + "preflight_incremental": "Estimate incremental", + "preflight_full": "Estimate full", + "preflight_loading": "Calculating…", + "preflight_ready": "Run scope calculated", + "preflight_scope": "To process", + "preflight_skipped": "Already covered", + "preflight_lingua_speed": "Lingua", + "preflight_rows_sec": "rows/s", + "preflight_languages": "Source language distribution", + "preflight_lingua_slow": "Lingua exceeded the performance threshold. The run will continue without local language detection.", "loading": "Loading run status...", "loading_result": "Loading result...", "result_title": "Run Result", diff --git a/frontend/src/lib/i18n/locales/ru/translate.json b/frontend/src/lib/i18n/locales/ru/translate.json index eecb1f4e4..e7c403cf0 100644 --- a/frontend/src/lib/i18n/locales/ru/translate.json +++ b/frontend/src/lib/i18n/locales/ru/translate.json @@ -516,6 +516,18 @@ "page_next": "Вперёд" }, "run": { + "preflight_title": "Оценка запуска", + "preflight_hint": "Рассчитайте фактический объём источника, стоимость и распределение исходных языков.", + "preflight_incremental": "Оценить инкрементальный", + "preflight_full": "Оценить полный", + "preflight_loading": "Расчёт…", + "preflight_ready": "Объём запуска рассчитан", + "preflight_scope": "К обработке", + "preflight_skipped": "Уже обработано", + "preflight_lingua_speed": "Lingua", + "preflight_rows_sec": "строк/с", + "preflight_languages": "Распределение исходных языков", + "preflight_lingua_slow": "Lingua превысила порог производительности. Перевод продолжится без локального определения языка.", "loading": "Загрузка статуса запуска...", "loading_result": "Загрузка результата...", "result_title": "Результат запуска", diff --git a/frontend/src/lib/models/AgentChatModel.svelte.ts b/frontend/src/lib/models/AgentChatModel.svelte.ts index 3e25197d7..570508b12 100644 --- a/frontend/src/lib/models/AgentChatModel.svelte.ts +++ b/frontend/src/lib/models/AgentChatModel.svelte.ts @@ -34,7 +34,7 @@ import { deleteAssistantConversation, } from "$lib/api/assistant.js"; import { fetchApi } from "$lib/api"; -import { addToast } from "$lib/toasts.svelte.js"; +import { notifications } from "$lib/toasts.svelte.js"; import { log } from "$lib/cot-logger"; import { t } from "$lib/i18n/index.svelte.js"; import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.ConnectionManager.svelte.js"; @@ -1000,12 +1000,12 @@ export class AgentChatModel { log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id }); try { await deleteAssistantConversation(id); - addToast("Диалог архивирован", "success"); + notifications.success("Диалог архивирован"); if (this.currentConversationId === id) this.createConversation(); } catch (e: unknown) { this.conversations = prevConversations; this.error = e instanceof Error ? e.message : "Failed to archive conversation"; - addToast("Не удалось архивировать диалог", "error"); + notifications.error("Не удалось архивировать диалог"); log("AgentChat.Model", "EXPLORE", "Archive failed, rolled back", {}, this.error); } } diff --git a/frontend/src/lib/models/BranchModel.svelte.ts b/frontend/src/lib/models/BranchModel.svelte.ts index 2f03f41cc..02b359044 100644 --- a/frontend/src/lib/models/BranchModel.svelte.ts +++ b/frontend/src/lib/models/BranchModel.svelte.ts @@ -24,7 +24,7 @@ // follows pattern established by GitStatusModel and GitManagerModel. import { gitService } from '../../services/gitService.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js'; // ── Types ───────────────────────────────────────────────────── @@ -130,7 +130,7 @@ export class BranchModel { this.branches = await gitService.getBranches(ref, eid); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Failed to load branches'; - addToast((tt().git as Record)?.load_branches_failed || msg, 'error'); + notifications.error((tt().git as Record)?.load_branches_failed || msg); this.branches = []; } finally { this.loading = false; @@ -167,9 +167,8 @@ export class BranchModel { if (typeof this.onChange === 'function') { this.onChange({ branch: branchName }); } - addToast( + notifications.success( ((tt().git as Record)?.switched_to || 'Switched to {branch}').replace('{branch}', branchName), - 'success', ); } catch (e: unknown) { const err = e as Record; @@ -179,7 +178,7 @@ export class BranchModel { message: shortMsg, next_steps: Array.isArray(detail?.next_steps) ? detail.next_steps as string[] : [], }; - addToast(shortMsg, 'warning'); + notifications.warning(shortMsg); } finally { this.checkingOut = false; } @@ -202,16 +201,15 @@ export class BranchModel { this.creating = true; try { await gitService.createBranch(ref, name, source, eid); - addToast( + notifications.success( ((tt().git as Record)?.created_branch || 'Created branch {branch}').replace('{branch}', name), - 'success', ); this.showCreate = false; this.newBranchName = ''; await this.loadBranches(ref, eid); } catch (e: unknown) { this.branchError = { message: e instanceof Error ? e.message : 'Branch creation failed', next_steps: [] }; - addToast(e instanceof Error ? e.message : 'Branch creation failed', 'error'); + notifications.error(e instanceof Error ? e.message : 'Branch creation failed'); } finally { this.creating = false; } @@ -243,19 +241,18 @@ export class BranchModel { ) as { status: string; conflicts?: string[]; source_deleted?: boolean }; this.mergeResult = res; if (res.status === 'success') { - addToast( + notifications.success( 'Merged ' + sourceBranch + ' → ' + targetBranch, - 'success', ); await this.loadBranches(); } else if (res.status === 'conflicts') { const conflictFiles = (res.conflicts || []).join(', '); - addToast('Merge conflicts in: ' + conflictFiles, 'warning'); + notifications.warning('Merge conflicts in: ' + conflictFiles); } } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Merge failed'; this.branchError = { message: msg, next_steps: [] }; - addToast(msg, 'error'); + notifications.error(msg); } finally { this.merging = false; } @@ -285,16 +282,15 @@ export class BranchModel { this.branchError = null; try { await gitService.deleteBranch(this.dashboardId, name, force, this.envId); - addToast( + notifications.success( ((tt().git as Record)?.delete_branch?.success || 'Branch deleted').replace('{branch}', name), - 'success', ); this.closeDeleteConfirm(); await this.loadBranches(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Delete failed'; this.branchError = { message: msg, next_steps: [] }; - addToast(msg, 'error'); + notifications.error(msg); } finally { this.deleting = false; } diff --git a/frontend/src/lib/models/BulkReplaceModalModel.svelte.ts b/frontend/src/lib/models/BulkReplaceModalModel.svelte.ts index 64bb9caee..7469c7685 100644 --- a/frontend/src/lib/models/BulkReplaceModalModel.svelte.ts +++ b/frontend/src/lib/models/BulkReplaceModalModel.svelte.ts @@ -11,11 +11,11 @@ // @RELATION CALLS -> [dictionaryApi.fetchDictionaries] // @DATA_CONTRACT Input: runId, targetLanguages → Output: applied with rows_affected count // @POST After apply: uxState='applied', onApplied callback invoked with changed count. -// @SIDE_EFFECT API fetch (preview, apply, dictionaries), toast notifications via addToast. +// @SIDE_EFFECT API fetch (preview, apply, dictionaries), toast notifications via notifications. // @RATIONALE BulkReplaceModal.svelte (461 LOC → ~300 LOC after extraction) violated INV_7. The component managed 13 $state atoms and a 7-state FSM inline. Model-first extraction moves all state, FSM transitions, and API calls into BulkReplaceModal.Model. The component retains only modal chrome (backdrop, header, scrollable body) and the previewAfter() pure helper function. Follows the TopNavbar/TranslationRunResult pattern. // @REJECTED Embedding the FSM as a Svelte store was rejected — this is a single-modal component with no cross-route state sharing. A store would add unnecessary global scope. Extracting only the API calls (without state) was rejected — the FSM transitions and state atoms are tightly coupled to API results (e.g., uxState='applied' only after bulkFindReplace succeeds). -import { addToast } from "$lib/toasts.svelte.js"; +import { notifications } from "$lib/toasts.svelte.js"; import { bulkFindReplace, bulkReplacePreview, dictionaryApi } from "$lib/api/translate.js"; import { _ } from "$lib/i18n/index.svelte.js"; @@ -86,17 +86,17 @@ export class BulkReplaceModalModel { this.selectedDictId = this.dictionaries[0].id as string; } } catch { - addToast(_("translate.bulk_replace.dict_load_failed"), "error"); + notifications.error(_("translate.bulk_replace.dict_load_failed")); } } async handlePreview(runId: string): Promise { if (!this.findPattern.trim()) { - addToast(_("translate.bulk_replace.find_pattern_required"), "warning"); + notifications.warning(_("translate.bulk_replace.find_pattern_required")); return; } if (!this.targetLanguage) { - addToast(_("translate.bulk_replace.target_language_required"), "warning"); + notifications.warning(_("translate.bulk_replace.target_language_required")); return; } this.uxState = "previewing"; @@ -114,7 +114,7 @@ export class BulkReplaceModalModel { } catch (err: any) { this.errorMessage = err?.message || _("translate.bulk_replace.preview_failed"); this.uxState = "preview_error"; - addToast(this.errorMessage, "error"); + notifications.error(this.errorMessage); } } @@ -144,11 +144,11 @@ export class BulkReplaceModalModel { const changed = (result as any)?.rows_affected || this.applyCount; this.uxState = "applied"; onApplied(changed); - addToast(_("translate.bulk_replace.applied_toast").replace("{count}", String(changed)), "success"); + notifications.success(_("translate.bulk_replace.applied_toast").replace("{count}", String(changed))); } catch (err: any) { this.errorMessage = err?.message || _("translate.bulk_replace.apply_failed"); this.uxState = "apply_error"; - addToast(this.errorMessage, "error"); + notifications.error(this.errorMessage); } } } diff --git a/frontend/src/lib/models/CommitModel.svelte.ts b/frontend/src/lib/models/CommitModel.svelte.ts index ce6e3f3a7..2a97c5410 100644 --- a/frontend/src/lib/models/CommitModel.svelte.ts +++ b/frontend/src/lib/models/CommitModel.svelte.ts @@ -19,7 +19,7 @@ import { gitService } from '../../services/gitService.js'; import { api } from '$lib/api.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js'; // ── Types ───────────────────────────────────────────────────── @@ -104,7 +104,7 @@ export class CommitModel { this.diff = combined || ''; } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : 'Failed to load status'; - addToast((tt().git as Record)?.load_changes_failed || errMsg, 'error'); + notifications.error((tt().git as Record)?.load_changes_failed || errMsg); this.status = null; this.diff = ''; } finally { @@ -129,9 +129,9 @@ export class CommitModel { { suppressToast: true }, ); this.message = data?.message || ''; - addToast((tt().git as Record)?.commit_message_generated || 'Commit message generated', 'success'); + notifications.success((tt().git as Record)?.commit_message_generated || 'Commit message generated'); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : (tt().git as Record)?.commit_message_failed || 'Failed to generate message', 'error'); + notifications.error(e instanceof Error ? e.message : (tt().git as Record)?.commit_message_failed || 'Failed to generate message'); } finally { this.generatingMessage = false; } @@ -151,14 +151,14 @@ export class CommitModel { await gitService.commit(this.dashboardId, this.message, [], this.envId); if (this.autoPushAfterCommit) { await gitService.push(this.dashboardId, this.envId); - addToast((tt().git as Record)?.commit_and_push_success || 'Committed and pushed', 'success'); + notifications.success((tt().git as Record)?.commit_and_push_success || 'Committed and pushed'); } else { - addToast((tt().git as Record)?.commit_success || 'Committed successfully', 'success'); + notifications.success((tt().git as Record)?.commit_success || 'Committed successfully'); } this.show = false; this.message = ''; } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Commit failed', 'error'); + notifications.error(e instanceof Error ? e.message : 'Commit failed'); } finally { this.committing = false; } diff --git a/frontend/src/lib/models/DashboardDetailModel.svelte.ts b/frontend/src/lib/models/DashboardDetailModel.svelte.ts index b30cc752f..2053edd57 100644 --- a/frontend/src/lib/models/DashboardDetailModel.svelte.ts +++ b/frontend/src/lib/models/DashboardDetailModel.svelte.ts @@ -27,7 +27,7 @@ import { api } from '$lib/api.js'; import { fetchApi } from '$lib/api'; import { log } from '$lib/cot-logger'; import { GitStatusModel } from '$lib/models/GitStatusModel.svelte.ts'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js'; import { environmentContextStore } from '$lib/stores/environmentContext.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js'; @@ -232,11 +232,11 @@ export class DashboardDetailModel { const taskId = response?.task_id; if (taskId) { openDrawerForTaskIfPreferred(taskId); - addToast(getT()?.dashboard?.backup_started || 'Backup task started', 'success'); + notifications.success(getT()?.dashboard?.backup_started || 'Backup task started'); } await this.loadTaskHistory(); } catch (err: unknown) { - addToast(err instanceof Error ? err.message : (getT()?.dashboard?.backup_task_failed || 'Failed to start backup'), 'error'); + notifications.error(err instanceof Error ? err.message : (getT()?.dashboard?.backup_task_failed || 'Failed to start backup')); } finally { this.isStartingBackup = false; } diff --git a/frontend/src/lib/models/DashboardHubModel.svelte.ts b/frontend/src/lib/models/DashboardHubModel.svelte.ts index 44e2a189a..0abb71a86 100644 --- a/frontend/src/lib/models/DashboardHubModel.svelte.ts +++ b/frontend/src/lib/models/DashboardHubModel.svelte.ts @@ -35,7 +35,7 @@ import { goto } from "$app/navigation"; import { ROUTES } from "$lib/routes.js"; import { log } from "$lib/cot-logger"; import { api } from "$lib/api.js"; -import { addToast } from "$lib/toasts.svelte.js"; +import { notifications } from "$lib/toasts.svelte.js"; import { openDrawerForTask, openDrawerForTaskIfPreferred } from "$lib/stores/taskDrawer.svelte.js"; import { MigrationModel } from "./MigrationModel.svelte.ts"; import { t } from "$lib/i18n/index.svelte.js"; diff --git a/frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts b/frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts index 682e40604..bb7c59a3c 100644 --- a/frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts +++ b/frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts @@ -27,7 +27,7 @@ import { SvelteSet } from "svelte/reactivity"; import { gitService } from "../../services/gitService.js"; -import { addToast } from "$lib/toasts.svelte.js"; +import { notifications } from "$lib/toasts.svelte.js"; import { t } from "$lib/i18n/index.svelte.js"; // ── Types ──────────────────────────────────────────────────────── @@ -123,49 +123,49 @@ export class DashboardsGitActionsModel { this.setGitBusy(dashboard.id, true); try { const configs = await this.ensureGitConfigs(); - if (!configs.length) { addToast(t.git?.no_servers_configured || "No Git config found", "error"); return; } + if (!configs.length) { notifications.error(t.git?.no_servers_configured || "No Git config found"); return; } const config = configs[0]; const defaultRemote = config?.default_repository ? `${String(config.url || "").replace(/\/$/, "")}/${config.default_repository}.git` : ""; const remoteUrl = prompt(t.git?.remote_url || "Remote URL", defaultRemote); if (!remoteUrl) return; await gitService.initRepository(dashboard.id, config.id, remoteUrl.trim()); - addToast(t.git?.init_success || "Repository initialized", "success"); + notifications.success(t.git?.init_success || "Repository initialized"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); - } catch (err: any) { addToast(err?.message || "Git init failed", "error"); } + } catch (err: any) { notifications.error(err?.message || "Git init failed"); } finally { this.setGitBusy(dashboard.id, false); } } async handleGitSync(dashboard: DashboardRow, selectedEnv: string | null): Promise { this.setGitBusy(dashboard.id, true); - try { await gitService.sync(dashboard.id, selectedEnv || null); addToast(t.git?.sync_success || "Synced", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } - catch (err: any) { addToast(err?.message || "Git sync failed", "error"); } + try { await gitService.sync(dashboard.id, selectedEnv || null); notifications.success(t.git?.sync_success || "Synced"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { notifications.error(err?.message || "Git sync failed"); } finally { this.setGitBusy(dashboard.id, false); } } async handleGitCommit(dashboard: DashboardRow, selectedEnv: string | null): Promise { - if (!dashboard.git?.hasRepo) { addToast(t.git?.not_linked || "Repository not linked", "error"); return; } - if (!dashboard.git?.hasChangesForCommit) { addToast(t.git?.nothing_to_commit || "No changes to commit", "error"); return; } + if (!dashboard.git?.hasRepo) { notifications.error(t.git?.not_linked || "Repository not linked"); return; } + if (!dashboard.git?.hasChangesForCommit) { notifications.error(t.git?.nothing_to_commit || "No changes to commit"); return; } const message = prompt(t.git?.commit_message || "Commit message", `Update dashboard ${dashboard.title}`); if (!message?.trim()) return; this.setGitBusy(dashboard.id, true); - try { await gitService.commit(dashboard.slug || dashboard.id, message.trim()); addToast(t.git?.commit_success || "Committed", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } - catch (err: any) { addToast(err?.message || "Git commit failed", "error"); } + try { await gitService.commit(dashboard.slug || dashboard.id, message.trim()); notifications.success(t.git?.commit_success || "Committed"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { notifications.error(err?.message || "Git commit failed"); } finally { this.setGitBusy(dashboard.id, false); } } async handleGitPull(dashboard: DashboardRow, selectedEnv: string | null): Promise { if (!dashboard.git?.hasRepo) return; this.setGitBusy(dashboard.id, true); - try { await gitService.pull(dashboard.slug || dashboard.id, selectedEnv || null); addToast(t.git?.pull_success || "Pulled", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } - catch (err: any) { addToast(err?.message || "Git pull failed", "error"); } + try { await gitService.pull(dashboard.slug || dashboard.id, selectedEnv || null); notifications.success(t.git?.pull_success || "Pulled"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { notifications.error(err?.message || "Git pull failed"); } finally { this.setGitBusy(dashboard.id, false); } } async handleGitPush(dashboard: DashboardRow, selectedEnv: string | null): Promise { if (!dashboard.git?.hasRepo) return; this.setGitBusy(dashboard.id, true); - try { await gitService.push(dashboard.slug || dashboard.id, selectedEnv || null); addToast(t.git?.push_success || "Pushed", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } - catch (err: any) { addToast(err?.message || "Git push failed", "error"); } + try { await gitService.push(dashboard.slug || dashboard.id, selectedEnv || null); notifications.success(t.git?.push_success || "Pushed"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { notifications.error(err?.message || "Git push failed"); } finally { this.setGitBusy(dashboard.id, false); } } diff --git a/frontend/src/lib/models/DeploymentModel.svelte.ts b/frontend/src/lib/models/DeploymentModel.svelte.ts index f25ba5568..d8cd3a6d0 100644 --- a/frontend/src/lib/models/DeploymentModel.svelte.ts +++ b/frontend/src/lib/models/DeploymentModel.svelte.ts @@ -22,7 +22,7 @@ import { gitService } from '../../services/gitService.js'; import { api } from '$lib/api.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js'; import { normalizeEnvStage } from '../../services/git-utils.js'; @@ -138,7 +138,7 @@ export class DeploymentModel { this.environments = await api.getEnvironmentsList(); this.selectedEnv = this.deploymentCandidates[0]?.id || ''; } catch { - addToast((tt().migration as Record)?.loading_envs_failed || 'Failed to load environments', 'error'); + notifications.error((tt().migration as Record)?.loading_envs_failed || 'Failed to load environments'); } finally { this.loading = false; } @@ -156,17 +156,17 @@ export class DeploymentModel { if (!this.selectedEnv) return; const stage = this.selectedEnvironmentStage.toLowerCase(); if (stage !== 'preprod' && stage !== 'prod') { - addToast('Для развёртывания выберите PREPROD или PROD.', 'error'); + notifications.error('Для развёртывания выберите PREPROD или PROD.'); return; } this.deploying = true; try { const result: { message?: string } = await gitService.deploy(this.dashboardId, stage, this.envId, this.commitHash, this.sourceBranch); - addToast(result?.message || (tt().git as Record)?.deploy_success || 'Deployed successfully', 'success'); + notifications.success(result?.message || (tt().git as Record)?.deploy_success || 'Deployed successfully'); this.show = false; } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Deploy failed', 'error'); + notifications.error(e instanceof Error ? e.message : 'Deploy failed'); } finally { this.deploying = false; } diff --git a/frontend/src/lib/models/DictionaryDetailModel.svelte.ts b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts index 9e9df6bb5..48df42d60 100644 --- a/frontend/src/lib/models/DictionaryDetailModel.svelte.ts +++ b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts @@ -13,7 +13,7 @@ // @REJECTED Component-first state for dictionary detail (disjoint $state scattered across +page.svelte) rejected — the entry CRUD + import flow has complex modal/view interactions (expand toggles, preview→confirm transition, language select filtering) that are predictable only with the FSM appState pattern. Splitting into EntryListModel + ImportModel rejected — entry operations and import share dictionaryId and loadEntries() context, making split overhead unjustified at 225 lines. import { api } from '$lib/api.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { t } from '$lib/i18n/index.svelte.js'; import { ALL_LANGUAGES } from '$lib/i18n/languages.js'; import { SvelteURLSearchParams } from "svelte/reactivity"; @@ -120,7 +120,7 @@ export class DictionaryDetailModel { this.loadAllowedLanguages(); this.loadEntries(); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to load dictionary', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to load dictionary'); this.appState = 'idle'; } } @@ -134,7 +134,7 @@ export class DictionaryDetailModel { this.entries = (res.items || res.entries || []) as DictionaryEntry[]; this.totalEntries = res.total || 0; } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to load entries', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to load entries'); } } @@ -159,11 +159,11 @@ export class DictionaryDetailModel { try { payload.context_data = JSON.parse(this.editContextDataRaw); } catch { /* keep as string */ } } await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${this.editEntryId}`, 'PUT', payload); - addToast(t.translate?.dictionaries?.entry_saved || 'Entry saved', 'success'); + notifications.success(t.translate?.dictionaries?.entry_saved || 'Entry saved'); this.cancelEdit(); this.loadEntries(); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to save entry', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to save entry'); this.appState = 'editing'; } } @@ -172,12 +172,12 @@ export class DictionaryDetailModel { this.isAdding = true; try { await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries`, 'POST', this.addForm); - addToast(t.translate?.dictionaries?.entry_added || 'Entry added', 'success'); + notifications.success(t.translate?.dictionaries?.entry_added || 'Entry added'); this.showAddForm = false; this.addForm = { source_term: '', target_term: '', source_language: 'und', target_language: 'und', context_notes: '', is_regex: false }; this.loadEntries(); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to add entry', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to add entry'); } finally { this.isAdding = false; } } @@ -185,10 +185,10 @@ export class DictionaryDetailModel { if (!confirm(t.translate?.dictionaries?.confirm_delete || 'Delete this entry?')) return; try { await api.requestApi(`/translate/dictionaries/${this.dictionaryId}/entries/${entryId}`, 'DELETE'); - addToast(t.translate?.dictionaries?.entry_deleted || 'Entry deleted', 'success'); + notifications.success(t.translate?.dictionaries?.entry_deleted || 'Entry deleted'); this.loadEntries(); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to delete entry', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to delete entry'); } } @@ -207,7 +207,7 @@ export class DictionaryDetailModel { this.importErrors = (res.errors || []) as string[]; this.appState = 'import_preview'; } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Preview failed', 'error'); + notifications.error(e instanceof Error ? e.message : 'Preview failed'); this.appState = 'idle'; } } @@ -219,9 +219,9 @@ export class DictionaryDetailModel { this.importResult = res; this.showImportForm = false; this.loadEntries(); - addToast(t.translate?.dictionaries?.import_success || `Imported ${res.imported || 0} entries`, 'success'); + notifications.success(t.translate?.dictionaries?.import_success || `Imported ${res.imported || 0} entries`); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Import failed', 'error'); + notifications.error(e instanceof Error ? e.message : 'Import failed'); } finally { this.isImporting = false; } } } diff --git a/frontend/src/lib/models/GitConfigModel.svelte.ts b/frontend/src/lib/models/GitConfigModel.svelte.ts index de84255ee..4d6b2e443 100644 --- a/frontend/src/lib/models/GitConfigModel.svelte.ts +++ b/frontend/src/lib/models/GitConfigModel.svelte.ts @@ -30,7 +30,7 @@ // @REJECTED getState/setState proxy pattern rejected — fragile, untyped, hard to test. Real $state atoms with class methods enable L1 testing without DOM render. import { gitService } from '../../services/gitService.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js'; // ── Types ───────────────────────────────────────────────────── @@ -178,7 +178,7 @@ export class GitConfigModel { this.configs = await gitService.getConfigs(); } catch (e: unknown) { this.configsError = e instanceof Error ? e.message : 'Failed to load git configs'; - addToast(e instanceof Error ? e.message : 'Failed to load git configs', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to load git configs'); } finally { this.configsLoading = false; } @@ -254,17 +254,17 @@ export class GitConfigModel { const updated = await gitService.updateConfig(this.editingConfigId, this.formData); this.configs = this.configs.map((c: GitConfigItem) => (c.id === this.editingConfigId ? updated : c)); this.saveSuccess = (this._t?.settings as Record)?.git_config_updated as string || 'Git configuration updated'; - addToast(this.saveSuccess, 'success'); + notifications.success(this.saveSuccess); } else { const saved = await gitService.createConfig(this.formData); this.configs = [...this.configs, saved]; this.saveSuccess = (this._t?.settings as Record)?.git_config_saved as string || 'Git configuration saved'; - addToast(this.saveSuccess, 'success'); + notifications.success(this.saveSuccess); } this.cancelEdit(); } catch (e: unknown) { this.saveError = e instanceof Error ? e.message : 'Failed to save git config'; - addToast(e instanceof Error ? e.message : 'Failed to save git config', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to save git config'); } finally { this.saving = false; } @@ -279,13 +279,13 @@ export class GitConfigModel { try { await gitService.deleteConfig(id); this.configs = this.configs.filter((c: GitConfigItem) => c.id !== id); - addToast((this._t?.settings as Record)?.git_config_deleted as string || 'Git configuration deleted', 'success'); + notifications.success((this._t?.settings as Record)?.git_config_deleted as string || 'Git configuration deleted'); if (this.selectedGiteaConfigId === id) { this.selectedGiteaConfigId = ''; this.giteaRepos = []; } } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to delete git config', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to delete git config'); } finally { this.deleting = false; } @@ -309,15 +309,14 @@ export class GitConfigModel { const result: ConnectionTestResult = await gitService.testConnection(testPayload); this.connectionTestResult = result; if (result.status === 'success') { - addToast((this._t?.settings as Record)?.connection_success as string || 'Connection successful', 'success'); + notifications.success((this._t?.settings as Record)?.connection_success as string || 'Connection successful'); } else { - addToast( + notifications.error( result.message || (this._t?.settings as Record)?.connection_failed_short as string || 'Connection failed', - 'error', ); } } catch { - addToast((this._t?.settings as Record)?.connection_failed_short as string || 'Connection failed', 'error'); + notifications.error((this._t?.settings as Record)?.connection_failed_short as string || 'Connection failed'); } finally { this.testingConnection = false; } @@ -338,7 +337,7 @@ export class GitConfigModel { try { this.giteaRepos = await gitService.listGiteaRepositories(this.selectedGiteaConfigId); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to load Gitea repos', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to load Gitea repos'); this.giteaRepos = []; } finally { this.giteaReposLoading = false; @@ -358,7 +357,7 @@ export class GitConfigModel { ...this.newGiteaRepo, name: this.newGiteaRepo.name.trim(), } satisfies GiteaRepoForm); - addToast('Gitea repository created', 'success'); + notifications.success('Gitea repository created'); this.newGiteaRepo = { name: '', private: true, @@ -368,7 +367,7 @@ export class GitConfigModel { }; await this.loadGiteaRepos(); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to create Gitea repo', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to create Gitea repo'); } finally { this.giteaCreating = false; } @@ -385,16 +384,16 @@ export class GitConfigModel { const owner = parts.length > 1 ? parts[0] : ''; const repoName = parts.length > 1 ? parts[1] : repo.name; if (!owner || !repoName) { - addToast('Cannot resolve repository owner/name', 'error'); + notifications.error('Cannot resolve repository owner/name'); return; } this.giteaDeleting = true; try { await gitService.deleteGiteaRepository(this.selectedGiteaConfigId, owner, repoName); - addToast('Gitea repository deleted', 'success'); + notifications.success('Gitea repository deleted'); await this.loadGiteaRepos(); } catch (e: unknown) { - addToast(e instanceof Error ? e.message : 'Failed to delete Gitea repo', 'error'); + notifications.error(e instanceof Error ? e.message : 'Failed to delete Gitea repo'); } finally { this.giteaDeleting = false; } diff --git a/frontend/src/lib/models/GitManagerModel.svelte.ts b/frontend/src/lib/models/GitManagerModel.svelte.ts index 646f478c5..5bb560165 100644 --- a/frontend/src/lib/models/GitManagerModel.svelte.ts +++ b/frontend/src/lib/models/GitManagerModel.svelte.ts @@ -56,7 +56,7 @@ import { gitService } from '../../services/gitService.js'; import { api } from '$lib/api.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { isNumericDashboardRef, resolveDefaultConfig, @@ -433,7 +433,7 @@ export class GitManagerModel { err.errorType = errorType || (err.status >= 500 ? 'error' : err.status === 409 ? 'warning' : 'error'); this.gitError = err; this.gitErrorType = err.errorType; - addToast(err.message, err.errorType === 'warning' ? 'warning' : 'error', 0); + notifications[err.errorType === 'warning' ? 'warning' : 'error'](err.message, { duration: 0 }); } /** @private Convenient access to i18n store value for translations. */ @@ -481,7 +481,7 @@ export class GitManagerModel { await gitService.checkoutBranch(this.dashboardId, branch, this.resolvedEnvId); this.currentBranch = branch; await this.loadWorkspace(); - addToast((this._t?.git as Record)?.feature_flow_opened as string || 'Черновик открыт для доработки', 'success'); + notifications.success((this._t?.git as Record)?.feature_flow_opened as string || 'Черновик открыт для доработки'); } catch (e: unknown) { this._setGitError(e); } finally { @@ -560,7 +560,7 @@ export class GitManagerModel { async validatePreprodDeployment(): Promise { const preprod = this.deploymentStatus?.environments.find((env) => env.stage === 'preprod'); if (!preprod?.content_hash) { - addToast((this._t?.git as Record)?.pipeline_preprod_missing as string || 'Сначала разверните версию в PREPROD', 'warning'); + notifications.warning((this._t?.git as Record)?.pipeline_preprod_missing as string || 'Сначала разверните версию в PREPROD'); return; } this.validatingPreprod = true; @@ -569,7 +569,7 @@ export class GitManagerModel { this.deploymentStatus = await gitService.validatePreprodDeployment( this.dashboardId, this.resolvedEnvId, ); - addToast((this._t?.git as Record)?.pipeline_preprod_validated as string || 'Проверка PREPROD подтверждена', 'success'); + notifications.success((this._t?.git as Record)?.pipeline_preprod_validated as string || 'Проверка PREPROD подтверждена'); log('GitManagerModel.validatePreprodDeployment', 'REFLECT', 'PREPROD validation persisted', { stage: 'preprod' }); } catch (e: unknown) { log('GitManagerModel.validatePreprodDeployment', 'EXPLORE', 'PREPROD validation could not be persisted', { stage: 'preprod' }, e instanceof Error ? e.message : String(e)); @@ -653,7 +653,7 @@ export class GitManagerModel { if (isNumericDashboardRef(this.dashboardId)) { this.checkingStatus = false; this.initialized = false; - addToast((this._t?.git as Record)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); + notifications.error((this._t?.git as Record)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.'); return; } this.checkingStatus = true; @@ -721,7 +721,7 @@ export class GitManagerModel { */ async handleSync(): Promise { if (isNumericDashboardRef(this.dashboardId)) { - addToast((this._t?.git as Record)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); + notifications.error((this._t?.git as Record)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.'); return; } this.clearGitError(); @@ -729,7 +729,7 @@ export class GitManagerModel { try { const sourceEnvId = this.resolvedEnvId || localStorage.getItem('selected_env_id'); await gitService.sync(this.dashboardId, sourceEnvId, this.resolvedEnvId); - addToast((this._t?.git as Record)?.sync_success as string || 'Состояние дашборда синхронизировано с Git', 'success'); + notifications.success((this._t?.git as Record)?.sync_success as string || 'Состояние дашборда синхронизировано с Git'); await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); @@ -756,7 +756,7 @@ export class GitManagerModel { { suppressToast: true }, ); this.commitMessage = data?.message || ''; - addToast((this._t?.git as Record)?.commit_message_generated as string || 'Сообщение для коммита сгенерировано', 'success'); + notifications.success((this._t?.git as Record)?.commit_message_generated as string || 'Сообщение для коммита сгенерировано'); } catch (e: unknown) { this._setGitError(e); } finally { @@ -845,9 +845,9 @@ export class GitManagerModel { await gitService.commit(this.dashboardId, this.commitMessage, [], this.resolvedEnvId); if (this.autoPushAfterCommit) { await gitService.push(this.dashboardId, this.resolvedEnvId); - addToast((this._t?.git as Record)?.commit_and_push_success as string || 'Коммит создан и отправлен в remote', 'success', 8000); + notifications.success((this._t?.git as Record)?.commit_and_push_success as string || 'Коммит создан и отправлен в remote', { duration: 8000 }); } else { - addToast((this._t?.git as Record)?.commit_success as string || 'Коммит успешно создан', 'success', 8000); + notifications.success((this._t?.git as Record)?.commit_success as string || 'Коммит успешно создан', { duration: 8000 }); } this.commitMessage = ''; this.commitCompleted = true; @@ -869,11 +869,11 @@ export class GitManagerModel { */ async handlePromote(): Promise { if (!this.promoteFromBranch || !this.promoteToBranch || this.promoteFromBranch === this.promoteToBranch) { - addToast((this._t?.git as Record)?.promote_branches_must_differ as string || 'Выберите разные исходную и целевую ветки', 'error'); + notifications.error((this._t?.git as Record)?.promote_branches_must_differ as string || 'Выберите разные исходную и целевую ветки'); return; } if (this.promoteMode === 'direct' && !String(this.promoteReason || '').trim()) { - addToast((this._t?.git as Record)?.direct_reason_required as string || 'Для небезопасного прямого переноса укажите причину', 'error'); + notifications.error((this._t?.git as Record)?.direct_reason_required as string || 'Для небезопасного прямого переноса укажите причину'); return; } this.clearGitError(); @@ -892,10 +892,10 @@ export class GitManagerModel { this.resolvedEnvId, ); if (this.promoteMode === 'direct') { - addToast((this._t?.git as Record)?.direct_promote_done as string || 'Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning'); + notifications.warning((this._t?.git as Record)?.direct_promote_done as string || 'Прямой перенос выполнен. Нарушение политики записано в логи.'); } else { if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer'); - addToast((this._t?.git as Record)?.mr_created as string || 'Merge Request создан на Git сервере', 'success'); + notifications.success((this._t?.git as Record)?.mr_created as string || 'Merge Request создан на Git сервере'); } } catch (e: unknown) { this._setGitError(e); @@ -915,7 +915,7 @@ export class GitManagerModel { this.isPulling = true; try { await gitService.pull(this.dashboardId, this.resolvedEnvId); - addToast((this._t?.git as Record)?.pull_success as string || 'Изменения получены из Git', 'success'); + notifications.success((this._t?.git as Record)?.pull_success as string || 'Изменения получены из Git'); await this.loadWorkspace(); void this.loadEnvironmentHistories(); } catch (e: unknown) { @@ -941,7 +941,7 @@ export class GitManagerModel { this.isPushing = true; try { await gitService.push(this.dashboardId, this.resolvedEnvId); - addToast((this._t?.git as Record)?.push_success as string || 'Изменения отправлены в Git', 'success'); + notifications.success((this._t?.git as Record)?.push_success as string || 'Изменения отправлены в Git'); await this.loadWorkspace(); void this.loadEnvironmentHistories(); } catch (e: unknown) { @@ -965,7 +965,7 @@ export class GitManagerModel { this.deployCommitHash = commitHash; if (String(effectiveTargetStage).toUpperCase() === 'PROD') { if (isExplicitProdTarget && !this.canDeployToProd) { - addToast((this._t?.git as Record)?.pipeline_prod_blocked as string || 'PROD доступен только после проверки той же версии в PREPROD', 'warning'); + notifications.warning((this._t?.git as Record)?.pipeline_prod_blocked as string || 'PROD доступен только после проверки той же версии в PREPROD'); return; } this.deployConfirmSlug = String(this.dashboardId); @@ -981,7 +981,7 @@ export class GitManagerModel { */ confirmDeploy(slug: string): void { if (slug.trim() !== this.deployConfirmSlug) { - addToast((this._t?.git as Record)?.deploy_confirm_failed as string || 'Подтверждение PROD не пройдено. Деплой отменен.', 'error'); + notifications.error((this._t?.git as Record)?.deploy_confirm_failed as string || 'Подтверждение PROD не пройдено. Деплой отменен.'); this.showDeployConfirm = false; return; } @@ -998,7 +998,7 @@ export class GitManagerModel { async handleCreateRemoteRepo(): Promise { const config = resolveDefaultConfig(this.configs, this.selectedConfigId); if (!config) { - addToast((this._t?.git as Record)?.init_validation_error as string || 'Сначала выберите Git сервер', 'error'); + notifications.error((this._t?.git as Record)?.init_validation_error as string || 'Сначала выберите Git сервер'); return; } if (!this.selectedConfigId && config.id) this.selectedConfigId = String(config.id); @@ -1028,10 +1028,10 @@ export class GitManagerModel { const url = repo?.clone_url || repo?.html_url || ''; if (!url) throw new Error((this._t?.git as Record)?.repo_url_empty as string || 'Remote repository created, but URL is empty'); this.remoteUrl = url; - addToast(`Repository created on ${config.provider}`, 'success'); + notifications.success(`Repository created on ${config.provider}`); } catch (e: unknown) { if ((e as Record)?.status === 409 && /already exists/i.test(String((e as Error)?.message || ''))) { - addToast((this._t?.git as Record)?.repo_already_exists as string || 'Repository already exists. Enter its URL below and click Init.', 'warning', 0); + notifications.warning((this._t?.git as Record)?.repo_already_exists as string || 'Repository already exists. Enter its URL below and click Init.', { duration: 0 }); } else { this._setGitError(e, 'warning'); } @@ -1048,18 +1048,18 @@ export class GitManagerModel { */ async handleInit(): Promise { if (!this.selectedConfigId || !this.remoteUrl) { - addToast((this._t?.git as Record)?.init_fields_required as string || (this._t?.git as Record)?.init_validation_error as string || 'Заполните все поля', 'error'); + notifications.error((this._t?.git as Record)?.init_fields_required as string || (this._t?.git as Record)?.init_validation_error as string || 'Заполните все поля'); return; } if (!this.resolvedEnvId && !isNumericDashboardRef(this.dashboardId)) { - addToast((this._t?.git as Record)?.env_required_for_init as string || 'Environment must be selected to initialize Git for this dashboard.', 'error'); + notifications.error((this._t?.git as Record)?.env_required_for_init as string || 'Environment must be selected to initialize Git for this dashboard.'); return; } this.clearGitError(); this.loading = true; try { await gitService.initRepository(this.dashboardId, this.selectedConfigId, this.remoteUrl, this.resolvedEnvId); - addToast((this._t?.git as Record)?.init_success as string || 'Репозиторий инициализирован', 'success'); + notifications.success((this._t?.git as Record)?.init_success as string || 'Репозиторий инициализирован'); const provider = resolveDefaultConfig(this.configs, this.selectedConfigId)?.provider || ''; this.initialized = true; this.repositoryProvider = provider; @@ -1137,7 +1137,7 @@ export class GitManagerModel { try { const mc: MergeConflict[] = await gitService.getMergeConflicts(this.dashboardId, this.resolvedEnvId); if (!Array.isArray(mc) || mc.length === 0) { - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.no_conflicts as string || 'No unresolved conflicts were found', 'info'); + notifications.info(((this._t?.git as Record)?.unfinished_merge as Record)?.no_conflicts as string || 'No unresolved conflicts were found'); return; } this.mergeConflicts = mc; @@ -1157,14 +1157,14 @@ export class GitManagerModel { const detail = event?.detail || {}; const resolutions: Array<{ file_path: string; resolution: unknown }> = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r })); if (!resolutions.length) { - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.resolve_empty as string || 'No conflict resolutions selected', 'warning'); + notifications.warning(((this._t?.git as Record)?.unfinished_merge as Record)?.resolve_empty as string || 'No conflict resolutions selected'); return; } this.clearGitError(); this.mergeResolveInProgress = true; try { await gitService.resolveMergeConflicts(this.dashboardId, resolutions, this.resolvedEnvId); - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.resolve_success as string || 'Conflicts were resolved and staged', 'success'); + notifications.success(((this._t?.git as Record)?.unfinished_merge as Record)?.resolve_success as string || 'Conflicts were resolved and staged'); this.showConflictResolver = false; await this.loadMergeRecoveryState(); await this.loadWorkspace(); @@ -1184,7 +1184,7 @@ export class GitManagerModel { this.mergeAbortInProgress = true; try { await gitService.abortMerge(this.dashboardId, this.resolvedEnvId); - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.abort_success as string || 'Merge was aborted', 'success'); + notifications.success(((this._t?.git as Record)?.unfinished_merge as Record)?.abort_success as string || 'Merge was aborted'); this.closeUnfinishedMergeDialog(); await this.loadWorkspace(); } catch (e: unknown) { @@ -1203,7 +1203,7 @@ export class GitManagerModel { this.mergeContinueInProgress = true; try { await gitService.continueMerge(this.dashboardId, '', this.resolvedEnvId); - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.continue_success as string || 'Merge commit created successfully', 'success'); + notifications.success(((this._t?.git as Record)?.unfinished_merge as Record)?.continue_success as string || 'Merge commit created successfully'); this.closeUnfinishedMergeDialog(); await this.loadWorkspace(); } catch (e: unknown) { @@ -1228,15 +1228,15 @@ export class GitManagerModel { async handleCopyUnfinishedMergeCommands(): Promise { const text = this.getUnfinishedMergeCommandsText(); if (!text) { - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_empty as string || 'Команды для копирования отсутствуют', 'warning'); + notifications.warning(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_empty as string || 'Команды для копирования отсутствуют'); return; } this.copyingUnfinishedMergeCommands = true; try { await navigator.clipboard.writeText(text); - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_success as string || 'Команды скопированы в буфер обмена', 'success'); + notifications.success(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_success as string || 'Команды скопированы в буфер обмена'); } catch { - addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_failed as string || 'Не удалось скопировать команды', 'error'); + notifications.error(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_failed as string || 'Не удалось скопировать команды'); } finally { this.copyingUnfinishedMergeCommands = false; } diff --git a/frontend/src/lib/models/GitStatusModel.svelte.ts b/frontend/src/lib/models/GitStatusModel.svelte.ts index 43dbdead6..974204e06 100644 --- a/frontend/src/lib/models/GitStatusModel.svelte.ts +++ b/frontend/src/lib/models/GitStatusModel.svelte.ts @@ -14,7 +14,7 @@ // @REJECTED Merging Git.StatusModel into GitManagerModel rejected — status operations are reused across multiple parent models (DashboardDetailModel needs standalone status+sync for its detail page, GitManagerModel needs it for the workspace panel). A standalone model with clear @RELATION edges makes reusability explicit. Inline status state in each consuming component rejected — would duplicate status atoms and API calls across 4+ components. import { gitService } from '../../services/gitService.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { getT } from '$lib/i18n/index.svelte.js'; // ── Types ───────────────────────────────────────────────────── @@ -258,10 +258,10 @@ export class GitStatusModel { const unstaged: string = await gitService.getDiff(this.dashboardId, null, false, this.envId || null); this.diffPreview = [staged, unstaged].filter(Boolean).join('\n\n'); if (!this.diffPreview) { - addToast((tt().git as Record)?.no_changes || 'No changes detected', 'info'); + notifications.info((tt().git as Record)?.no_changes || 'No changes detected'); } } catch (err: unknown) { - addToast(err instanceof Error ? err.message : 'Failed to load diff', 'error'); + notifications.error(err instanceof Error ? err.message : 'Failed to load diff'); } finally { this.diffLoading = false; } @@ -276,18 +276,18 @@ export class GitStatusModel { */ async syncRepository(): Promise { if (!this.hasGitRepo) { - addToast((tt().git as Record)?.not_linked || 'Repository is not initialized', 'error'); + notifications.error((tt().git as Record)?.not_linked || 'Repository is not initialized'); return false; } if (this.syncing) return false; this.syncing = true; try { await gitService.sync(this.dashboardId, this.envId || null, this.envId || null); - addToast((tt().git as Record)?.sync_success || 'Dashboard state synced to Git', 'success'); + notifications.success((tt().git as Record)?.sync_success || 'Dashboard state synced to Git'); await this.loadStatus(); return true; } catch (err: unknown) { - addToast(err instanceof Error ? err.message : 'Git sync failed', 'error'); + notifications.error(err instanceof Error ? err.message : 'Git sync failed'); return false; } finally { this.syncing = false; @@ -305,10 +305,10 @@ export class GitStatusModel { this.pulling = true; try { await gitService.pull(this.dashboardId, this.envId || null); - addToast((tt().git as Record)?.pull_success || 'Changes pulled from remote', 'success'); + notifications.success((tt().git as Record)?.pull_success || 'Changes pulled from remote'); await this.loadStatus(); } catch (err: unknown) { - addToast(err instanceof Error ? err.message : 'Git pull failed', 'error'); + notifications.error(err instanceof Error ? err.message : 'Git pull failed'); } finally { this.pulling = false; } @@ -325,10 +325,10 @@ export class GitStatusModel { this.pushing = true; try { await gitService.push(this.dashboardId, this.envId || null); - addToast((tt().git as Record)?.push_success || 'Changes pushed to remote', 'success'); + notifications.success((tt().git as Record)?.push_success || 'Changes pushed to remote'); await this.loadStatus(); } catch (err: unknown) { - addToast(err instanceof Error ? err.message : 'Git push failed', 'error'); + notifications.error(err instanceof Error ? err.message : 'Git push failed'); } finally { this.pushing = false; } @@ -347,7 +347,7 @@ export class GitStatusModel { this.gitHistory = await gitService.getHistory(this.dashboardId, limit, this.envId || null); } catch (err: unknown) { this.gitHistory = []; - addToast(err instanceof Error ? err.message : 'Failed to load history', 'error'); + notifications.error(err instanceof Error ? err.message : 'Failed to load history'); } finally { this.historyLoading = false; } diff --git a/frontend/src/lib/models/HealthCenterModel.svelte.ts b/frontend/src/lib/models/HealthCenterModel.svelte.ts index c529517af..ebca29979 100644 --- a/frontend/src/lib/models/HealthCenterModel.svelte.ts +++ b/frontend/src/lib/models/HealthCenterModel.svelte.ts @@ -22,7 +22,7 @@ import { SvelteSet } from 'svelte/reactivity'; import { getHealthSummary, getEnvironments, getConsolidatedSettings, requestApi } from '$lib/api.js'; import { healthStore } from '$lib/stores/health.svelte.js'; import { appTimezone } from '$lib/stores/timezone.svelte.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { log } from '$lib/cot-logger'; // ── Types ──────────────────────────────────────────────────────── @@ -161,7 +161,7 @@ export class HealthCenterModel { await this.loadData(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Unknown'; - addToast(`Failed to delete: ${msg}`, 'error'); + notifications.error(`Failed to delete: ${msg}`); } finally { this.deletingReportIds.delete(item.record_id); } diff --git a/frontend/src/lib/models/TaskCenterModel.svelte.ts b/frontend/src/lib/models/TaskCenterModel.svelte.ts index 5f5e5cda8..d838f5ca1 100644 --- a/frontend/src/lib/models/TaskCenterModel.svelte.ts +++ b/frontend/src/lib/models/TaskCenterModel.svelte.ts @@ -22,7 +22,7 @@ import { replaceState } from '$app/navigation'; import { api } from '$lib/api.js'; import { getTaskEventsWsUrl } from '$lib/api.js'; import { openDrawerForTask } from '$lib/stores/taskDrawer.svelte.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import type { TaskType, ReportStatus, @@ -195,7 +195,7 @@ export class TaskCenterModel { } catch (e: unknown) { this.screenState = 'error'; log('TaskCenter.Model', 'EXPLORE', 'Initial load failed', {}, e instanceof Error ? e.message : 'unknown'); - addToast('Не удалось загрузить данные задач', 'error'); + notifications.error('Не удалось загрузить данные задач'); } } async loadReports(): Promise { @@ -212,7 +212,7 @@ export class TaskCenterModel { } catch (e: unknown) { this.screenState = 'error'; log('TaskCenter.Model', 'EXPLORE', 'Load reports failed', {}, e instanceof Error ? e.message : 'unknown'); - addToast('Не удалось загрузить данные задач', 'error'); + notifications.error('Не удалось загрузить данные задач'); } } diff --git a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts index c96de52bb..cab9791b3 100644 --- a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts +++ b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts @@ -11,7 +11,7 @@ // @RATIONALE Translate.HistoryModel manages the full translation run history lifecycle — paginated list with 5 filter dimensions (job, status, trigger, date range), run-level actions (cancel, retry, download CSVs), detail panel with selectedRunDetail, and aggregated metrics/jobs. The `uxState` FSM (idle → loading → empty → populated → detail_open → pruned) gates which UI sections are active: the detail panel opens only when selectedRun is set, and filters reset pagination to page 1 per the filter-reset invariant. Download actions are model-level to keep the component stateless. // @REJECTED Splitting Translate.HistoryModel into HistoryListModel + HistoryActionsModel rejected — run-level actions (cancel, retry, download) share `selectedRun` and `selectedRunDetail` state; splitting would create synchronization overhead without reducing cognitive load. Component-first state in +page.svelte rejected — the 6-state lifecycle + 5 filter dimensions + pagination would exceed 400 lines and violate INV_7. -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js'; import { fetchAllRuns, fetchRunDetail, fetchAllMetrics, fetchJobs, downloadSkippedCsv, downloadFailedCsv, cancelRun, retryFailedBatches } from '$lib/api/translate.js'; @@ -67,7 +67,7 @@ export class TranslateHistoryModel { this.total = (result as { total?: number })?.total || 0; this.uxState = this.runs.length === 0 ? 'empty' : 'populated'; } catch (err: unknown) { - addToast(err instanceof Error ? err.message : _('translate.history.load_failed'), 'error'); + notifications.error(err instanceof Error ? err.message : _('translate.history.load_failed')); this.uxState = 'empty'; } finally { this.isLoading = false; @@ -104,7 +104,7 @@ export class TranslateHistoryModel { this.selectedRunDetail = await fetchRunDetail(run.id as string); this.uxState = 'detail_open'; } catch (err: unknown) { - addToast(err instanceof Error ? err.message : _('translate.history.load_detail_failed'), 'error'); + notifications.error(err instanceof Error ? err.message : _('translate.history.load_detail_failed')); } } @@ -125,11 +125,11 @@ export class TranslateHistoryModel { this.cancellingRunId = runId; try { await cancelRun(runId); - addToast(getT()?.translate?.run?.run_cancelled || 'Run cancelled', 'success'); + notifications.success(getT()?.translate?.run?.run_cancelled || 'Run cancelled'); if (this.selectedRunDetail?.id === runId) this.selectedRunDetail = await fetchRunDetail(runId); this.loadRuns(); } catch (err: unknown) { - addToast(err instanceof Error ? err.message : (getT()?.translate?.run?.cancel_failed || 'Failed to cancel run'), 'error'); + notifications.error(err instanceof Error ? err.message : (getT()?.translate?.run?.cancel_failed || 'Failed to cancel run')); } finally { this.cancellingRunId = null; } } @@ -137,20 +137,20 @@ export class TranslateHistoryModel { this.retryingRunId = runId; try { await retryFailedBatches(runId); - addToast(getT()?.translate?.run?.retry_success || 'Retry started', 'success'); + notifications.success(getT()?.translate?.run?.retry_success || 'Retry started'); if (this.selectedRunDetail?.id === runId) this.selectedRunDetail = await fetchRunDetail(runId); this.loadRuns(); } catch (err: unknown) { - addToast(err instanceof Error ? err.message : (getT()?.translate?.run?.retry_failed_msg || 'Retry failed'), 'error'); + notifications.error(err instanceof Error ? err.message : (getT()?.translate?.run?.retry_failed_msg || 'Retry failed')); } finally { this.retryingRunId = null; } } async handleDownloadSkipped(runId: string): Promise { - try { await downloadSkippedCsv(runId); } catch (err: unknown) { addToast(err instanceof Error ? err.message : _('translate.history.download_failed'), 'error'); } + try { await downloadSkippedCsv(runId); } catch (err: unknown) { notifications.error(err instanceof Error ? err.message : _('translate.history.download_failed')); } } async handleDownloadFailed(runId: string): Promise { - try { await downloadFailedCsv(runId); } catch (err: unknown) { addToast(err instanceof Error ? err.message : _('translate.history.download_failed'), 'error'); } + try { await downloadFailedCsv(runId); } catch (err: unknown) { notifications.error(err instanceof Error ? err.message : _('translate.history.download_failed')); } } // ── Utilities ───────────────────────────────────────────────── diff --git a/frontend/src/lib/models/TranslationJobModel.svelte.ts b/frontend/src/lib/models/TranslationJobModel.svelte.ts index 383c10ddf..e84d12628 100644 --- a/frontend/src/lib/models/TranslationJobModel.svelte.ts +++ b/frontend/src/lib/models/TranslationJobModel.svelte.ts @@ -19,10 +19,11 @@ // @REJECTED Converting methods to arrow class fields rejected — it would conflict with the Svelte 5 // `$state` rune initialization order for non-primitive state atoms in the constructor. import { api } from '$lib/api.js'; -import { addToast } from '$lib/toasts.svelte.js'; +import { notifications } from '$lib/toasts.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js'; -import { triggerRun, fetchRunHistory, cancelRun, fetchDatasourceColumns, fetchDatasources } from '$lib/api/translate.js'; -import { startTranslationRun, resetTranslationRun, translationRunStore } from '$lib/stores/translationRun.svelte.js'; +import { calculateRunPreflight, triggerRun, fetchRunHistory, cancelRun, fetchDatasourceColumns, fetchDatasources } from '$lib/api/translate.js'; +import type { RunPreflightResponse } from '$lib/api/translate/runs'; +import { getStoredActiveRun, reconnectToRun, startTranslationRun, resetTranslationRun, translationRunStore } from '$lib/stores/translationRun.svelte.js'; type UxState = 'idle' | 'loading' | 'configured' | 'saving' | 'validation_error' | 'datasource_unavailable'; @@ -114,8 +115,8 @@ export class TranslationJobModel { { key: 'targetLanguages', label: 'Target languages', ok: this.targetLanguages.length > 0, required: true, message: 'Select at least one target language', tab: 'config' }, { key: 'provider', label: 'LLM provider', ok: !!this.providerId, required: true, message: 'Select an LLM provider', tab: 'config' }, { key: 'targetSchema', label: 'Target schema', ok: !!this.targetSchema, required: false, message: 'Target schema not set — insert may fail', tab: 'target' }, - { key: 'targetTable', label: 'Target table', ok: !!this.targetTable, required: false, message: 'Target table not set — insert may fail', tab: 'target' }, - { key: 'schemaValidated', label: 'Target schema validated', ok: this.schemaValidated, required: false, message: 'Validate target schema before first run', tab: 'target' }, + { key: 'targetTable', label: 'Target table', ok: !!this.targetTable, required: true, message: 'Enter a target table', tab: 'target' }, + { key: 'schemaValidated', label: 'Target schema validated', ok: this.schemaValidated, required: true, message: 'Validate target schema before first run', tab: 'target' }, ]; if (this.insertMethod === 'direct_db') { items.push({ key: 'connectionId', label: 'DB connection', ok: !!this.connectionId, required: true, message: 'Select a Direct DB connection', tab: 'target' }); @@ -145,7 +146,16 @@ export class TranslationJobModel { }); // ── Run state ───────────────────────────────────────────────── - currentRunId = $derived(translationRunStore.value?.runId || null); + currentRunId = $derived.by(() => { + const state = translationRunStore.value; + return state?.runId && (!state.jobId || state.jobId === this.jobId) ? state.runId : null; + }); + activeRunId = $derived.by(() => { + const state = translationRunStore.value; + const active = state?.uxState === 'running' || state?.uxState === 'inserting'; + return active && (!state.jobId || state.jobId === this.jobId) ? (state.runId || null) : null; + }); + isRunActive = $derived(this.isRunning || !!this.activeRunId); completedRuns: Record[] = $state([]); expandedRunIds: string[] = $state([]); runHistoryPage: number = $state(1); @@ -156,29 +166,37 @@ export class TranslationJobModel { showPageBulkReplace: boolean = $state(false); runComplete: boolean = $state(false); schemaValidated: boolean = $state(false); + runPreflight: RunPreflightResponse | null = $state(null); + preflightLoading: boolean = $state(false); + preflightError: string | null = $state(null); // ── Actions: Run ───────────────────────────────────────────── async handleTriggerRun(full = false): Promise { + if (this.isRunActive) { + this.runError = _('translate.run.disabled_running'); + return; + } this.runError = ''; // Auto-save any unsaved config changes before triggering a run - if (!this.isNewJob) { - try { - await this.saveJob(); - } catch (err: unknown) { - this.runError = err instanceof Error ? err.message : _('translate.config.run_failed'); + if (!this.isNewJob && this.isDirty) { + const saved = await this.saveJob(); + if (!saved) { + this.runError = this.error || _('translate.config.run_failed'); this.isRunning = false; - addToast(this.runError, 'error'); - return; // don't proceed if auto-save fails + return; } } this.isRunning = true; this.runComplete = false; this.isFullRun = full; try { - const run = await triggerRun(this.jobId, full); + const languageDetection = this.runPreflight?.full_translation === full + ? this.runPreflight.recommended_language_detection + : 'auto'; + const run = await triggerRun(this.jobId, full, languageDetection); startTranslationRun(run.id, { jobId: this.jobId, isFullRun: full, onComplete: this._onRunComplete.bind(this) }); - addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success'); + notifications.success(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started')); } catch (err: unknown) { this.runError = err instanceof Error ? err.message : _('translate.config.run_failed'); this.isRunning = false; @@ -192,7 +210,7 @@ export class TranslationJobModel { this.runComplete = true; this.expandedRunIds = []; if (statusData?.status !== 'CANCELLED') this.loadRunHistory(); - addToast(`${_('translate.run.run_id')} ${finishedRunId}`, 'info'); + notifications.info(`${_('translate.run.run_id')} ${finishedRunId}`); } async loadRunHistory(append = false): Promise { @@ -211,6 +229,13 @@ export class TranslationJobModel { this.completedRuns = items; this.runHistoryPage = 1; } + const storedRun = getStoredActiveRun(); + const activeRun = this.completedRuns.find((run) => run.status === 'PENDING' || run.status === 'RUNNING') + || (storedRun?.jobId === this.jobId ? { id: storedRun.runId } : null); + if (activeRun && this.jobId) { + this.isRunning = true; + reconnectToRun(String(activeRun.id), { jobId: this.jobId }); + } // Auto-expand failed or partial runs so the user immediately sees the error details const failedRunIds = this.completedRuns .filter((run: Record) => run.status === 'FAILED' || (run.status === 'COMPLETED' && (run.failed_records || 0) > 0)) @@ -221,7 +246,17 @@ export class TranslationJobModel { ...failedRunIds, ]), ]; - } catch { this.completedRuns = []; this.expandedRunIds = []; this.runHistoryHasMore = false; this.runHistoryPage = 1; } + } catch { + this.completedRuns = []; + this.expandedRunIds = []; + this.runHistoryHasMore = false; + this.runHistoryPage = 1; + const storedRun = getStoredActiveRun(); + if (storedRun?.jobId === this.jobId) { + this.isRunning = true; + reconnectToRun(storedRun.runId, { jobId: this.jobId, isFullRun: storedRun.isFullRun }); + } + } } toggleRunDetails(runId: string): void { @@ -236,12 +271,29 @@ export class TranslationJobModel { await this.handleTriggerRun(); } + async calculateRunPreflight(full = false): Promise { + if (this.isNewJob || !this.jobId) return; + this.preflightLoading = true; + this.preflightError = null; + try { + if (this.isDirty && !(await this.saveJob())) return; + this.runPreflight = await calculateRunPreflight(this.jobId, full); + notifications.success(getT()?.translate?.run?.preflight_ready || 'Run scope calculated'); + } catch (err: unknown) { + this.runPreflight = null; + this.preflightError = err instanceof Error ? err.message : 'Failed to calculate run scope'; + notifications.error(this.preflightError); + } finally { + this.preflightLoading = false; + } + } + async handleRetryInsert(): Promise { if (this.currentRunId) { try { await api.postApi(`/translate/runs/${this.currentRunId}/retry-insert`, {}); - addToast(_('translate.config.insert_retry_started'), 'success'); - } catch (err: unknown) { addToast(err instanceof Error ? err.message : _('translate.config.insert_retry_failed'), 'error'); } + notifications.success(_('translate.config.insert_retry_started')); + } catch (err: unknown) { notifications.error(err instanceof Error ? err.message : _('translate.config.insert_retry_failed')); } } } @@ -359,12 +411,19 @@ export class TranslationJobModel { markDirty(): void { this.isDirty = true; + this.runPreflight = null; + this.preflightError = null; } - async saveJob(): Promise { + invalidateSchemaValidation(): void { + this.schemaValidated = false; + this.markDirty(); + } + + async saveJob(): Promise { this.uxState = 'saving'; this.validationErrors = {}; - this.isDirty = false; + this.isSaving = true; try { if (this.runReady && this.status === 'DRAFT') { this.status = 'READY'; @@ -408,8 +467,10 @@ export class TranslationJobModel { this.isNewJob = false; this.existingJob = { id: resp.id, ...payload }; } - addToast(getT()?.translate?.config?.saved || 'Job saved', 'success'); + notifications.success(getT()?.translate?.config?.saved || 'Job saved'); this.uxState = 'configured'; + this.isDirty = false; + return true; } catch (err: unknown) { this.error = err instanceof Error ? err.message : 'Failed to save'; // Parse structured Pydantic 422 errors into per-field validationErrors @@ -444,8 +505,12 @@ export class TranslationJobModel { } } } - addToast(this.error, 'error'); + notifications.error(this.error); this.uxState = 'validation_error'; + this.isDirty = true; + return false; + } finally { + this.isSaving = false; } } } diff --git a/frontend/src/lib/models/TranslationRunResultModel.svelte.ts b/frontend/src/lib/models/TranslationRunResultModel.svelte.ts index 14e1ba2df..6d3df2fc0 100644 --- a/frontend/src/lib/models/TranslationRunResultModel.svelte.ts +++ b/frontend/src/lib/models/TranslationRunResultModel.svelte.ts @@ -13,12 +13,12 @@ // @RELATION CALLS -> [retryInsert] // @DATA_CONTRACT Input: runId → Output: status: RunStatus, records: Record[], derived badges // @POST All data fetched on runId change. Retry actions reload data and signal parent via onRefresh callback. -// @SIDE_EFFECT API fetch (status, records, batches, retry), toast notifications via addToast. +// @SIDE_EFFECT API fetch (status, records, batches, retry), toast notifications via notifications. // @RATIONALE TranslationRunResult.svelte (623 LOC → ~350 LOC after extraction) violated INV_7. State management (16 $state atoms, 6 API calls, 3 $derived projections) was scattered across the component's {#if show} -
+