Files
ss-tools/frontend/src/routes/migration/+page.svelte
2026-02-25 18:31:50 +03:00

451 lines
14 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 fixCrossFilters = true;
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 || $t.migration?.resume_failed ;
// 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 =
$t.migration?.select_both_envs ||
"Please select both source and target environments.";
return;
}
if (sourceEnvId === targetEnvId) {
error =
$t.migration?.different_envs ||
"Source and target environments must be different.";
return;
}
if (selectedDashboardIds.length === 0) {
error =
$t.migration?.select_dashboards ||
"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,
fix_cross_filters: fixCrossFilters,
};
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(
$t.migration?.task_placeholder_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>{$t.migration?.loading_envs }</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={$t.migration?.source_env }
bind:selectedId={sourceEnvId}
{environments}
/>
<EnvSelector
label={$t.migration?.target_env }
bind:selectedId={targetEnvId}
{environments}
/>
</div>
<!-- [DEF:DashboardSelectionSection:Component] -->
<div class="mb-8">
<h2 class="text-lg font-medium mb-4">
{$t.migration?.select_dashboards_title }
</h2>
{#if sourceEnvId}
<DashboardGrid
{dashboards}
bind:selectedIds={selectedDashboardIds}
environmentId={sourceEnvId}
/>
{:else}
<p class="text-gray-500 italic">
{$t.dashboard?.select_source ||
"Select a source environment to view dashboards."}
</p>
{/if}
</div>
<!-- [/DEF:DashboardSelectionSection:Component] -->
<div class="mb-4">
<div class="flex items-center mb-2">
<input
id="fix-cross-filters"
type="checkbox"
bind:checked={fixCrossFilters}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label for="fix-cross-filters" class="ml-2 block text-sm text-gray-900">
{$t.migration?.fix_cross_filters ||
"Fix Cross-Filters (Auto-repair broken links during migration)"}
</label>
</div>
<div class="flex items-center">
<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">
{$t.migration?.replace_db }
</label>
</div>
</div>
{#if replaceDb}
<div class="mb-8 p-4 border rounded-md bg-gray-50">
<h3 class="text-md font-medium mb-4">
{$t.migration?.database_mappings }
</h3>
{#if fetchingDbs}
<p>
{$t.migration?.loading_dbs ||
"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"
>
{$t.migration?.refresh_dbs }
</button>
{/if}
</div>
{/if}
<Button
on:click={startMigration}
disabled={!sourceEnvId ||
!targetEnvId ||
sourceEnvId === targetEnvId ||
selectedDashboardIds.length === 0}
>
{$t.migration?.start }
</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] -->
<!-- [/DEF:MigrationDashboard:Component] -->