001-migration-ui-redesign (#3)

Reviewed-on: #3
This commit is contained in:
2025-12-26 18:17:58 +03:00
parent 4448352ef9
commit a43f8fb021
38 changed files with 2434 additions and 51 deletions

View 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] -->

View 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] -->

View 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">&#8203;</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] -->

View File

@@ -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] -->