security: fullstack hardening — task ownership, mapping validation, API-key scoping, test fixes

Backend:
- Add validate_mapping_database_ownership() to verify source/target UUIDs
  belong to declared environments before persisting mappings (mappings.py)
- Add API-key environment scoping to get_mappings (filter) and
  suggest_mappings_api (enforce) (mappings.py)
- Add user_id Column to TaskRecord model + Alembic migration (task.py)
- Persist task.user_id on save, restore on load (persistence.py)
- Wire current_user.id into migrate_dashboards + backup_dashboards
  task creation (_action_routes.py)
- Fix test_migration_routes.py: module-level patch leak → autouse fixture,
  SupersetClient→AsyncSupersetClient, AsyncMock for sync_environment/run
- Fix 7 Pydantic serializer warnings: 'PENDING'→TaskStatus.PENDING
  in test_tasks.py + import TaskStatus

Frontend:
- Deepen isDryRunResult(): validate selection field, risk.items entries
  (all 5 fields), and diff object uuids individually (ExecutorModel.svelte.ts)

Prior work included: task password redaction, resume ownership checks,
canonical dry-run DTO alignment, migration UI callback fixes, credential
exposure reduction, assistant dry-run await fix.
This commit is contained in:
2026-07-15 23:02:23 +03:00
parent 30c8acf7ae
commit 20071b8c7a
68 changed files with 3845 additions and 376 deletions

View File

