391 lines
12 KiB
Svelte
391 lines
12 KiB
Svelte
<!-- [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';
|
|
import DashboardGrid from '../../components/DashboardGrid.svelte';
|
|
import MappingTable from '../../components/MappingTable.svelte';
|
|
import TaskRunner from '../../components/TaskRunner.svelte';
|
|
import TaskHistory from '../../components/TaskHistory.svelte';
|
|
import TaskLogViewer from '../../components/TaskLogViewer.svelte';
|
|
import PasswordPrompt from '../../components/PasswordPrompt.svelte';
|
|
import { api } from '../../lib/api.js';
|
|
import { selectedTask } from '../../lib/stores.js';
|
|
import { resumeTask } from '../../services/taskService.js';
|
|
import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard';
|
|
import { t } from '$lib/i18n';
|
|
import { Button, Card, PageHeader } from '$lib/ui';
|
|
// [/SECTION]
|
|
|
|
// [SECTION: STATE]
|
|
let environments: any[] = [];
|
|
let sourceEnvId = "";
|
|
let targetEnvId = "";
|
|
let replaceDb = false;
|
|
let loading = true;
|
|
let error = "";
|
|
let dashboards: DashboardMetadata[] = [];
|
|
let selectedDashboardIds: number[] = [];
|
|
let sourceDatabases: any[] = [];
|
|
let targetDatabases: any[] = [];
|
|
let mappings: any[] = [];
|
|
let suggestions: any[] = [];
|
|
let fetchingDbs = false;
|
|
|
|
// UI State for Modals
|
|
let showLogViewer = false;
|
|
let logViewerTaskId: string | null = null;
|
|
let logViewerTaskStatus: string | null = null;
|
|
|
|
let showPasswordPrompt = false;
|
|
let passwordPromptDatabases: string[] = [];
|
|
let passwordPromptErrorMessage = "";
|
|
// [/SECTION]
|
|
|
|
// [DEF:fetchEnvironments:Function]
|
|
/**
|
|
* @purpose Fetches the list of environments from the API.
|
|
* @pre None.
|
|
* @post environments state is updated.
|
|
*/
|
|
async function fetchEnvironments() {
|
|
try {
|
|
environments = await api.getEnvironmentsList();
|
|
} catch (e) {
|
|
error = e.message;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
// [/DEF:fetchEnvironments:Function]
|
|
|
|
// [DEF:fetchDashboards:Function]
|
|
/**
|
|
* @purpose Fetches dashboards for the selected source environment.
|
|
* @pre envId is a valid environment ID.
|
|
* @param envId The environment ID.
|
|
* @post dashboards state is updated.
|
|
*/
|
|
async function fetchDashboards(envId: string) {
|
|
try {
|
|
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
|
|
selectedDashboardIds = []; // Reset selection when env changes
|
|
} catch (e) {
|
|
error = e.message;
|
|
dashboards = [];
|
|
}
|
|
}
|
|
// [/DEF:fetchDashboards:Function]
|
|
|
|
onMount(fetchEnvironments);
|
|
|
|
// Reactive: fetch dashboards when source env changes
|
|
$: if (sourceEnvId) fetchDashboards(sourceEnvId);
|
|
|
|
// [DEF:fetchDatabases:Function]
|
|
/**
|
|
* @purpose Fetches databases from both environments and gets suggestions.
|
|
* @pre sourceEnvId and targetEnvId must be set.
|
|
* @post sourceDatabases, targetDatabases, mappings, and suggestions are updated.
|
|
*/
|
|
async function fetchDatabases() {
|
|
if (!sourceEnvId || !targetEnvId) return;
|
|
fetchingDbs = true;
|
|
error = "";
|
|
|
|
try {
|
|
const [src, tgt, maps, sugs] = await Promise.all([
|
|
api.requestApi(`/environments/${sourceEnvId}/databases`),
|
|
api.requestApi(`/environments/${targetEnvId}/databases`),
|
|
api.requestApi(`/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
|
|
api.postApi(`/mappings/suggest`, { source_env_id: sourceEnvId, target_env_id: targetEnvId })
|
|
]);
|
|
|
|
sourceDatabases = src;
|
|
targetDatabases = tgt;
|
|
mappings = maps;
|
|
suggestions = sugs;
|
|
} catch (e) {
|
|
error = e.message;
|
|
} finally {
|
|
fetchingDbs = false;
|
|
}
|
|
}
|
|
// [/DEF:fetchDatabases:Function]
|
|
|
|
// [DEF:handleMappingUpdate:Function]
|
|
/**
|
|
* @purpose Saves a mapping to the backend.
|
|
* @pre event.detail contains sourceUuid and targetUuid.
|
|
* @post Mapping is saved and local mappings list is updated.
|
|
*/
|
|
async function handleMappingUpdate(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 savedMapping = await api.postApi('/mappings', {
|
|
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
|
|
});
|
|
|
|
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
|
|
} catch (e) {
|
|
error = e.message;
|
|
}
|
|
}
|
|
// [/DEF:handleMappingUpdate:Function]
|
|
|
|
// [DEF:handleViewLogs:Function]
|
|
// @PURPOSE: Opens the log viewer for a specific task.
|
|
// @PRE: event.detail contains task object.
|
|
// @POST: logViewer state updated and showLogViewer set to true.
|
|
function handleViewLogs(event: CustomEvent) {
|
|
const task = event.detail;
|
|
logViewerTaskId = task.id;
|
|
logViewerTaskStatus = task.status;
|
|
showLogViewer = true;
|
|
}
|
|
// [/DEF:handleViewLogs:Function]
|
|
|
|
// [DEF:handlePasswordPrompt:Function]
|
|
// @PURPOSE: Reactive logic to show password prompt when a task is awaiting input.
|
|
// @PRE: selectedTask status is AWAITING_INPUT.
|
|
// @POST: showPasswordPrompt set to true with request data.
|
|
// This is triggered by TaskRunner or TaskHistory when a task needs input
|
|
// For now, we rely on the WebSocket or manual check.
|
|
// Ideally, TaskHistory or TaskRunner emits an event when input is needed.
|
|
// Or we watch selectedTask.
|
|
|
|
$: if ($selectedTask && $selectedTask.status === 'AWAITING_INPUT' && $selectedTask.input_request) {
|
|
const req = $selectedTask.input_request;
|
|
if (req.type === 'database_password') {
|
|
passwordPromptDatabases = req.databases || [];
|
|
passwordPromptErrorMessage = req.error_message || "";
|
|
showPasswordPrompt = true;
|
|
}
|
|
} else if (!$selectedTask || $selectedTask.status !== 'AWAITING_INPUT') {
|
|
// Close prompt if task is no longer waiting (e.g. resumed)
|
|
// But only if we are viewing this task.
|
|
// showPasswordPrompt = false;
|
|
// Actually, don't auto-close, let the user or success handler close it.
|
|
}
|
|
// [/DEF:handlePasswordPrompt:Function]
|
|
|
|
// [DEF:handleResumeMigration:Function]
|
|
// @PURPOSE: Resumes a migration task with provided passwords.
|
|
// @PRE: event.detail contains passwords.
|
|
// @POST: resumeTask is called and showPasswordPrompt is hidden on success.
|
|
async function handleResumeMigration(event: CustomEvent) {
|
|
if (!$selectedTask) return;
|
|
|
|
const { passwords } = event.detail;
|
|
try {
|
|
await resumeTask($selectedTask.id, passwords);
|
|
showPasswordPrompt = false;
|
|
// Task status update will be handled by store/websocket
|
|
} catch (e) {
|
|
console.error("Failed to resume task:", e);
|
|
passwordPromptErrorMessage = e.message;
|
|
// Keep prompt open
|
|
}
|
|
}
|
|
// [/DEF:handleResumeMigration:Function]
|
|
|
|
// [DEF:startMigration:Function]
|
|
/**
|
|
* @purpose Starts the migration process.
|
|
* @pre sourceEnvId and targetEnvId must be set and different.
|
|
* @post Migration task is started and selectedTask is updated.
|
|
*/
|
|
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;
|
|
}
|
|
if (selectedDashboardIds.length === 0) {
|
|
error = "Please select at least one dashboard to migrate.";
|
|
return;
|
|
}
|
|
|
|
error = "";
|
|
try {
|
|
const selection: DashboardSelection = {
|
|
selected_ids: selectedDashboardIds,
|
|
source_env_id: sourceEnvId,
|
|
target_env_id: targetEnvId,
|
|
replace_db_config: replaceDb
|
|
};
|
|
console.log(`[MigrationDashboard][Action] Starting migration with selection:`, selection);
|
|
const result = await api.postApi('/migration/execute', selection);
|
|
console.log(`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`);
|
|
|
|
// Wait a brief moment for the backend to ensure the task is retrievable
|
|
await new Promise(r => setTimeout(r, 500));
|
|
|
|
// Fetch full task details and switch to TaskRunner view
|
|
try {
|
|
const task = await api.getTask(result.task_id);
|
|
selectedTask.set(task);
|
|
} catch (fetchErr) {
|
|
// Fallback: create a temporary task object to switch view immediately
|
|
console.warn("Could not fetch task details immediately, using placeholder.");
|
|
selectedTask.set({
|
|
id: result.task_id,
|
|
plugin_id: 'superset-migration',
|
|
status: 'RUNNING',
|
|
logs: [],
|
|
params: {}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(`[MigrationDashboard][Failure] Migration failed:`, e);
|
|
error = e.message;
|
|
}
|
|
}
|
|
// [/DEF:startMigration:Function]
|
|
</script>
|
|
|
|
<!-- [SECTION: TEMPLATE] -->
|
|
<div class="max-w-4xl mx-auto p-6">
|
|
<PageHeader title={$t.nav.migration} />
|
|
|
|
<TaskHistory on:viewLogs={handleViewLogs} />
|
|
|
|
{#if $selectedTask}
|
|
<div class="mt-6">
|
|
<TaskRunner />
|
|
<div class="mt-4">
|
|
<Button variant="secondary" on:click={() => selectedTask.set(null)}>
|
|
{$t.common.cancel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{#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>
|
|
|
|
<!-- [DEF:DashboardSelectionSection] -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-medium mb-4">Select Dashboards</h2>
|
|
|
|
{#if sourceEnvId}
|
|
<DashboardGrid
|
|
{dashboards}
|
|
bind:selectedIds={selectedDashboardIds}
|
|
/>
|
|
{:else}
|
|
<p class="text-gray-500 italic">Select a source environment to view dashboards.</p>
|
|
{/if}
|
|
</div>
|
|
<!-- [/DEF:DashboardSelectionSection] -->
|
|
|
|
|
|
<div class="flex items-center mb-4">
|
|
<input
|
|
id="replace-db"
|
|
type="checkbox"
|
|
bind:checked={replaceDb}
|
|
on:change={() => { if (replaceDb && sourceDatabases.length === 0) fetchDatabases(); }}
|
|
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>
|
|
|
|
{#if replaceDb}
|
|
<div class="mb-8 p-4 border rounded-md bg-gray-50">
|
|
<h3 class="text-md font-medium mb-4">Database Mappings</h3>
|
|
{#if fetchingDbs}
|
|
<p>Loading databases and suggestions...</p>
|
|
{:else if sourceDatabases.length > 0}
|
|
<MappingTable
|
|
{sourceDatabases}
|
|
{targetDatabases}
|
|
{mappings}
|
|
{suggestions}
|
|
on:update={handleMappingUpdate}
|
|
/>
|
|
{:else if sourceEnvId && targetEnvId}
|
|
<button
|
|
on:click={fetchDatabases}
|
|
class="text-indigo-600 hover:text-indigo-500 text-sm font-medium"
|
|
>
|
|
Refresh Databases & Suggestions
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<Button
|
|
on:click={startMigration}
|
|
disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || selectedDashboardIds.length === 0}
|
|
>
|
|
Start Migration
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Modals -->
|
|
<TaskLogViewer
|
|
bind:show={showLogViewer}
|
|
taskId={logViewerTaskId}
|
|
taskStatus={logViewerTaskStatus}
|
|
on:close={() => showLogViewer = false}
|
|
/>
|
|
|
|
<PasswordPrompt
|
|
bind:show={showPasswordPrompt}
|
|
databases={passwordPromptDatabases}
|
|
errorMessage={passwordPromptErrorMessage}
|
|
on:resume={handleResumeMigration}
|
|
on:cancel={() => showPasswordPrompt = false}
|
|
/>
|
|
|
|
<!-- [/SECTION] -->
|
|
|
|
<style>
|
|
/* Page specific styles */
|
|
</style>
|
|
|
|
<!-- [/DEF:MigrationDashboard:Component] -->
|