Files
ss-tools/frontend/src/routes/migration/+page.svelte

707 lines
27 KiB
Svelte

<!-- [DEF:frontend/src/routes/migration/+page.svelte:Module] -->
<!--
@PRE: Default pre condition
@DATA_CONTRACT: Default state data
@SIDE_EFFECT: None
@POST: Default post condition
@COMPLEXITY: 5
@SEMANTICS: migration, dashboard, environment, selection, database-replacement, dry-run, task-resume
@PURPOSE: Main migration dashboard page for environment selection, dry-run validation, and migration execution.
@LAYER: UI
@RELATION: [DEPENDS_ON] ->[frontend/src/lib/api.js]
@RELATION: [DEPENDS_ON] ->[stores]
@RELATION: [DEPENDS_ON] ->[taskService]
@RELATION: [BINDS_TO] ->[frontend/src/components/EnvSelector.svelte]
@RELATION: [BINDS_TO] ->[DashboardGrid]
@RELATION: [BINDS_TO] ->[frontend/src/components/MappingTable.svelte]
@RELATION: [BINDS_TO] ->[TaskRunner]
@RELATION: [BINDS_TO] ->[frontend/src/components/TaskHistory.svelte]
@RELATION: [BINDS_TO] ->[TaskLogViewer]
@RELATION: [BINDS_TO] ->[frontend/src/components/PasswordPrompt.svelte]
@INVARIANT: Migration start is blocked unless source and target environments are selected, distinct, and at least one dashboard is selected.
@UX_STATE: [Idle] -> User configures source/target environments, dashboard selection, and migration options.
@UX_STATE: [Loading] -> Environment/database/dry-run fetch operations disable relevant actions and show progress text.
@UX_STATE: [Error] -> Error banner/prompt message is shown while keeping user input intact for correction.
@UX_STATE: [Success] -> Dry-run summary or active task view is rendered after successful API operations.
@UX_FEEDBACK: Inline error banner, disabled CTA states, loading labels, dry-run summary cards, modal dialogs.
@UX_RECOVERY: User can adjust selection, refresh databases, retry dry-run/migration, resume task with passwords, or cancel modal flow.
@UX_REACTIVITY: State transitions rely on Svelte reactive bindings and store subscription to selectedTask.
@TEST_CONTRACT: [DashboardSelection + Environment IDs] -> [DryRunResult | TaskStartResult | ValidationError]
@TEST_SCENARIO: start_migration_valid_selection -> Starts backend task and switches to task view.
@TEST_SCENARIO: start_dry_run_valid_selection -> Renders pre-flight diff summary and risk panel.
@TEST_SCENARIO: awaiting_input_task_selected -> Opens password prompt with requested databases.
@TEST_FIXTURE: migration_dry_run_fixture -> file:backend/tests/fixtures/migration_dry_run_fixture.json
@TEST_EDGE: missing_field -> Empty source/target/selection blocks action and surfaces validation message.
@TEST_EDGE: invalid_type -> Malformed API payload is surfaced through error state.
@TEST_EDGE: external_fail -> API failures set error state and preserve recoverable UI controls.
@TEST_INVARIANT: migration_guardrails_enforced -> VERIFIED_BY: [start_migration_valid_selection, start_dry_run_valid_selection, missing_field]
-->
<!-- [DEF:MigrationDashboard:Component] -->
<!--
@PURPOSE: Orchestrate migration UI workflow and route user actions to backend APIs and task store.
@PRE: API client and component dependencies are available; i18n store is initialized.
@POST: User can progress through selection, dry-run, migration start, and task resume flows.
@SIDE_EFFECT: Performs HTTP requests, mutates local UI state, updates selectedTask store.
@DATA_CONTRACT: DashboardSelection -> MigrationDryRunResult | Task DTO (from /tasks endpoints)
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from "svelte";
import { fromStore } from "svelte/store";
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,
MigrationDryRunResult,
} from "../../types/dashboard";
import { t } from "$lib/i18n";
import { Button, Card, PageHeader } from "$lib/ui";
// [/SECTION]
// [SECTION: STATE]
let environments: any[] = $state([]);
let sourceEnvId = $state("");
let targetEnvId = $state("");
let replaceDb = $state(false);
let fixCrossFilters = $state(true);
let loading = $state(true);
let error = $state("");
let dashboards: DashboardMetadata[] = $state([]);
let selectedDashboardIds: number[] = $state([]);
let sourceDatabases: any[] = $state([]);
let targetDatabases: any[] = $state([]);
let mappings: any[] = $state([]);
let suggestions: any[] = $state([]);
let fetchingDbs = $state(false);
let dryRunLoading = $state(false);
let dryRunResult: MigrationDryRunResult | null = $state(null);
// UI State for Modals
let showLogViewer = $state(false);
let logViewerTaskId: string | null = $state(null);
let logViewerTaskStatus: string | null = $state(null);
let showPasswordPrompt = $state(false);
let passwordPromptDatabases: string[] = $state([]);
let passwordPromptErrorMessage = $state("");
// [/SECTION]
const selectedTaskState = fromStore(selectedTask);
const belief_scope = <T>(_id: string, fn: () => T): T => fn();
// [DEF:fetchEnvironments:Function]
/**
* @purpose Fetches the list of environments from the API.
* @pre None.
* @post environments state is updated.
*/
async function fetchEnvironments() {
return belief_scope("fetchEnvironments", async () => {
console.info("[fetchEnvironments][REASON] Initializing environment list for selection");
try {
environments = await api.getEnvironmentsList();
console.info("[fetchEnvironments][REFLECT] Environments loaded", { count: environments.length });
} catch (e) {
console.error("[fetchEnvironments][EXPLORE] Failed to fetch environments", 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) {
return belief_scope("fetchDashboards", async () => {
console.info("[fetchDashboards][REASON] Fetching dashboards for environment", { envId });
try {
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
selectedDashboardIds = []; // Reset selection when env changes
console.info("[fetchDashboards][REFLECT] Dashboards loaded", { count: dashboards.length });
} catch (e) {
console.error("[fetchDashboards][EXPLORE] Failed to fetch dashboards", e);
error = e.message;
dashboards = [];
}
});
}
// [/DEF:fetchDashboards:Function]
onMount(fetchEnvironments);
// [DEF:ReactiveDashboardFetch:Block]
/**
* @PURPOSE: Automatically fetch dashboards when the source environment is changed.
* @PRE: sourceEnvId is not empty.
* @POST: fetchDashboards is called with the new sourceEnvId.
* @UX_STATE: [Loading] -> Triggered when sourceEnvId changes.
*/
$effect(() => {
if (!sourceEnvId) return;
console.info("[ReactiveDashboardFetch][REASON] Source environment changed, fetching dashboards", { sourceEnvId });
void fetchDashboards(sourceEnvId);
});
// [/DEF:ReactiveDashboardFetch:Block]
// [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() {
return belief_scope("fetchDatabases", async () => {
if (!sourceEnvId || !targetEnvId) {
console.warn("[fetchDatabases][EXPLORE] Missing environment IDs for database fetch");
return;
}
console.info("[fetchDatabases][REASON] Fetching databases and suggestions for mapping", { sourceEnvId, targetEnvId });
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;
console.info("[fetchDatabases][REFLECT] Databases and mappings loaded", {
sourceCount: src.length,
targetCount: tgt.length,
mappingCount: maps.length
});
} catch (e) {
console.error("[fetchDatabases][EXPLORE] Failed to fetch databases", 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) {
return belief_scope("handleMappingUpdate", async () => {
const { sourceUuid, targetUuid } = event.detail;
const sDb = sourceDatabases.find((d) => d.uuid === sourceUuid);
const tDb = targetDatabases.find((d) => d.uuid === targetUuid);
if (!sDb || !tDb) {
console.warn("[handleMappingUpdate][EXPLORE] Database not found for mapping", { sourceUuid, targetUuid });
return;
}
console.info("[handleMappingUpdate][REASON] Updating database mapping", { sourceUuid, targetUuid });
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,
];
console.info("[handleMappingUpdate][REFLECT] Mapping saved successfully");
} catch (e) {
console.error("[handleMappingUpdate][EXPLORE] Failed to save mapping", 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) {
return belief_scope("handleViewLogs", () => {
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.
// [DEF:ReactivePasswordPrompt:Block]
/**
* @PURPOSE: Monitor selected task for input requests and trigger password prompt.
* @PRE: $selectedTask is not null and status is AWAITING_INPUT.
* @POST: showPasswordPrompt is set to true if input_request is database_password.
* @UX_STATE: [AwaitingInput] -> Password prompt modal is displayed.
*/
$effect(() => {
const activeTask = selectedTaskState.current;
if (
!activeTask ||
activeTask.status !== "AWAITING_INPUT" ||
!activeTask.input_request
) {
return;
}
const req = activeTask.input_request;
if (req.type === "database_password") {
console.info("[ReactivePasswordPrompt][REASON] Task awaiting database passwords", { taskId: activeTask.id });
passwordPromptDatabases = req.databases || [];
passwordPromptErrorMessage = req.error_message || "";
showPasswordPrompt = true;
}
});
// [/DEF:ReactivePasswordPrompt:Block]
// [/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) {
return belief_scope("handleResumeMigration", async () => {
if (!selectedTaskState.current) return;
const { passwords } = event.detail;
try {
await resumeTask(selectedTaskState.current.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: Initiates the migration process by sending the selection to the backend.
* @PRE: sourceEnvId and targetEnvId are set and different; at least one dashboard is selected.
* @POST: A migration task is created and selectedTask store is updated.
* @SIDE_EFFECT: Resets dryRunResult; updates error state on failure.
* @UX_STATE: [Loading] -> [Success] or [Error]
*/
async function startMigration() {
return belief_scope("startMigration", async () => {
if (!sourceEnvId || !targetEnvId) {
console.warn("[startMigration][EXPLORE] Missing environment selection");
error =
$t.migration?.select_both_envs ||
"Please select both source and target environments.";
return;
}
if (sourceEnvId === targetEnvId) {
console.warn("[startMigration][EXPLORE] Source and target environments are identical");
error =
$t.migration?.different_envs ||
"Source and target environments must be different.";
return;
}
if (selectedDashboardIds.length === 0) {
console.warn("[startMigration][EXPLORE] No dashboards selected");
error =
$t.migration?.select_dashboards ||
"Please select at least one dashboard to migrate.";
return;
}
console.info("[startMigration][REASON] Initiating migration execution", {
sourceEnvId,
targetEnvId,
dashboardCount: selectedDashboardIds.length
});
error = "";
try {
dryRunResult = null;
const selection: DashboardSelection = {
selected_ids: selectedDashboardIds,
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
replace_db_config: replaceDb,
fix_cross_filters: fixCrossFilters,
};
const result = await api.postApi("/migration/execute", selection);
console.info("[startMigration][REFLECT] Migration task created", { taskId: result.task_id });
// 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);
console.info("[startMigration][REFLECT] Task details fetched and store updated");
} catch (fetchErr) {
console.warn("[startMigration][EXPLORE] Could not fetch task details immediately, using placeholder", fetchErr);
selectedTask.set({
id: result.task_id,
plugin_id: "superset-migration",
status: "RUNNING",
logs: [],
params: {},
});
}
} catch (e) {
console.error("[startMigration][EXPLORE] Migration initiation failed", e);
error = e.message;
}
});
}
// [/DEF:startMigration:Function]
// [DEF:startDryRun:Function]
/**
* @PURPOSE: Performs a dry-run migration to identify potential risks and changes.
* @PRE: source/target environments and selected dashboards are valid.
* @POST: dryRunResult is populated with the pre-flight analysis.
* @UX_STATE: [Loading] -> [Success] or [Error]
* @UX_FEEDBACK: User sees summary cards + risk block + JSON details after success.
* @UX_RECOVERY: User can adjust selection and press Dry Run again.
*/
async function startDryRun() {
return belief_scope("startDryRun", async () => {
if (!sourceEnvId || !targetEnvId) {
console.warn("[startDryRun][EXPLORE] Missing environment selection");
error =
$t.migration?.select_both_envs ||
"Please select both source and target environments.";
return;
}
if (sourceEnvId === targetEnvId) {
console.warn("[startDryRun][EXPLORE] Source and target environments are identical");
error =
$t.migration?.different_envs ||
"Source and target environments must be different.";
return;
}
if (selectedDashboardIds.length === 0) {
console.warn("[startDryRun][EXPLORE] No dashboards selected");
error =
$t.migration?.select_dashboards ||
"Please select at least one dashboard to migrate.";
return;
}
console.info("[startDryRun][REASON] Initiating dry-run analysis", { sourceEnvId, targetEnvId });
error = "";
dryRunLoading = true;
try {
const selection: DashboardSelection = {
selected_ids: selectedDashboardIds,
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
replace_db_config: replaceDb,
fix_cross_filters: fixCrossFilters,
};
dryRunResult = await api.postApi("/migration/dry-run", selection);
console.info("[startDryRun][REFLECT] Dry-run analysis completed", { riskScore: dryRunResult.risk.score });
} catch (e) {
console.error("[startDryRun][EXPLORE] Dry-run analysis failed", e);
error = e.message;
dryRunResult = null;
} finally {
dryRunLoading = false;
}
});
}
// [/DEF:startDryRun:Function]
// [/DEF:MigrationDashboard:Component]
</script>
<!-- [DEF:MigrationDashboardView:Block] -->
<!--
@PURPOSE: Render migration configuration controls, action CTAs, dry-run results, and modal entry points.
@UX_STATE: Idle -> Configuration form is interactive.
@UX_STATE: Loading -> Loading messages and disabled buttons prevent duplicate actions.
@UX_STATE: Error -> Error banner is displayed without discarding current selection.
@UX_STATE: Success -> Dry-run details or TaskRunner content is presented.
@UX_FEEDBACK: Buttons, banners, cards, and dialogs provide immediate operation feedback.
@UX_RECOVERY: User can retry operations from the same page state.
-->
<!-- [SECTION: TEMPLATE] -->
<div class="max-w-4xl mx-auto p-6">
<!-- [DEF:MigrationHeader:Block] -->
<PageHeader title={$t.nav.migration} />
<!-- [/DEF:MigrationHeader:Block] -->
<!-- [DEF:TaskHistorySection:Block] -->
<TaskHistory />
<!-- [/DEF:TaskHistorySection:Block] -->
<!-- [DEF:ActiveTaskSection:Block] -->
{#if $selectedTask}
<div class="mt-6">
<TaskRunner />
<div class="mt-4">
<Button variant="secondary" onclick={() => {
console.info("[ActiveTaskSection][REASON] User cancelled active task view");
selectedTask.set(null);
}}>
{$t.common.cancel}
</Button>
</div>
</div>
{:else}
<!-- [/DEF:ActiveTaskSection:Block] -->
{#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}
<!-- [DEF:EnvironmentSelectionSection:Block] -->
<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:EnvironmentSelectionSection:Block] -->
<!-- [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] -->
<!-- [DEF:MigrationOptionsSection:Block] -->
<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}
onchange={() => {
console.info("[MigrationOptionsSection][REASON] Database replacement toggled", { replaceDb });
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>
<!-- [/DEF:MigrationOptionsSection:Block] -->
{#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}
onupdate={handleMappingUpdate}
/>
{:else if sourceEnvId && targetEnvId}
<button
onclick={fetchDatabases}
class="text-indigo-600 hover:text-indigo-500 text-sm font-medium"
>
{$t.migration?.refresh_dbs }
</button>
{/if}
</div>
{/if}
<div class="flex items-center gap-3">
<Button
variant="secondary"
onclick={startDryRun}
disabled={!sourceEnvId ||
!targetEnvId ||
sourceEnvId === targetEnvId ||
selectedDashboardIds.length === 0 ||
dryRunLoading}
>
{dryRunLoading ? "Dry Run..." : "Dry Run"}
</Button>
<Button
onclick={startMigration}
disabled={!sourceEnvId ||
!targetEnvId ||
sourceEnvId === targetEnvId ||
selectedDashboardIds.length === 0}
>
{$t.migration?.start || "Apply"}
</Button>
</div>
<!-- [DEF:DryRunResultsSection:Block] -->
{#if dryRunResult}
<div class="mt-6 rounded-md border border-slate-200 bg-slate-50 p-4 space-y-3">
<h3 class="text-base font-semibold">Pre-flight Diff</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div class="rounded border border-slate-200 bg-white p-3">
<div class="font-medium mb-1">Dashboards</div>
<div>create: {dryRunResult.summary.dashboards.create}</div>
<div>update: {dryRunResult.summary.dashboards.update}</div>
<div>delete: {dryRunResult.summary.dashboards.delete}</div>
</div>
<div class="rounded border border-slate-200 bg-white p-3">
<div class="font-medium mb-1">Charts</div>
<div>create: {dryRunResult.summary.charts.create}</div>
<div>update: {dryRunResult.summary.charts.update}</div>
<div>delete: {dryRunResult.summary.charts.delete}</div>
</div>
<div class="rounded border border-slate-200 bg-white p-3">
<div class="font-medium mb-1">Datasets</div>
<div>create: {dryRunResult.summary.datasets.create}</div>
<div>update: {dryRunResult.summary.datasets.update}</div>
<div>delete: {dryRunResult.summary.datasets.delete}</div>
</div>
</div>
<div class="rounded border border-slate-200 bg-white p-3 text-sm">
<div class="font-medium mb-1">Risk</div>
<div>
score: {dryRunResult.risk.score}, level: {dryRunResult.risk.level}
</div>
<div class="mt-1">
issues: {dryRunResult.risk.items.length}
</div>
</div>
<details class="rounded border border-slate-200 bg-white p-3">
<summary class="cursor-pointer font-medium">Diff JSON</summary>
<pre class="mt-2 max-h-72 overflow-auto text-xs">{JSON.stringify(dryRunResult, null, 2)}</pre>
</details>
</div>
{/if}
<!-- [/DEF:DryRunResultsSection:Block] -->
{/if}
</div>
<!-- [DEF:MigrationModals:Block] -->
<!--
@PURPOSE: Render overlay components for log viewing and password entry.
@UX_STATE: [LogViewing] -> TaskLogViewer is visible.
@UX_STATE: [AwaitingInput] -> PasswordPrompt is visible.
-->
<TaskLogViewer
bind:show={showLogViewer}
taskId={logViewerTaskId}
taskStatus={logViewerTaskStatus}
onclose={() => {
console.info("[MigrationModals][REASON] Closing log viewer");
showLogViewer = false;
}}
/>
<PasswordPrompt
bind:show={showPasswordPrompt}
databases={passwordPromptDatabases}
errorMessage={passwordPromptErrorMessage}
onresume={handleResumeMigration}
oncancel={() => {
console.info("[MigrationModals][REASON] User cancelled password prompt");
showPasswordPrompt = false;
}}
/>
<!-- [/DEF:MigrationModals:Block] -->
<!-- [/SECTION] -->
<!-- [/DEF:MigrationDashboardView:Block] -->
<!-- [/DEF:frontend/src/routes/migration/+page.svelte:Module] -->