@@ -119,9 +119,22 @@ function shouldSuppressApiErrorToast(endpoint: string, error: ApiError): boolean
// #region wsUrlHelpers [C:2] [TYPE Block] [SEMANTICS websocket, url, task-logs, maintenance, translate]
// @BRIEF WebSocket URL builders — each constructs an authenticated WS endpoint for a specific channel.
// @PRE taskId / runId are non-empty strings where applicable.
// @POST Returns fully-qualified ws:// or wss:// URL with auth token as query parameter.
// @POST Returns fully-qualified ws:// or wss:// URL with auth and x-trace-id query parameters.
// @SIDE_EFFECT Reads localStorage for auth_token on each call.
// @RATIONALE WebSocket API does not support custom headers in browser, so auth token is appended as query param.
// @RATIONALE WebSocket API does not support custom headers in browser, so auth and trace IDs are
// appended as query parameters for authentication and cross-stack correlation.
function _appendWsCredentials(url: string): string {
const params = new URLSearchParams();
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) params.set('token', token);
const traceId = getTraceId();
if (traceId && traceId !== 'no-trace') params.set('x-trace-id', traceId);
}
const query = params.toString();
return query ? `${url}?${query}` : url;
}
/**
* Build a WebSocket URL for a task's log stream, including auth token.
@@ -129,14 +142,7 @@ function shouldSuppressApiErrorToast(endpoint: string, error: ApiError): boolean
export const getWsUrl = (taskId: string): string => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/logs/${taskId}`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}
}
return url;
return _appendWsCredentials(`${protocol}//${host}/ws/logs/${taskId}`);
};
/**
@@ -145,14 +151,7 @@ export const getWsUrl = (taskId: string): string => {
export const getTaskEventsWsUrl = (): string => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/task-events`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}
}
return url;
return _appendWsCredentials(`${protocol}//${host}/ws/task-events`);
};
/**
@@ -162,14 +161,7 @@ export const getTaskEventsWsUrl = (): string => {
export const getMaintenanceEventsWsUrl = (): string => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/maintenance/events`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}
}
return url;
return _appendWsCredentials(`${protocol}//${host}/ws/maintenance/events`);
};
// #endregion getMaintenanceEventsWsUrl
@@ -180,14 +172,7 @@ export const getMaintenanceEventsWsUrl = (): string => {
export const getTranslateRunWsUrl = (runId: string): string => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/translate/run/${runId}`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}
}
return url;
return _appendWsCredentials(`${protocol}//${host}/ws/translate/run/${runId}`);
};
// #endregion getTranslateRunWsUrl
// #endregion wsUrlHelpers
@@ -281,14 +266,18 @@ async function fetchApi<T = unknown>(endpoint: string, options: FetchOptions = {
// @PRE endpoint is a non-empty string path.
// @POST Returns Promise<Blob> with binary data. Throws ApiError on failure or 202 "in progress".
// @SIDE_EFFECT Sends HTTP GET request. On failure (unless notifyError=false), dispatches error toast.
// Writes CoT log line on entry, success, and failure.
// @RELATION DEPENDS_ON -> [buildApiError]
// @RELATION DEPENDS_ON -> [notifyApiError]
// @RELATION DEPENDS_ON -> [getAuthHeaders]
// @RATIONALE 202 status is handled as a special case — the thumbnail generation may still be in progress.
// The caller can retry after a delay. This is NOT treated as a server error.
async function fetchApiBlob(endpoint: string, options: FetchOptions = {}): Promise<Blob> {
const _start = performance.now();
const notifyError = options.notifyError !== false;
const _silent = _isSilentPolling(endpoint);
try {
if (!_silent) log('ApiClient', 'REASON', 'GET blob', { endpoint });
const fetchInit: RequestInit = { headers: getAuthHeaders(options.headers || {}) };
if (options.signal) fetchInit.signal = options.signal;
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
@@ -299,9 +288,15 @@ async function fetchApiBlob(endpoint: string, options: FetchOptions = {}): Promi
throw error;
}
if (!response.ok) throw await buildApiError(response);
_captureTraceId(response);
if (!_silent) log('ApiClient', 'REFLECT', 'GET blob completed', {
endpoint, status: response.status,
elapsed_ms: Math.round(performance.now() - _start),
});
return await response.blob();
} catch (error) {
const apiError = error as ApiError;
log('ApiClient', 'EXPLORE', 'GET blob failed', { endpoint, status: apiError?.status }, apiError?.message || 'unknown');
if (notifyError) notifyApiError(apiError);
throw error;
}

View File

@@ -7,22 +7,7 @@
// @TEST_CONTRACT: postApi -> Sends POST with JSON body, handles errors
// @TEST_CONTRACT: deleteApi -> Sends DELETE, always notifies on error
// @TEST_CONTRACT: requestApi -> Generic method with suppression heuristics
// @TEST_CONTRACT: wsUrl helpers -> Build correct ws:// URL with auth token
// @TEST_EDGE: network_failure -> fetch throws, error toast dispatched
// @TEST_EDGE: auth_token -> localStorage token injected as Bearer header
// @TEST_EDGE: suppress_toast -> Options.suppressToast suppresses error toast
// @TEST_EDGE: api_methods -> Registry methods call correct endpoints
// #region ApiModuleTest [C:3] [TYPE Module] [SEMANTICS test, api, fetch, ws-url]
// @BRIEF Unit tests for the core API communication layer — fetch wrappers, WebSocket URL builders,
// endpoint registry, error normalization, and toast suppression.
// @LAYER Tests
// @RELATION BINDS_TO -> [ApiModule]
// @TEST_CONTRACT: fetchApi -> Returns typed JSON for 2xx, null for 204, throws ApiError on error
// @TEST_CONTRACT: postApi -> Sends POST with JSON body, handles errors
// @TEST_CONTRACT: deleteApi -> Sends DELETE, always notifies on error
// @TEST_CONTRACT: requestApi -> Generic method with suppression heuristics
// @TEST_CONTRACT: wsUrl helpers -> Build correct ws:// URL with auth token
// @TEST_CONTRACT: wsUrl helpers -> Build correct ws:// URL with auth token and trace ID
// @TEST_EDGE: network_failure -> fetch throws, error toast dispatched
// @TEST_EDGE: auth_token -> localStorage token injected as Bearer header
// @TEST_EDGE: suppress_toast -> Options.suppressToast suppresses error toast
@@ -65,34 +50,34 @@ describe('ApiModule — wsUrl helpers', () => {
localStorage.setItem('auth_token', 'my-token-123');
const { getWsUrl } = await import('$lib/api.js');
const url = getWsUrl('task-42');
expect(url).toBe('ws://localhost:5173/ws/logs/task-42?token=my-token-123');
expect(url).toBe('ws://localhost:5173/ws/logs/task-42?token=my-token-123&x-trace-id=test-trace-id');
});
it('getWsUrl returns ws:// URL without token when auth_token missing', async () => {
const { getWsUrl } = await import('$lib/api.js');
const url = getWsUrl('task-99');
expect(url).toBe('ws://localhost:5173/ws/logs/task-99');
expect(url).toBe('ws://localhost:5173/ws/logs/task-99?x-trace-id=test-trace-id');
});
it('getTaskEventsWsUrl builds correct URL with token', async () => {
localStorage.setItem('auth_token', 'tok');
const { getTaskEventsWsUrl } = await import('$lib/api.js');
const url = getTaskEventsWsUrl();
expect(url).toBe('ws://localhost:5173/ws/task-events?token=tok');
expect(url).toBe('ws://localhost:5173/ws/task-events?token=tok&x-trace-id=test-trace-id');
});
it('getMaintenanceEventsWsUrl builds correct URL', async () => {
localStorage.setItem('auth_token', 'maint-tok');
const { getMaintenanceEventsWsUrl } = await import('$lib/api.js');
const url = getMaintenanceEventsWsUrl();
expect(url).toBe('ws://localhost:5173/ws/maintenance/events?token=maint-tok');
expect(url).toBe('ws://localhost:5173/ws/maintenance/events?token=maint-tok&x-trace-id=test-trace-id');
});
it('getTranslateRunWsUrl builds correct URL with runId', async () => {
localStorage.setItem('auth_token', 'run-tok');
const { getTranslateRunWsUrl } = await import('$lib/api.js');
const url = getTranslateRunWsUrl('run-1');
expect(url).toBe('ws://localhost:5173/ws/translate/run/run-1?token=run-tok');
expect(url).toBe('ws://localhost:5173/ws/translate/run/run-1?token=run-tok&x-trace-id=test-trace-id');
});
it('getWsUrl uses wss:// when window.location.protocol is https:', async () => {
@@ -102,7 +87,7 @@ describe('ApiModule — wsUrl helpers', () => {
localStorage.setItem('auth_token', 'sec-tok');
const { getWsUrl } = await import('$lib/api.js');
const url = getWsUrl('task-secure');
expect(url).toBe('wss://app.example.com/ws/logs/task-secure?token=sec-tok');
expect(url).toBe('wss://app.example.com/ws/logs/task-secure?token=sec-tok&x-trace-id=test-trace-id');
});
});
@@ -785,6 +770,146 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => {
});
});
// #endregion captureTraceIdTests
// #region fetchApiBlobTraceTests [C:2] [TYPE Test] [SEMANTICS test,api,blob,trace-id,cot]
// @BRIEF Verify fetchApiBlob captures x-trace-id and logs CoT markers on success/failure.
describe('fetchApiBlob — trace propagation & CoT logging', () => {
const testUuid = 'a1b2c3d4e5f67890abcdef1234567890';
beforeEach(() => {
vi.clearAllMocks();
});
it('captures x-trace-id from response headers', async () => {
const blob = new Blob(['img'], { type: 'image/png' });
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
blob: () => Promise.resolve(blob),
headers: {
get: vi.fn((name: string) => name === 'x-trace-id' ? testUuid : null),
},
} as unknown as Response);
const cotLogger = await import('$lib/cot-logger.js');
cotLogger.setTraceId.mockClear();
const { api } = await import('$lib/api.js');
await api.fetchApiBlob('/thumbnail');
expect(cotLogger.setTraceId).toHaveBeenCalledWith(testUuid);
});
it('handles missing x-trace-id header gracefully', async () => {
const blob = new Blob(['img'], { type: 'image/png' });
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
blob: () => Promise.resolve(blob),
headers: { get: vi.fn(() => null) },
} as unknown as Response);
const cotLogger = await import('$lib/cot-logger.js');
cotLogger.setTraceId.mockClear();
const { api } = await import('$lib/api.js');
await api.fetchApiBlob('/no-trace');
expect(cotLogger.setTraceId).not.toHaveBeenCalled();
});
it('logs REASON then REFLECT on success with elapsed_ms', async () => {
const blob = new Blob(['data'], { type: 'text/plain' });
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
blob: () => Promise.resolve(blob),
headers: { get: vi.fn(() => null) },
} as unknown as Response);
const cotLogger = await import('$lib/cot-logger.js');
cotLogger.log.mockClear();
const { api } = await import('$lib/api.js');
await api.fetchApiBlob('/file');
const calls = cotLogger.log.mock.calls;
expect(calls.length).toBeGreaterThanOrEqual(2);
const reasonCall = calls.find((c: unknown[]) => c[1] === 'REASON');
expect(reasonCall).toBeTruthy();
expect(reasonCall[0]).toBe('ApiClient');
expect(reasonCall[2]).toBe('GET blob');
expect(reasonCall[3]).toEqual({ endpoint: '/file' });
const reflectCall = calls.find((c: unknown[]) => c[1] === 'REFLECT');
expect(reflectCall).toBeTruthy();
expect(reflectCall[0]).toBe('ApiClient');
expect(reflectCall[2]).toBe('GET blob completed');
expect(reflectCall[3]).toMatchObject({ endpoint: '/file', status: 200 });
expect(typeof reflectCall[3]?.elapsed_ms).toBe('number');
});
it('logs EXPLORE on 500 with status in payload and error message', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ detail: 'Internal server error' }),
} as Response);
const cotLogger = await import('$lib/cot-logger.js');
cotLogger.log.mockClear();
const { api } = await import('$lib/api.js');
await expect(api.fetchApiBlob('/fail')).rejects.toMatchObject({ status: 500 });
const exploreCall = cotLogger.log.mock.calls.find((c: unknown[]) => c[1] === 'EXPLORE');
expect(exploreCall).toBeTruthy();
expect(exploreCall[0]).toBe('ApiClient');
expect(exploreCall[2]).toBe('GET blob failed');
expect(exploreCall[3]).toMatchObject({ endpoint: '/fail', status: 500 });
expect(exploreCall[4]).toMatch(/Internal server error/);
});
it('logs EXPLORE on 202 (resource being prepared)', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 202,
json: () => Promise.resolve({ message: 'Still generating' }),
} as Response);
const cotLogger = await import('$lib/cot-logger.js');
cotLogger.log.mockClear();
const { api } = await import('$lib/api.js');
await expect(api.fetchApiBlob('/pending')).rejects.toMatchObject({ status: 202 });
const exploreCall = cotLogger.log.mock.calls.find((c: unknown[]) => c[1] === 'EXPLORE');
expect(exploreCall).toBeTruthy();
expect(exploreCall[2]).toBe('GET blob failed');
// 202 is an error case for blob — status in payload
expect(exploreCall[3]).toMatchObject({ endpoint: '/pending', status: 202 });
});
it('suppresses CoT REASON/REFLECT for silent polling endpoints, still logs EXPLORE on failure', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ detail: 'boom' }),
} as Response);
const cotLogger = await import('$lib/cot-logger.js');
cotLogger.log.mockClear();
const { api } = await import('$lib/api.js');
await expect(api.fetchApiBlob('/health/summary')).rejects.toMatchObject({ status: 500 });
const calls = cotLogger.log.mock.calls;
const reasonCall = calls.find((c: unknown[]) => c[1] === 'REASON');
expect(reasonCall).toBeUndefined();
const exploreCall = calls.find((c: unknown[]) => c[1] === 'EXPLORE');
expect(exploreCall).toBeTruthy();
});
});
// #endregion fetchApiBlobTraceTests
});
describe('ApiModule — deleteValidationTask query param', () => {

View File

@@ -2,6 +2,12 @@
<!-- @ingroup UI -->
<!-- @BRIEF Displays and allows editing of database mappings. -->
<!-- @LAYER UI -->
<!-- @UX_STATE Suggested -> Preselects the recommendation and exposes its confidence as an editable draft. -->
<!-- @UX_STATE Saved -> Shows the persisted target database; a user-selected alternative replaces the suggestion and is labelled as manual. -->
<!-- @UX_STATE Unmapped -> Requires an explicit target database selection before replacement can use this source database. -->
<!-- @UX_FEEDBACK Choosing a target invokes the parent persistence callback and changes status to Saved after success. -->
<!-- @UX_RECOVERY The user can select any target database again to overwrite a saved or suggested mapping. -->
<!-- @UX_TEST Suggested -> {select: non-recommended target, expected: Saved status and selected alternative remains visible}. -->
<!--
@SEMANTICS: mapping, table, database, editor
@PURPOSE: Displays and allows editing of database mappings.
@@ -11,20 +17,37 @@
@INVARIANT: Each source database can be mapped to one target database.
-->
<script lang="ts">
<script lang="ts">
// [SECTION: IMPORTS]
import { t } from '$lib/i18n/index.svelte.js';
import { HelpTooltip } from '$lib/ui';
// [/SECTION]
// [SECTION: PROPS]
let {
sourceDatabases = [],
targetDatabases = [],
mappings = [],
suggestions = [],
onupdate = () => {},
} = $props();
type Database = { uuid: string; database_name: string; engine?: string };
type Mapping = { source_db_uuid?: string; target_db_uuid?: string };
type Suggestion = { source_db_uuid: string; target_db_uuid: string; confidence: number };
type MappingUpdate = {
sourceUuid: string;
targetUuid: string;
sourceName: string;
targetName: string;
engine: string;
};
let {
sourceDatabases = [],
targetDatabases = [],
mappings = [],
suggestions = [],
onupdate = () => {},
}: {
sourceDatabases?: Database[];
targetDatabases?: Database[];
mappings?: Mapping[];
suggestions?: Suggestion[];
onupdate?: (_update: MappingUpdate) => void;
} = $props();
// [/SECTION]
@@ -35,9 +58,11 @@
* @pre sourceUuid and targetUuid are provided.
* @post Parent callback receives normalized mapping payload.
*/
function updateMapping(sourceUuid: string, targetUuid: string) {
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
function updateMapping(sourceUuid: string, targetUuid: string) {
if (!targetUuid) return;
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
if (!sDb || !tDb) return;
onupdate({
sourceUuid,
@@ -56,7 +81,7 @@
* @pre sourceUuid is provided.
* @post Returns matching suggestion object or undefined.
*/
function getSuggestion(sourceUuid: string) {
function getSuggestion(sourceUuid: string): Suggestion | undefined {
return suggestions.find(s => s.source_db_uuid === sourceUuid);
}
// #endregion getSuggestion:Function
@@ -78,7 +103,7 @@
</tr>
</thead>
<tbody class="bg-surface-card divide-y divide-border">
{#each sourceDatabases as sDb}
{#each sourceDatabases as sDb (sDb.uuid)}
{@const mapping = mappings.find(m => m.source_db_uuid === sDb.uuid)}
{@const suggestion = getSuggestion(sDb.uuid)}
<tr class={suggestion && !mapping ? 'bg-success-light' : ''}>
@@ -87,23 +112,31 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">
<select
aria-label={`${$t.dashboard?.target_database ?? "Target database"}: ${sDb.database_name}`}
class="block w-full pl-3 pr-10 py-2 text-base border-border-strong focus:outline-none focus:ring-primary-ring focus:border-primary-ring sm:text-sm rounded-md"
value={mapping?.target_db_uuid || suggestion?.target_db_uuid || ""}
onchange={(e) => updateMapping(sDb.uuid, (e.target as HTMLSelectElement).value)}
>
<option value="">{$t.migration?.target_env }</option>
{#each targetDatabases as tDb}
<option value="">{$t.migration?.select_target_database ?? "Select target database"}</option>
{#each targetDatabases as tDb (tDb.uuid)}
<option value={tDb.uuid}>{tDb.database_name}</option>
{/each}
</select>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">
{#if mapping}
<span class="text-primary font-semibold">{$t.dashboard?.saved }</span>
<span class="text-primary font-semibold">
{$t.dashboard?.saved ?? "Saved"}
{#if suggestion && mapping.target_db_uuid !== suggestion.target_db_uuid}
<span class="ml-1 text-text-muted font-normal">({$t.migration?.mapping_custom_selection ?? "Selected manually"})</span>
{/if}
</span>
{:else if suggestion}
<span class="text-success font-semibold">{$t.dashboard?.suggested } ({Math.round(suggestion.confidence * 100)}%)</span>
<span class="text-success font-semibold">
{$t.dashboard?.suggested ?? "Suggested"} ({Math.round(suggestion.confidence * 100)}%)
</span>
{:else}
<span class="text-destructive">{$t.dashboard?.not_mapped }</span>
<span class="text-destructive">{$t.dashboard?.not_mapped ?? "Not mapped"}</span>
{/if}
</td>
</tr>

View File

@@ -0,0 +1,97 @@
// #region Test.MappingTable [C:3] [TYPE Module] [SEMANTICS test,mapping,database,ux]
// @BRIEF Verify database mapping selection, recommendation, and manual-selection UX.
// @RELATION BINDS_TO -> [MappingTable]
// @TEST_SCENARIO: suggested_mapping -> Recommended target is selected and confidence is shown.
// @TEST_SCENARIO: manual_mapping -> Selecting another target emits a normalized plain object.
// @TEST_SCENARIO: unmapped_database -> Empty target remains unmapped and does not emit a save.
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import MappingTable from '../MappingTable.svelte';
vi.mock('$lib/i18n/index.svelte.js', () => ({
t: {
subscribe(run: (value: Record<string, Record<string, string>>) => void) {
run({
dashboard: {
source_database: 'Source database',
target_database: 'Target database',
status: 'Status',
saved: 'Saved',
suggested: 'Suggested',
not_mapped: 'Not mapped',
},
migration: {
help_mapping_status: 'Mapping status',
select_target_database: 'Select target database',
mapping_custom_selection: 'Selected manually',
},
});
return () => {};
},
},
}));
const sourceDatabases = [
{ uuid: 'source-1', database_name: 'Source DB', engine: 'postgresql' },
];
const targetDatabases = [
{ uuid: 'target-recommended', database_name: 'Recommended DB', engine: 'postgresql' },
{ uuid: 'target-manual', database_name: 'Manual DB', engine: 'postgresql' },
];
const suggestions = [
{ source_db_uuid: 'source-1', target_db_uuid: 'target-recommended', confidence: 0.93 },
];
describe('MappingTable', () => {
it('renders a recommendation and its confidence', () => {
render(MappingTable, { sourceDatabases, targetDatabases, suggestions });
const select = screen.getByRole('combobox', { name: 'Target database: Source DB' }) as HTMLSelectElement;
expect(select.value).toBe('target-recommended');
expect(screen.getByText('Suggested (93%)')).toBeTruthy();
});
it('emits a normalized plain object for a manually selected target', async () => {
const onupdate = vi.fn();
render(MappingTable, { sourceDatabases, targetDatabases, suggestions, onupdate });
const select = screen.getByRole('combobox', { name: 'Target database: Source DB' });
await fireEvent.change(select, { target: { value: 'target-manual' } });
expect(onupdate).toHaveBeenCalledWith({
sourceUuid: 'source-1',
targetUuid: 'target-manual',
sourceName: 'Source DB',
targetName: 'Manual DB',
engine: 'postgresql',
});
});
it('labels a saved alternative as manually selected', () => {
render(MappingTable, {
sourceDatabases,
targetDatabases,
suggestions,
mappings: [{ source_db_uuid: 'source-1', target_db_uuid: 'target-manual' }],
});
expect(screen.getByText('Saved')).toBeTruthy();
expect(screen.getByText('(Selected manually)')).toBeTruthy();
});
it('does not save an unmapped empty selection', async () => {
const onupdate = vi.fn();
render(MappingTable, { sourceDatabases, targetDatabases, onupdate });
const select = screen.getByRole('combobox', { name: 'Target database: Source DB' });
expect(screen.getByText('Not mapped')).toBeTruthy();
await fireEvent.change(select, { target: { value: '' } });
expect(onupdate).not.toHaveBeenCalled();
});
});
// #endregion Test.MappingTable

View File

@@ -9,6 +9,8 @@
"select_dashboards_title": "Select Dashboards",
"replace_db": "Replace Database (Apply Mappings)",
"database_mappings": "Database Mappings",
"select_target_database": "Select target database",
"mapping_custom_selection": "Selected manually",
"loading_dbs": "Loading databases and suggestions...",
"refresh_dbs": "Refresh Databases & Suggestions",
"start": "Start Migration",
@@ -39,8 +41,19 @@
"step_review": "Review",
"step_migrate": "Migrate",
"options": "Options",
"review_validate": "Review & Validate",
"selected_dashboards": "Selected Dashboards",
"dashboards": "Dashboards",
"charts": "Charts",
"datasets": "Datasets",
"create": "Create",
"update": "Update",
"delete": "Delete",
"risk_assessment": "Risk Assessment",
"risk_score": "Score",
"risk_level_low": "Low",
"risk_level_medium": "Medium",
"risk_level_high": "High",
"no_issues": "No issues detected.",
"no_dry_run": "Run a dry-run to see the migration summary here.",
"get_started": "Get Started",

View File

@@ -9,6 +9,8 @@
"select_dashboards_title": "Выберите дашборды",
"replace_db": "Заменить БД (применить маппинги)",
"database_mappings": "Маппинги баз данных",
"select_target_database": "Выберите целевую БД",
"mapping_custom_selection": "Выбрано вручную",
"loading_dbs": "Загрузка баз данных и подсказок...",
"refresh_dbs": "Обновить БД и подсказки",
"start": "Запустить миграцию",
@@ -40,8 +42,19 @@
"step_review": "Проверка",
"step_migrate": "Миграция",
"options": "Опции",
"review_validate": "Проверка и подтверждение",
"selected_dashboards": "Выбранные дашборды",
"dashboards": "Дашборды",
"charts": "Чарты",
"datasets": "Датасеты",
"create": "Создать",
"update": "Обновить",
"delete": "Удалить",
"risk_assessment": "Оценка рисков",
"risk_score": "Оценка",
"risk_level_low": "Низкий",
"risk_level_medium": "Средний",
"risk_level_high": "Высокий",
"no_issues": "Проблем не обнаружено.",
"no_dry_run": "Запустите пробный прогон, чтобы увидеть сводку миграции.",
"get_started": "Начать",

View File

@@ -17,27 +17,84 @@
// @REJECTED Inline dry-run/execution logic in Migration.Model rejected — the execution lifecycle (dry-run calculate, submit migration, poll for AWAITING_INPUT, resume with passwords) has distinct state (dryRunResult, showPasswordPrompt) that would bloat the parent past the 400-line decomposition gate. A single action method `executeFullMigration()` on Migration.Model that internally orchestrates dry-run→execute→resume was rejected — the parent would need to track sub-state atoms anyway, and the caller (MigrationPage) needs separate gating (`canExecute`, `showPasswordPrompt`) for UI step controls.
import { api } from "$lib/api.js";
import { selectedTask } from "$lib/stores/selectedTask.svelte.js";
import { resumeTask } from "../../services/taskService.js";
import { t } from "$lib/i18n/index.svelte.js";
import type {
DashboardSelection as DashboardSelectionDto,
MigrationDryRunResult,
} from "../../types/dashboard";
import type { MigrationModel } from "./MigrationModel.svelte.ts";
// ── Types ─────────────────────────────────────────────────────
export interface DryRunResult {
summary?: Record<string, unknown>;
risk?: { score: number; level: string; items: unknown[] };
selected_dashboard_titles?: string[];
[key: string]: unknown;
}
export type DryRunResult = MigrationDryRunResult;
export type DashboardSelection = DashboardSelectionDto;
export interface DashboardSelection {
selected_ids: string[];
source_env_id: string;
target_env_id: string;
replace_db_config: boolean;
fix_cross_filters: boolean;
// #region Migration.ValidateDryRunResult [C:3] [TYPE Function] [SEMANTICS migration,dry-run,validation,dto]
// @ingroup Migration
// @BRIEF Narrow an untrusted API payload to the canonical migration dry-run DTO.
// @POST Returns true only when the review page's required summary, diff, and risk fields are present.
function isDryRunResult(payload: unknown): payload is DryRunResult {
if (!payload || typeof payload !== "object") return false;
const result = payload as Record<string, unknown>;
const isCountBucket = (value: unknown): boolean => {
if (!value || typeof value !== "object") return false;
const bucket = value as Record<string, unknown>;
return ["create", "update", "delete"].every(
(key) => typeof bucket[key] === "number",
);
};
const isDiffBucket = (value: unknown): boolean => {
if (!value || typeof value !== "object") return false;
const bucket = value as Record<string, unknown>;
return ["create", "update", "delete"].every(
(key) => Array.isArray(bucket[key]),
);
};
const isDiffObject = (item: unknown): boolean => {
if (!item || typeof item !== "object") return false;
const d = item as Record<string, unknown>;
return typeof d.uuid === "string";
};
const isRiskItem = (item: unknown): boolean => {
if (!item || typeof item !== "object") return false;
const r = item as Record<string, unknown>;
return (
typeof r.code === "string" &&
typeof r.severity === "string" &&
typeof r.object_type === "string" &&
typeof r.object_uuid === "string" &&
typeof r.message === "string"
);
};
const summary = result.summary as Record<string, unknown> | undefined;
const diff = result.diff as Record<string, unknown> | undefined;
const risk = result.risk as Record<string, unknown> | undefined;
return (
typeof result.generated_at === "string" &&
!!result.selection &&
typeof result.selection === "object" &&
Array.isArray(result.selected_dashboard_titles) &&
!!summary &&
["dashboards", "charts", "datasets"].every((key) => isCountBucket(summary[key])) &&
typeof summary.selected_dashboards === "number" &&
!!diff &&
["dashboards", "charts", "datasets"].every((key) => {
if (!isDiffBucket(diff[key])) return false;
const bucket = diff[key] as Record<string, unknown>;
return ["create", "update", "delete"].every((op) => {
const items = bucket[op] as unknown[];
return Array.isArray(items) && items.every(isDiffObject);
});
}) &&
!!risk &&
typeof risk.score === "number" &&
typeof risk.level === "string" &&
Array.isArray(risk.items) &&
risk.items.every(isRiskItem)
);
}
// #endregion Migration.ValidateDryRunResult
export class MigrationExecutor {
parent: MigrationModel;
@@ -70,7 +127,14 @@ export class MigrationExecutor {
this.parent.error = "";
this.dryRunLoading = true;
try {
this.dryRunResult = await api.postApi("/migration/dry-run", this.parent._buildSelection());
const result: unknown = await api.postApi(
"/migration/dry-run",
this.parent._buildSelection(),
);
if (!isDryRunResult(result)) {
throw new Error("Migration dry-run response has an invalid shape");
}
this.dryRunResult = result;
this.parent.wizard.currentStep = 3;
} catch (e: unknown) {
this.parent.error = e instanceof Error ? e.message : "Dry-run failed";

View File

@@ -4,6 +4,7 @@
// @INVARIANT Changing source environment resets dashboard selection to empty.
// @INVARIANT Changing source environment resets databases, mappings, and suggestions.
// @INVARIANT Changing source environment clears dry-run result.
// @INVARIANT Persisting a database mapping clears dry-run results because the target database selection changes the import plan.
// @INVARIANT Migration execution blocked unless source≠target and ≥1 dashboard selected.
// @INVARIANT Dry-run must complete before advancing to execution step.
// @INVARIANT Password prompt only appears for AWAITING_INPUT tasks with type "database_password".
@@ -78,6 +79,12 @@ interface Mapping {
[key: string]: unknown;
}
interface DatabaseSuggestion {
source_db_uuid: string;
target_db_uuid: string;
confidence: number;
}
interface LogViewerTask {
id: string;
status: string;
@@ -108,7 +115,7 @@ export class MigrationModel {
sourceDatabases: Database[] = $state([]);
targetDatabases: Database[] = $state([]);
mappings: Mapping[] = $state([]);
suggestions: Record<string, unknown>[] = $state([]);
suggestions: DatabaseSuggestion[] = $state([]);
// Loading & error
loading: boolean = $state(true);
@@ -303,7 +310,7 @@ export class MigrationModel {
}
}
/** Save a database mapping between source and target. */
/** Save a database mapping between source and target and invalidate any stale dry-run. */
async saveMapping(sourceUuid: string, targetUuid: string): Promise<void> {
const sDb = this.sourceDatabases.find((d: Database) => d.uuid === sourceUuid);
const tDb = this.targetDatabases.find((d: Database) => d.uuid === targetUuid);
@@ -321,6 +328,8 @@ export class MigrationModel {
...this.mappings.filter((m: Mapping) => m.source_db_uuid !== sourceUuid),
savedMapping,
];
// @INVARIANT: a mapping change alters the archive transformation/import plan.
this.dryRunResult = null;
} catch (e: unknown) {
this.error = e instanceof Error ? e.message : "Failed to save mapping";
}
@@ -361,7 +370,7 @@ export class MigrationModel {
/** Build the DashboardSelection payload for API calls. */
_buildSelection(): DashboardSelection {
return {
selected_ids: this.selectedDashboardIds,
selected_ids: this.selectedDashboardIds.map((id) => Number(id)),
source_env_id: this.sourceEnvId,
target_env_id: this.targetEnvId,
replace_db_config: this.replaceDb,

View File

@@ -270,6 +270,18 @@ describe("MigrationModel — L1 invariants (no render)", () => {
expect(model.mappings[0].id).toBe(2);
expect(model.mappings[1].id).toBe(3);
});
it("clears a completed dry-run after choosing a different target database", async () => {
model.sourceDatabases = [{ uuid: "suuid", database_name: "SrcDB" }];
model.targetDatabases = [{ uuid: "recommended", database_name: "Recommended" }, { uuid: "manual", database_name: "Manual" }];
model.mappings = [{ id: 1, source_db_uuid: "suuid", target_db_uuid: "recommended" }];
model.dryRunResult = { summary: {}, risk: { score: 10, level: "low", items: [] } } as any;
vi.mocked(api.postApi).mockResolvedValue({ id: 2, source_db_uuid: "suuid", target_db_uuid: "manual" });
await model.saveMapping("suuid", "manual");
expect(model.mappings).toEqual([{ id: 2, source_db_uuid: "suuid", target_db_uuid: "manual" }]);
expect(model.dryRunResult).toBeNull();
});
it("sets error on failure", async () => {
model.sourceDatabases = [{ uuid: "suuid", database_name: "S" }]; model.targetDatabases = [{ uuid: "tuuid", database_name: "T" }];
vi.mocked(api.postApi).mockRejectedValue(new Error("Save failed"));
@@ -288,7 +300,7 @@ describe("MigrationModel — L1 invariants (no render)", () => {
it("builds correct payload", () => {
model.selectedDashboardIds = ["1"]; model.sourceEnvId = "src"; model.targetEnvId = "tgt"; model.replaceDb = true; model.fixCrossFilters = false;
const sel = model._buildSelection();
expect(sel).toEqual({ selected_ids: ["1"], source_env_id: "src", target_env_id: "tgt", replace_db_config: true, fix_cross_filters: false });
expect(sel).toEqual({ selected_ids: [1], source_env_id: "src", target_env_id: "tgt", replace_db_config: true, fix_cross_filters: false });
});
});
@@ -321,7 +333,14 @@ describe("MigrationModel — L1 invariants (no render)", () => {
describe("calculateDryRun → toggleDashboard invariant", () => {
it("clears dryRunResult on toggle after dry-run", async () => {
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
vi.mocked(api.postApi).mockResolvedValueOnce({ summary: {}, risk: { score: 10, level: "low", items: [] }, selected_dashboard_titles: ["Sales"] });
vi.mocked(api.postApi).mockResolvedValueOnce({
generated_at: "2026-07-15T00:00:00+00:00",
selection: { selected_ids: [1], source_env_id: "env-1", target_env_id: "env-2", replace_db_config: false, fix_cross_filters: true },
selected_dashboard_titles: ["Sales"],
diff: { dashboards: { create: [], update: [], delete: [] }, charts: { create: [], update: [], delete: [] }, datasets: { create: [], update: [], delete: [] } },
summary: { dashboards: { create: 0, update: 0, delete: 0 }, charts: { create: 0, update: 0, delete: 0 }, datasets: { create: 0, update: 0, delete: 0 }, selected_dashboards: 1 },
risk: { score: 10, level: "low", items: [] },
});
await model.calculateDryRun();
expect(model.dryRunResult).toBeTruthy();
model.toggleDashboard("1");
@@ -378,7 +397,14 @@ describe("MigrationModel — L1 invariants (no render)", () => {
});
it("advances to step 3 on success", async () => {
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
vi.mocked(api.postApi).mockResolvedValue({ summary: {}, risk: { score: 10, level: "low", items: [] }, selected_dashboard_titles: ["Sales"] });
vi.mocked(api.postApi).mockResolvedValue({
generated_at: "2026-07-15T00:00:00+00:00",
selection: { selected_ids: [1], source_env_id: "env-1", target_env_id: "env-2", replace_db_config: false, fix_cross_filters: true },
selected_dashboard_titles: ["Sales"],
diff: { dashboards: { create: [], update: [], delete: [] }, charts: { create: [], update: [], delete: [] }, datasets: { create: [], update: [], delete: [] } },
summary: { dashboards: { create: 0, update: 0, delete: 0 }, charts: { create: 0, update: 0, delete: 0 }, datasets: { create: 0, update: 0, delete: 0 }, selected_dashboards: 1 },
risk: { score: 10, level: "low", items: [] },
});
await model.calculateDryRun();
expect(model.currentStep).toBe(3);
expect(model.dryRunResult).toBeTruthy();

View File

@@ -73,7 +73,7 @@
}}
>
<option value="">{$t.dashboard?.target_env_placeholder || 'Select target environment...'}</option>
{#each environments.filter((e) => e.id !== model.selectedEnv) as env}
{#each environments.filter((e) => e.id !== model.selectedEnv) as env (env.id)}
<option value={env.id}>{env.name}</option>
{/each}
</select>
@@ -106,7 +106,7 @@
targetDatabases={model.bulkMigrateModel.targetDatabases}
mappings={model.bulkMigrateModel.mappings}
suggestions={model.bulkMigrateModel.suggestions}
onupdate={(e) => model.bulkMigrateModel.saveMapping(e.detail.sourceUuid, e.detail.targetUuid)}
onupdate={(mapping) => model.bulkMigrateModel.saveMapping(mapping.sourceUuid, mapping.targetUuid)}
/>
</div>
{:else}
@@ -121,7 +121,7 @@
</thead>
<tbody>
{#if model.bulkMigrateModel.suggestions.length > 0}
{#each model.bulkMigrateModel.suggestions as mapping}
{#each model.bulkMigrateModel.suggestions as mapping (mapping.source_db_uuid)}
{@const targetMapping = model.bulkMigrateModel.mappings.find((m2) => m2.source_db_uuid === mapping.source_db_uuid)}
<tr class="border-b border-border last:border-b-0">
<td class="px-4 py-2">{mapping.source_db || mapping.source_db_name}</td>
@@ -170,8 +170,8 @@
<div>
<p class="block text-sm font-medium text-text mb-2">{$t.dashboard?.selected_dashboards}</p>
<div class="max-h-40 overflow-y-auto border border-border rounded-lg bg-surface-muted p-2">
{#each Array.from(model.selectedIds) as id}
{#each model.allDashboards as d}
{#each Array.from(model.selectedIds) as id (id)}
{#each model.allDashboards as d (d.id)}
{#if d.id === id}
<div class="flex items-center text-sm py-1 px-2"><span class="text-success mr-2"></span><span class="text-text">{d.title}</span></div>
{/if}

View File

@@ -0,0 +1,100 @@
// #region Test.MigrateDashboardModal [C:3] [TYPE Module] [SEMANTICS test,dashboard,migration,modal,ux]
// @BRIEF Verify the dashboard migration modal consumes MappingTable callback payloads correctly.
// @RELATION BINDS_TO -> [MigrateDashboardModal]
// @RELATION DEPENDS_ON -> [MappingTable]
// @TEST_SCENARIO: manual_mapping -> Modal forwards the selected source and target UUIDs to the migration model.
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import MigrateDashboardModal from '../MigrateDashboardModal.svelte';
vi.mock('$lib/i18n/index.svelte.js', () => ({
t: {
subscribe(run: (value: Record<string, Record<string, string>>) => void) {
run({
common: { close_modal: 'Close', cancel: 'Cancel', on: 'On', off: 'Off' },
migration: {
source_env: 'Source environment',
target_env: 'Target environment',
database_mappings: 'Database mappings',
start: 'Start migration',
},
dashboard: {
migrate_modal_title: 'Migrate {count} dashboards',
read_only: '(read-only)',
target_env_placeholder: 'Select target environment...',
edit_mappings: 'Edit mappings',
view_summary: 'View summary',
source_database: 'Source database',
target_database: 'Target database',
match_percent: 'Match',
suggested: 'Suggested',
not_mapped: 'Not mapped',
selected_dashboards: 'Selected dashboards',
},
});
return () => {};
},
},
}));
vi.mock('$lib/stores/taskDrawer.svelte.js', () => ({
openDrawerForTaskIfPreferred: vi.fn(),
}));
describe('MigrateDashboardModal', () => {
it('forwards a manual MappingTable selection to the migration model', async () => {
const saveMapping = vi.fn();
const model = {
showMigrateModal: true,
selectedEnv: 'source-env',
selectedIds: new Set(['dashboard-1']),
allDashboards: [{ id: 'dashboard-1', title: 'Sales dashboard' }],
isEditingMappings: true,
clearSelectedIds: vi.fn(),
bulkMigrateModel: {
targetEnvId: 'target-env',
sourceEnvId: 'source-env',
replaceDb: true,
fixCrossFilters: true,
sourceDatabases: [{ uuid: 'source-db', database_name: 'Source DB', engine: 'postgresql' }],
targetDatabases: [
{ uuid: 'recommended-db', database_name: 'Recommended DB', engine: 'postgresql' },
{ uuid: 'manual-db', database_name: 'Manual DB', engine: 'postgresql' },
],
mappings: [],
suggestions: [{ source_db_uuid: 'source-db', target_db_uuid: 'recommended-db', confidence: 0.95 }],
saveMapping,
selectedDashboardIds: ['dashboard-1'],
dryRunResult: null,
dryRunLoading: false,
error: '',
currentStep: 1,
canExecute: false,
selectTargetEnv: vi.fn(),
fetchDatabases: vi.fn().mockResolvedValue(undefined),
calculateDryRun: vi.fn(),
goToStep: vi.fn(),
executeMigration: vi.fn(),
selectedTaskStore: { current: null },
deselectAllDashboards: vi.fn(),
},
};
render(MigrateDashboardModal, {
model,
environments: [
{ id: 'source-env', name: 'Source' },
{ id: 'target-env', name: 'Target' },
],
});
await fireEvent.change(screen.getByRole('combobox', { name: 'Target database: Source DB' }), {
target: { value: 'manual-db' },
});
expect(saveMapping).toHaveBeenCalledWith('source-db', 'manual-db');
});
});
// #endregion Test.MigrateDashboardModal

View File

@@ -129,7 +129,7 @@
{ step: 2, label: $t.migration?.select_dashboards_title || "Dashboards" },
{ step: 3, label: $t.tasks?.summary_report || "Review" },
{ step: 4, label: $t.migration?.start || "Migrate" },
] as s}
] as s (s.step)}
<!-- Step indicator buttons — custom styling for step wizard (not standard Button) -->
<Button variant="ghost"
onclick={() => model.goToStep(s.step)}
@@ -353,7 +353,7 @@
targetDatabases={model.targetDatabases}
mappings={model.mappings}
suggestions={model.suggestions}
onupdate={(e) => model.saveMapping(e.detail.sourceUuid, e.detail.targetUuid)}
onupdate={(mapping) => model.saveMapping(mapping.sourceUuid, mapping.targetUuid)}
/>
{:else if model.sourceEnvId && model.targetEnvId}
<p class="text-sm text-text-muted">{$t.migration?.mapping_hint || "Select environments and click \"Fetch Databases\" to start mapping."}</p>
@@ -389,7 +389,7 @@
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<span class="flex items-center justify-center w-8 h-8 rounded-full bg-primary-light text-primary text-sm font-bold">3</span>
<h3 class="text-lg font-semibold text-text">{$t.tasks?.summary_report || "Review & Validate"}</h3>
<h3 class="text-lg font-semibold text-text">{$t.migration?.review_validate || "Review & Validate"}</h3>
</div>
{#if model.dryRunResult}
@@ -399,10 +399,10 @@
<svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-sm font-semibold text-info-hover">{$t.dashboard?.selected_dashboards || "Selected Dashboards"}</h3>
<h3 class="text-sm font-semibold text-info-hover">{$t.migration?.selected_dashboards || "Selected Dashboards"}</h3>
</div>
<div class="flex flex-wrap gap-2">
{#each model.dryRunResult.selected_dashboard_titles || [] as title}
{#each model.dryRunResult.selected_dashboard_titles || [] as title (title)}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-info-light text-info">
{title}
</span>
@@ -418,19 +418,19 @@
<div class="w-8 h-8 rounded-lg bg-info-light flex items-center justify-center">
<Icon name="dashboard" size={16} class="text-info" strokeWidth={2} />
</div>
<h4 class="text-sm font-semibold text-text">{$t.dashboard?.title || "Dashboards"}</h4>
<h4 class="text-sm font-semibold text-text">{$t.migration?.dashboards || "Dashboards"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">Create</span>
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.dashboards.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">Update</span>
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.dashboards.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">Delete</span>
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.dashboards.delete}</span>
</div>
</div>
@@ -444,19 +444,19 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h4 class="text-sm font-semibold text-text">{$t.dashboard?.charts || "Charts"}</h4>
<h4 class="text-sm font-semibold text-text">{$t.migration?.charts || "Charts"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">Create</span>
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.charts.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">Update</span>
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.charts.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">Delete</span>
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.charts.delete}</span>
</div>
</div>
@@ -468,19 +468,19 @@
<div class="w-8 h-8 rounded-lg bg-success-light flex items-center justify-center">
<Icon name="database" size={16} class="text-success" strokeWidth={2} />
</div>
<h4 class="text-sm font-semibold text-text">{$t.dashboard?.linked_resources || "Datasets"}</h4>
<h4 class="text-sm font-semibold text-text">{$t.migration?.datasets || "Datasets"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">Create</span>
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.datasets.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">Update</span>
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.datasets.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">Delete</span>
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.datasets.delete}</span>
</div>
</div>
@@ -494,11 +494,11 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" />
</svg>
<h3 class="text-sm font-semibold text-text">{$t.tasks?.issues || "Risk Assessment"}</h3>
<h3 class="text-sm font-semibold text-text">{$t.migration?.risk_assessment || "Risk Assessment"}</h3>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-xs text-text-muted">Score:</span>
<span class="text-xs text-text-muted">{$t.migration?.risk_score || "Score"}:</span>
<div class={`
px-2.5 py-1 rounded-full text-xs font-bold
${model.dryRunResult.risk.score <= 20 ? 'bg-success-light text-success' :
@@ -514,7 +514,11 @@
model.dryRunResult.risk.level === 'medium' ? 'bg-warning-light text-warning' :
'bg-destructive-light text-destructive'}
`}>
{model.dryRunResult.risk.level.toUpperCase()}
{model.dryRunResult.risk.level === "low"
? ($t.migration?.risk_level_low || "Low")
: model.dryRunResult.risk.level === "medium"
? ($t.migration?.risk_level_medium || "Medium")
: ($t.migration?.risk_level_high || "High")}
</div>
</div>
</div>
@@ -533,7 +537,7 @@
<!-- Risk Items -->
{#if model.dryRunResult.risk.items && model.dryRunResult.risk.items.length > 0}
<div class="space-y-2">
{#each model.dryRunResult.risk.items as item}
{#each model.dryRunResult.risk.items as item (`${item.object_type}:${item.object_uuid}:${item.message}`)}
<div class="flex items-start gap-3 p-2.5 rounded-lg text-sm
${item.severity === 'high' ? 'bg-destructive-light' :
item.severity === 'medium' ? 'bg-warning-light' : 'bg-info-light'}
@@ -551,7 +555,7 @@
{/each}
</div>
{:else}
<p class="text-sm text-text-muted">No issues detected.</p>
<p class="text-sm text-text-muted">{$t.migration?.no_issues || "No issues detected."}</p>
{/if}
</div>
@@ -568,7 +572,7 @@
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">Run a dry-run to see the migration summary here.</p>
<p class="text-sm">{$t.migration?.no_dry_run || "Run a dry-run to see the migration summary here."}</p>
</div>
{/if}

View File

@@ -20,8 +20,8 @@ export interface DashboardSelection {
selected_ids: number[];
source_env_id: string;
target_env_id: string;
replace_db_config?: boolean;
fix_cross_filters?: boolean;
replace_db_config: boolean;
fix_cross_filters: boolean;
}
export interface DiffObjectRef {