feat(migration): implement interactive mapping resolution workflow

- Add SQLite database integration for environments and mappings
- Update TaskManager to support pausing tasks (AWAITING_MAPPING)
- Modify MigrationPlugin to detect missing mappings and wait for resolution
- Add frontend UI for handling missing mappings interactively
- Create dedicated migration routes and API endpoints
- Update .gitignore and project documentation
This commit is contained in:
2025-12-25 22:27:29 +03:00
parent 43b4c75e36
commit 2ffc3cc68f
38 changed files with 2437 additions and 51 deletions

View File

@@ -4,6 +4,7 @@
import DynamicForm from '../components/DynamicForm.svelte';
import { api } from '../lib/api.js';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
/** @type {import('./$types').PageData} */
export let data;
@@ -15,7 +16,11 @@
function selectPlugin(plugin) {
console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`);
selectedPlugin.set(plugin);
if (plugin.id === 'superset-migration') {
goto('/migration');
} else {
selectedPlugin.set(plugin);
}
}
async function handleFormSubmit(event) {

View File

@@ -0,0 +1,132 @@
<!-- [DEF:MigrationDashboard:Component] -->
<!--
@SEMANTICS: migration, dashboard, environment, selection, database-replacement
@PURPOSE: Main dashboard for configuring and starting migrations.
@LAYER: Page
@RELATION: USES -> EnvSelector
@INVARIANT: Migration cannot start without source and target environments.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import EnvSelector from '../../components/EnvSelector.svelte';
// [/SECTION]
// [SECTION: STATE]
let environments = [];
let sourceEnvId = "";
let targetEnvId = "";
let dashboardRegex = ".*";
let replaceDb = false;
let loading = true;
let error = "";
// [/SECTION]
// [DEF:fetchEnvironments:Function]
/**
* @purpose Fetches the list of environments from the API.
* @post environments state is updated.
*/
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
// [/DEF:fetchEnvironments]
onMount(fetchEnvironments);
// [DEF:startMigration:Function]
/**
* @purpose Starts the migration process.
* @pre sourceEnvId and targetEnvId must be set and different.
*/
async function startMigration() {
if (!sourceEnvId || !targetEnvId) {
error = "Please select both source and target environments.";
return;
}
if (sourceEnvId === targetEnvId) {
error = "Source and target environments must be different.";
return;
}
error = "";
console.log(`[MigrationDashboard][Action] Starting migration from ${sourceEnvId} to ${targetEnvId} (Replace DB: ${replaceDb})`);
// TODO: Implement actual migration trigger in US3
}
// [/DEF:startMigration]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="max-w-4xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Migration Dashboard</h1>
{#if loading}
<p>Loading environments...</p>
{:else if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<EnvSelector
label="Source Environment"
bind:selectedId={sourceEnvId}
{environments}
/>
<EnvSelector
label="Target Environment"
bind:selectedId={targetEnvId}
{environments}
/>
</div>
<div class="mb-8">
<label for="dashboard-regex" class="block text-sm font-medium text-gray-700 mb-1">Dashboard Regex</label>
<input
id="dashboard-regex"
type="text"
bind:value={dashboardRegex}
placeholder="e.g. ^Finance Dashboard$"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
<p class="mt-1 text-sm text-gray-500">Regular expression to filter dashboards to migrate.</p>
</div>
<div class="flex items-center mb-8">
<input
id="replace-db"
type="checkbox"
bind:checked={replaceDb}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label for="replace-db" class="ml-2 block text-sm text-gray-900">
Replace Database (Apply Mappings)
</label>
</div>
<button
on:click={startMigration}
disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400"
>
Start Migration
</button>
</div>
<!-- [/SECTION] -->
<style>
/* Page specific styles */
</style>
<!-- [/DEF:MigrationDashboard] -->

View File

@@ -0,0 +1,183 @@
<!-- [DEF:MappingManagement:Component] -->
<!--
@SEMANTICS: mapping, management, database, fuzzy-matching
@PURPOSE: Page for managing database mappings between environments.
@LAYER: Page
@RELATION: USES -> EnvSelector
@RELATION: USES -> MappingTable
@INVARIANT: Mappings are saved to the backend for persistence.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import EnvSelector from '../../../components/EnvSelector.svelte';
import MappingTable from '../../../components/MappingTable.svelte';
// [/SECTION]
// [SECTION: STATE]
let environments = [];
let sourceEnvId = "";
let targetEnvId = "";
let sourceDatabases = [];
let targetDatabases = [];
let mappings = [];
let suggestions = [];
let loading = true;
let fetchingDbs = false;
let error = "";
let success = "";
// [/SECTION]
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
onMount(fetchEnvironments);
// [DEF:fetchDatabases:Function]
/**
* @purpose Fetches databases from both environments and gets suggestions.
*/
async function fetchDatabases() {
if (!sourceEnvId || !targetEnvId) return;
fetchingDbs = true;
error = "";
success = "";
try {
const [srcRes, tgtRes, mapRes, sugRes] = await Promise.all([
fetch(`/api/environments/${sourceEnvId}/databases`),
fetch(`/api/environments/${targetEnvId}/databases`),
fetch(`/api/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
fetch(`/api/mappings/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_env_id: sourceEnvId, target_env_id: targetEnvId })
})
]);
if (!srcRes.ok || !tgtRes.ok) throw new Error('Failed to fetch databases from environments');
sourceDatabases = await srcRes.json();
targetDatabases = await tgtRes.json();
mappings = await mapRes.json();
suggestions = await sugRes.json();
} catch (e) {
error = e.message;
} finally {
fetchingDbs = false;
}
}
// [/DEF:fetchDatabases]
// [DEF:handleUpdate:Function]
/**
* @purpose Saves a mapping to the backend.
*/
async function handleUpdate(event: CustomEvent) {
const { sourceUuid, targetUuid } = event.detail;
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
if (!sDb || !tDb) return;
try {
const response = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
})
});
if (!response.ok) throw new Error('Failed to save mapping');
const savedMapping = await response.json();
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
success = "Mapping saved successfully";
} catch (e) {
error = e.message;
}
}
// [/DEF:handleUpdate]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="max-w-6xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Database Mapping Management</h1>
{#if loading}
<p>Loading environments...</p>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<EnvSelector
label="Source Environment"
bind:selectedId={sourceEnvId}
{environments}
on:change={() => { sourceDatabases = []; mappings = []; suggestions = []; }}
/>
<EnvSelector
label="Target Environment"
bind:selectedId={targetEnvId}
{environments}
on:change={() => { targetDatabases = []; mappings = []; suggestions = []; }}
/>
</div>
<div class="mb-8">
<button
on:click={fetchDatabases}
disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || fetchingDbs}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400"
>
{fetchingDbs ? 'Fetching...' : 'Fetch Databases & Suggestions'}
</button>
</div>
{#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
{/if}
{#if success}
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{success}
</div>
{/if}
{#if sourceDatabases.length > 0}
<MappingTable
{sourceDatabases}
{targetDatabases}
{mappings}
{suggestions}
on:update={handleUpdate}
/>
{:else if !fetchingDbs && sourceEnvId && targetEnvId}
<p class="text-gray-500 italic">Select environments and click "Fetch Databases" to start mapping.</p>
{/if}
{/if}
</div>
<!-- [/SECTION] -->
<style>
/* Page specific styles */
</style>
<!-- [/DEF:MappingManagement] -->