707 lines
27 KiB
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] -->
|