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:
@@ -4,14 +4,18 @@ export const nodes = [
|
||||
() => import('./nodes/0'),
|
||||
() => import('./nodes/1'),
|
||||
() => import('./nodes/2'),
|
||||
() => import('./nodes/3')
|
||||
() => import('./nodes/3'),
|
||||
() => import('./nodes/4'),
|
||||
() => import('./nodes/5')
|
||||
];
|
||||
|
||||
export const server_loads = [];
|
||||
|
||||
export const dictionary = {
|
||||
"/": [2],
|
||||
"/settings": [3]
|
||||
"/migration": [3],
|
||||
"/migration/mappings": [4],
|
||||
"/settings": [5]
|
||||
};
|
||||
|
||||
export const hooks = {
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
import * as universal from "../../../../src/routes/settings/+page.ts";
|
||||
export { universal };
|
||||
export { default as component } from "../../../../src/routes/settings/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/migration/+page.svelte";
|
||||
@@ -24,7 +24,7 @@ export const options = {
|
||||
app: ({ head, body, assets, nonce, env }) => "<!DOCTYPE html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\t\t" + head + "\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\">\n\t\t<div style=\"display: contents\">" + body + "</div>\n\t</body>\n</html>\n",
|
||||
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
|
||||
},
|
||||
version_hash: "1eogxsl"
|
||||
version_hash: "1pvaiah"
|
||||
};
|
||||
|
||||
export async function get_hooks() {
|
||||
|
||||
6
frontend/.svelte-kit/non-ambient.d.ts
vendored
6
frontend/.svelte-kit/non-ambient.d.ts
vendored
@@ -27,15 +27,17 @@ export {};
|
||||
|
||||
declare module "$app/types" {
|
||||
export interface AppTypes {
|
||||
RouteId(): "/" | "/settings";
|
||||
RouteId(): "/" | "/migration" | "/migration/mappings" | "/settings";
|
||||
RouteParams(): {
|
||||
|
||||
};
|
||||
LayoutParams(): {
|
||||
"/": Record<string, never>;
|
||||
"/migration": Record<string, never>;
|
||||
"/migration/mappings": Record<string, never>;
|
||||
"/settings": Record<string, never>
|
||||
};
|
||||
Pathname(): "/" | "/settings" | "/settings/";
|
||||
Pathname(): "/" | "/migration" | "/migration/" | "/migration/mappings" | "/migration/mappings/" | "/settings" | "/settings/";
|
||||
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
|
||||
Asset(): string & {};
|
||||
}
|
||||
|
||||
57
frontend/src/components/EnvSelector.svelte
Normal file
57
frontend/src/components/EnvSelector.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<!-- [DEF:EnvSelector:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: environment, selector, dropdown, migration
|
||||
@PURPOSE: Provides a UI component for selecting source and target environments.
|
||||
@LAYER: Feature
|
||||
@RELATION: BINDS_TO -> environments store
|
||||
|
||||
@INVARIANT: Source and target environments must be selectable from the list of configured environments.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let label: string = "Select Environment";
|
||||
export let selectedId: string = "";
|
||||
export let environments: Array<{id: string, name: string, url: string}> = [];
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:handleSelect:Function]
|
||||
/**
|
||||
* @purpose Dispatches the selection change event.
|
||||
* @param {Event} event - The change event from the select element.
|
||||
*/
|
||||
function handleSelect(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
selectedId = target.value;
|
||||
dispatch('change', { id: selectedId });
|
||||
}
|
||||
// [/DEF:handleSelect]
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="flex flex-col space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700">{label}</label>
|
||||
<select
|
||||
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
value={selectedId}
|
||||
on:change={handleSelect}
|
||||
>
|
||||
<option value="" disabled>-- Choose an environment --</option>
|
||||
{#each environments as env}
|
||||
<option value={env.id}>{env.name} ({env.url})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Component specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:EnvSelector] -->
|
||||
94
frontend/src/components/MappingTable.svelte
Normal file
94
frontend/src/components/MappingTable.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<!-- [DEF:MappingTable:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: mapping, table, database, editor
|
||||
@PURPOSE: Displays and allows editing of database mappings.
|
||||
@LAYER: Feature
|
||||
@RELATION: BINDS_TO -> mappings state
|
||||
|
||||
@INVARIANT: Each source database can be mapped to one target database.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let sourceDatabases: Array<{uuid: string, database_name: string}> = [];
|
||||
export let targetDatabases: Array<{uuid: string, database_name: string}> = [];
|
||||
export let mappings: Array<{source_db_uuid: string, target_db_uuid: string}> = [];
|
||||
export let suggestions: Array<{source_db_uuid: string, target_db_uuid: string, confidence: number}> = [];
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:updateMapping:Function]
|
||||
/**
|
||||
* @purpose Updates a mapping for a specific source database.
|
||||
*/
|
||||
function updateMapping(sourceUuid: string, targetUuid: string) {
|
||||
dispatch('update', { sourceUuid, targetUuid });
|
||||
}
|
||||
// [/DEF:updateMapping]
|
||||
|
||||
// [DEF:getSuggestion:Function]
|
||||
/**
|
||||
* @purpose Finds a suggestion for a source database.
|
||||
*/
|
||||
function getSuggestion(sourceUuid: string) {
|
||||
return suggestions.find(s => s.source_db_uuid === sourceUuid);
|
||||
}
|
||||
// [/DEF:getSuggestion]
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source Database</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Target Database</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each sourceDatabases as sDb}
|
||||
{@const mapping = mappings.find(m => m.source_db_uuid === sDb.uuid)}
|
||||
{@const suggestion = getSuggestion(sDb.uuid)}
|
||||
<tr class={suggestion && !mapping ? 'bg-green-50' : ''}>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{sDb.database_name}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<select
|
||||
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
value={mapping?.target_db_uuid || suggestion?.target_db_uuid || ""}
|
||||
on:change={(e) => updateMapping(sDb.uuid, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">-- Select Target --</option>
|
||||
{#each targetDatabases as tDb}
|
||||
<option value={tDb.uuid}>{tDb.database_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{#if mapping}
|
||||
<span class="text-blue-600 font-semibold">Saved</span>
|
||||
{:else if suggestion}
|
||||
<span class="text-green-600 font-semibold">Suggested ({Math.round(suggestion.confidence * 100)}%)</span>
|
||||
{:else}
|
||||
<span class="text-red-600">Unmapped</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Component specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:MappingTable] -->
|
||||
112
frontend/src/components/MissingMappingModal.svelte
Normal file
112
frontend/src/components/MissingMappingModal.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<!-- [DEF:MissingMappingModal:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: modal, mapping, prompt, migration
|
||||
@PURPOSE: Prompts the user to provide a database mapping when one is missing during migration.
|
||||
@LAYER: Feature
|
||||
@RELATION: DISPATCHES -> resolve
|
||||
|
||||
@INVARIANT: Modal blocks migration progress until resolved or cancelled.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let show: boolean = false;
|
||||
export let sourceDbName: string = "";
|
||||
export let sourceDbUuid: string = "";
|
||||
export let targetDatabases: Array<{uuid: string, database_name: string}> = [];
|
||||
// [/SECTION]
|
||||
|
||||
let selectedTargetUuid = "";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:resolve:Function]
|
||||
function resolve() {
|
||||
if (!selectedTargetUuid) return;
|
||||
dispatch('resolve', {
|
||||
sourceDbUuid,
|
||||
targetDbUuid: selectedTargetUuid,
|
||||
targetDbName: targetDatabases.find(d => d.uuid === selectedTargetUuid)?.database_name
|
||||
});
|
||||
show = false;
|
||||
}
|
||||
// [/DEF:resolve]
|
||||
|
||||
// [DEF:cancel:Function]
|
||||
function cancel() {
|
||||
dispatch('cancel');
|
||||
show = false;
|
||||
}
|
||||
// [/DEF:cancel]
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
{#if show}
|
||||
<div class="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100">
|
||||
<svg class="h-6 w-6 text-yellow-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Missing Database Mapping
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
The database <strong>{sourceDbName}</strong> is used in the assets being migrated but has no mapping to the target environment. Please select a target database.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6">
|
||||
<select
|
||||
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
bind:value={selectedTargetUuid}
|
||||
>
|
||||
<option value="" disabled>-- Select Target Database --</option>
|
||||
{#each targetDatabases as tDb}
|
||||
<option value={tDb.uuid}>{tDb.database_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button
|
||||
type="button"
|
||||
on:click={resolve}
|
||||
disabled={!selectedTargetUuid}
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
||||
>
|
||||
Apply & Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={cancel}
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
|
||||
>
|
||||
Cancel Migration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Modal specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:MissingMappingModal] -->
|
||||
@@ -15,6 +15,7 @@
|
||||
import { selectedTask, taskLogs } from '../lib/stores.js';
|
||||
import { getWsUrl } from '../lib/api.js';
|
||||
import { addToast } from '../lib/toasts.js';
|
||||
import MissingMappingModal from './MissingMappingModal.svelte';
|
||||
// [/SECTION]
|
||||
|
||||
let ws;
|
||||
@@ -25,7 +26,10 @@
|
||||
let reconnectTimeout;
|
||||
let waitingForData = false;
|
||||
let dataTimeout;
|
||||
let connectionStatus = 'disconnected'; // 'connecting', 'connected', 'disconnected', 'waiting', 'completed'
|
||||
let connectionStatus = 'disconnected'; // 'connecting', 'connected', 'disconnected', 'waiting', 'completed', 'awaiting_mapping'
|
||||
let showMappingModal = false;
|
||||
let missingDbInfo = { name: '', uuid: '' };
|
||||
let targetDatabases = [];
|
||||
|
||||
// [DEF:connect:Function]
|
||||
/**
|
||||
@@ -58,6 +62,17 @@
|
||||
connectionStatus = 'completed';
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// Check for missing mapping signal
|
||||
if (logEntry.message && logEntry.message.includes('Missing mapping for database UUID')) {
|
||||
const uuidMatch = logEntry.message.match(/UUID: ([\w-]+)/);
|
||||
if (uuidMatch) {
|
||||
missingDbInfo = { name: 'Unknown', uuid: uuidMatch[1] };
|
||||
connectionStatus = 'awaiting_mapping';
|
||||
fetchTargetDatabases();
|
||||
showMappingModal = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
@@ -85,6 +100,65 @@
|
||||
}
|
||||
// [/DEF:connect]
|
||||
|
||||
async function fetchTargetDatabases() {
|
||||
const task = get(selectedTask);
|
||||
if (!task || !task.params.to_env) return;
|
||||
|
||||
try {
|
||||
// We need to find the environment ID by name first
|
||||
const envsRes = await fetch('/api/environments');
|
||||
const envs = await envsRes.json();
|
||||
const targetEnv = envs.find(e => e.name === task.params.to_env);
|
||||
|
||||
if (targetEnv) {
|
||||
const res = await fetch(`/api/environments/${targetEnv.id}/databases`);
|
||||
targetDatabases = await res.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch target databases', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMappingResolve(event) {
|
||||
const task = get(selectedTask);
|
||||
const { sourceDbUuid, targetDbUuid, targetDbName } = event.detail;
|
||||
|
||||
try {
|
||||
// 1. Save mapping to backend
|
||||
const envsRes = await fetch('/api/environments');
|
||||
const envs = await envsRes.json();
|
||||
const srcEnv = envs.find(e => e.name === task.params.from_env);
|
||||
const tgtEnv = envs.find(e => e.name === task.params.to_env);
|
||||
|
||||
await fetch('/api/mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_env_id: srcEnv.id,
|
||||
target_env_id: tgtEnv.id,
|
||||
source_db_uuid: sourceDbUuid,
|
||||
target_db_uuid: targetDbUuid,
|
||||
source_db_name: missingDbInfo.name,
|
||||
target_db_name: targetDbName
|
||||
})
|
||||
});
|
||||
|
||||
// 2. Resolve task
|
||||
await fetch(`/api/tasks/${task.id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
resolution_params: { resolved_mapping: { [sourceDbUuid]: targetDbUuid } }
|
||||
})
|
||||
});
|
||||
|
||||
connectionStatus = 'connected';
|
||||
addToast('Mapping resolved, migration continuing...', 'success');
|
||||
} catch (e) {
|
||||
addToast('Failed to resolve mapping: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function startDataTimeout() {
|
||||
waitingForData = false;
|
||||
dataTimeout = setTimeout(() => {
|
||||
@@ -151,6 +225,9 @@
|
||||
{:else if connectionStatus === 'completed'}
|
||||
<span class="h-3 w-3 rounded-full bg-blue-500"></span>
|
||||
<span class="text-xs text-gray-500">Completed</span>
|
||||
{:else if connectionStatus === 'awaiting_mapping'}
|
||||
<span class="h-3 w-3 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
<span class="text-xs text-gray-500">Awaiting Mapping</span>
|
||||
{:else}
|
||||
<span class="h-3 w-3 rounded-full bg-red-500"></span>
|
||||
<span class="text-xs text-gray-500">Disconnected</span>
|
||||
@@ -177,6 +254,15 @@
|
||||
<p>No task selected.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<MissingMappingModal
|
||||
bind:show={showMappingModal}
|
||||
sourceDbName={missingDbInfo.name}
|
||||
sourceDbUuid={missingDbInfo.uuid}
|
||||
{targetDatabases}
|
||||
on:resolve={handleMappingResolve}
|
||||
on:cancel={() => { connectionStatus = 'disconnected'; ws.close(); }}
|
||||
/>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:TaskRunner] -->
|
||||
|
||||
@@ -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) {
|
||||
|
||||
132
frontend/src/routes/migration/+page.svelte
Normal file
132
frontend/src/routes/migration/+page.svelte
Normal 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] -->
|
||||
183
frontend/src/routes/migration/mappings/+page.svelte
Normal file
183
frontend/src/routes/migration/mappings/+page.svelte
Normal 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] -->
|
||||
Reference in New Issue
Block a user