feat(translate): move write settings to Target Config tab + fix datasource name lookup
Fixes: - Move batch size, upsert strategy, include source reference from Config tab to Target Config tab (Write Settings section) - Fix 'Dataset #26' fallback — look up real datasource name via API when source_table is not saved in job data - Persist include_source_reference in save payload and restore from job data on load (was a pre-existing gap) QA review fixes: - Update @BRIEF contracts for both tabs - Fix import indentation in +page.svelte Other: help tooltips on translate pages, flow hint on list page, git migration manager components, dashboard hub pages, migration settings pages
This commit is contained in:
@@ -1,39 +1,38 @@
|
||||
<!-- #region MigrationPage [C:4] [TYPE Page] [SEMANTICS sveltekit, migration, dashboard, dry-run, environment] -->
|
||||
<!-- #region MigrationPage [C:4] [TYPE Page] [SEMANTICS sveltekit, migration, dashboard, dry-run, environment, model-first] -->
|
||||
<!--
|
||||
@SEMANTICS: migration, dashboard, environment, dry-run, task-resume
|
||||
@BRIEF: Main migration dashboard page for environment selection, dry-run validation, and migration execution.
|
||||
@SEMANTICS: migration, dashboard, environment, dry-run, task-resume, model-first
|
||||
@BRIEF: Migration page — thin rendering layer. ALL logic lives in MigrationModel.
|
||||
@LAYER UI
|
||||
@RELATION DEPENDS_ON ->[ApiModule]
|
||||
@RELATION DEPENDS_ON ->[EXT:frontend:stores]
|
||||
@RELATION DEPENDS_ON ->[EXT:frontend:taskService]
|
||||
@RELATION BINDS_TO ->[EnvSelector]
|
||||
@RELATION BINDS_TO ->[DashboardGrid]
|
||||
@RELATION BINDS_TO ->[MappingTable]
|
||||
@RELATION BINDS_TO ->[EXT:frontend:TaskRunner]
|
||||
@RELATION BINDS_TO ->[TaskHistory]
|
||||
@RELATION BINDS_TO ->[TaskLogViewer]
|
||||
@RELATION BINDS_TO ->[PasswordPrompt]
|
||||
@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 through a step-by-step wizard.
|
||||
@UX_STATE: [Loading] -> Environment/database/dry-run fetch operations disable relevant actions and show skeleton loaders.
|
||||
@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.
|
||||
@RELATION BINDS_TO -> [MigrationModel]
|
||||
@RELATION DEPENDS_ON ->[EnvSelector]
|
||||
@RELATION DEPENDS_ON ->[DashboardGrid]
|
||||
@RELATION DEPENDS_ON ->[MappingTable]
|
||||
@RELATION DEPENDS_ON ->[EXT:frontend:TaskRunner]
|
||||
@RELATION DEPENDS_ON ->[TaskHistory]
|
||||
@RELATION DEPENDS_ON ->[TaskLogViewer]
|
||||
@RELATION DEPENDS_ON ->[PasswordPrompt]
|
||||
@INVARIANT: This component contains NO business logic — it only renders model state and calls model.action().
|
||||
@UX_STATE: [idle] -> Skeleton loader while environments load.
|
||||
@UX_STATE: [ready] -> Step wizard with env selection, dashboards, options.
|
||||
@UX_STATE: [loading] -> Skeleton/spinner during API calls (dry-run, DB fetch).
|
||||
@UX_STATE: [review] -> Dry-run summary with diff cards, risk assessment.
|
||||
@UX_STATE: [executing] -> Task runner view with WebSocket logs.
|
||||
@UX_STATE: [error] -> Red banner with error message and dismiss button.
|
||||
@UX_FEEDBACK: Inline error banner, disabled CTA states, loading spinners.
|
||||
@UX_RECOVERY: Dismiss error, retry dry-run/migration, resume with passwords.
|
||||
@UX_REACTIVITY: ALL state from MigrationModel atoms. Password prompt via $effect → checkPasswordPrompt().
|
||||
@TEST_CONTRACT: [Model state] -> [Rendered UI]
|
||||
@TEST_SCENARIO: render_with_model -> Component renders step 1 with env selector.
|
||||
@TEST_SCENARIO: step_navigation -> Buttons call model.goToStep().
|
||||
@TEST_SCENARIO: password_prompt -> $effect triggers model.checkPasswordPrompt() on AWAITING_INPUT.
|
||||
@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]
|
||||
@TEST_EDGE: missing_field -> Validation error surfaced from model.
|
||||
@TEST_EDGE: invalid_type -> API errors from model reflected in error state.
|
||||
@TEST_EDGE: external_fail -> Model sets error, component renders error banner.
|
||||
@TEST_INVARIANT: component_has_no_business_logic -> VERIFIED_BY: [render_with_model, step_navigation]
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from "svelte";
|
||||
import { fromStore } from "svelte/store";
|
||||
import EnvSelector from "../../components/EnvSelector.svelte";
|
||||
@@ -43,10 +42,7 @@
|
||||
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 { environmentContextStore } from '$lib/stores/environmentContext.js';
|
||||
import { MigrationModel } from "../../lib/models/MigrationModel.svelte.ts";
|
||||
import type {
|
||||
DashboardMetadata,
|
||||
DashboardSelection,
|
||||
@@ -54,371 +50,61 @@
|
||||
} 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);
|
||||
let currentStep = $state(1); // 1=environments, 2=dashboards, 3=review, 4=execute
|
||||
// ── Model: single source of truth ───────────────────────────────
|
||||
const model = new MigrationModel();
|
||||
|
||||
// UI State for Modals
|
||||
let showLogViewer = $state(false);
|
||||
let logViewerTaskId: string | null = $state(null);
|
||||
let logViewerTaskStatus: string | null = $state(null);
|
||||
// Store bindings (reactive, read from stores for TaskRunner + PasswordPrompt)
|
||||
const selectedTaskStore = model.selectedTaskStore;
|
||||
const taskSnap = fromStore(selectedTaskStore);
|
||||
|
||||
let showPasswordPrompt = $state(false);
|
||||
let passwordPromptDatabases: string[] = $state([]);
|
||||
let passwordPromptErrorMessage = $state("");
|
||||
// ── Lifecycle ──────────────────────────────────────────────────
|
||||
onMount(() => model.loadEnvironments());
|
||||
|
||||
// UI State
|
||||
let showTaskHistory = $state(false);
|
||||
// [/SECTION]
|
||||
|
||||
const selectedTaskState = fromStore(selectedTask);
|
||||
const envContextState = fromStore(environmentContextStore);
|
||||
|
||||
const belief_scope = <T>(_id: string, fn: () => T): T => fn();
|
||||
|
||||
// Derived: steps completion status
|
||||
let stepReady = $derived({
|
||||
1: sourceEnvId !== "" && targetEnvId !== "" && sourceEnvId !== targetEnvId,
|
||||
2: selectedDashboardIds.length > 0,
|
||||
3: true, // review is always "ready" if we got here
|
||||
});
|
||||
|
||||
// #region fetchEnvironments:Function [TYPE Function]
|
||||
async function fetchEnvironments() {
|
||||
return belief_scope("fetchEnvironments", async () => {
|
||||
console.info("[EXT:frontend:fetchEnvironments][REASON] Initializing environment list for selection");
|
||||
try {
|
||||
environments = await api.getEnvironmentsList();
|
||||
// Pre-fill source with active environment, leave target empty
|
||||
const activeEnvId = envContextState.current?.selectedEnvId;
|
||||
if (activeEnvId && environments.some((env) => env.id === activeEnvId)) {
|
||||
sourceEnvId = activeEnvId;
|
||||
}
|
||||
console.info("[EXT:frontend:fetchEnvironments][REFLECT] Environments loaded", { count: environments.length });
|
||||
} catch (e) {
|
||||
console.error("[EXT:frontend:fetchEnvironments][EXPLORE] Failed to fetch environments", e);
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endregion fetchEnvironments:Function
|
||||
|
||||
// #region fetchDashboards:Function [TYPE Function]
|
||||
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 = [];
|
||||
console.info("[fetchDashboards][REFLECT] Dashboards loaded", { count: dashboards.length });
|
||||
} catch (e) {
|
||||
console.error("[fetchDashboards][EXPLORE] Failed to fetch dashboards", e);
|
||||
error = e.message;
|
||||
dashboards = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endregion fetchDashboards:Function
|
||||
|
||||
onMount(fetchEnvironments);
|
||||
|
||||
// #region ReactiveDashboardFetch:Block [TYPE Function]
|
||||
// Reactive: watch for password-prompt signals from active task
|
||||
$effect(() => {
|
||||
if (!sourceEnvId) return;
|
||||
console.info("[ReactiveDashboardFetch][REASON] Source environment changed, fetching dashboards", { sourceEnvId });
|
||||
void fetchDashboards(sourceEnvId);
|
||||
model.checkPasswordPrompt();
|
||||
});
|
||||
// #endregion ReactiveDashboardFetch:Block
|
||||
|
||||
// #region fetchDatabases:Function [TYPE Function]
|
||||
async function fetchDatabases() {
|
||||
return belief_scope("fetchDatabases", async () => {
|
||||
if (!sourceEnvId || !targetEnvId) {
|
||||
console.warn("[EXT:frontend:fetchDatabases][EXPLORE] Missing environment IDs for database fetch");
|
||||
return;
|
||||
}
|
||||
console.info("[EXT:frontend: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("[EXT:frontend:fetchDatabases][REFLECT] Databases and mappings loaded", {
|
||||
sourceCount: src.length,
|
||||
targetCount: tgt.length,
|
||||
mappingCount: maps.length
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[EXT:frontend:fetchDatabases][EXPLORE] Failed to fetch databases", e);
|
||||
error = e.message;
|
||||
} finally {
|
||||
fetchingDbs = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endregion fetchDatabases:Function
|
||||
|
||||
// #region handleMappingUpdate:Function [TYPE Function]
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endregion handleMappingUpdate:Function
|
||||
|
||||
// #region handleViewLogs:Function [TYPE Function]
|
||||
function handleViewLogs(event: CustomEvent) {
|
||||
return belief_scope("handleViewLogs", () => {
|
||||
const task = event.detail;
|
||||
logViewerTaskId = task.id;
|
||||
logViewerTaskStatus = task.status;
|
||||
showLogViewer = true;
|
||||
});
|
||||
}
|
||||
// #endregion handleViewLogs:Function
|
||||
|
||||
// #region handlePasswordPrompt:Function [TYPE Function]
|
||||
// #region ReactivePasswordPrompt:Block [TYPE Function]
|
||||
// @RATIONALE This $effect exists because DashboardGrid uses bind:selectedIds which bypasses model.toggleDashboard().
|
||||
// The model self-enforces the invariant via toggleDashboard(), but the bind: path is outside model control.
|
||||
// This guard ensures dryRunResult is cleared regardless of the mutation path.
|
||||
// @INVARIANT: Dashboard selection change clears stale dry-run result
|
||||
$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;
|
||||
}
|
||||
const _ = model.selectedDashboardIds; // always track
|
||||
if (model.dryRunResult) {
|
||||
model.dryRunResult = null;
|
||||
}
|
||||
});
|
||||
// #endregion ReactivePasswordPrompt:Block
|
||||
// #endregion handlePasswordPrompt:Function
|
||||
|
||||
// #region handleResumeMigration:Function [TYPE Function]
|
||||
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;
|
||||
} catch (e) {
|
||||
console.error("Failed to resume task:", e);
|
||||
passwordPromptErrorMessage =
|
||||
e.message || $t.migration?.resume_failed;
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endregion handleResumeMigration:Function
|
||||
|
||||
// #region startMigration:Function [TYPE Function]
|
||||
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 });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endregion startMigration:Function
|
||||
|
||||
// #region startDryRun:Function [TYPE Function]
|
||||
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 });
|
||||
currentStep = 3; // Move to review step on successful dry-run
|
||||
} catch (e) {
|
||||
console.error("[startDryRun][EXPLORE] Dry-run analysis failed", e);
|
||||
error = e.message;
|
||||
dryRunResult = null;
|
||||
} finally {
|
||||
dryRunLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endregion startDryRun:Function
|
||||
|
||||
// Helper for step navigation
|
||||
function goToStep(step: number) {
|
||||
if (step <= currentStep || (step === 2 && stepReady[1]) || (step === 3 && stepReady[1] && stepReady[2]) || (step === 4 && dryRunResult)) {
|
||||
currentStep = step;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="max-w-6xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<!-- #region MigrationHeader -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<PageHeader title={$t.nav.migration} />
|
||||
<button
|
||||
onclick={() => showTaskHistory = !showTaskHistory}
|
||||
onclick={() => model.toggleTaskHistory()}
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
{$t.tasks?.recent || "Task History"}
|
||||
<svg class="w-4 h-4 transition-transform" class:rotate-180={showTaskHistory} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 transition-transform" class:rotate-180={model.showTaskHistory} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- #endregion MigrationHeader -->
|
||||
|
||||
{#if $selectedTask}
|
||||
{#if taskSnap.current}
|
||||
<!-- #region ActiveTaskSection -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">
|
||||
{$t.tasks?.task_label || "Task"}
|
||||
</h2>
|
||||
<Button variant="secondary" onclick={() => selectedTask.set(null)}>
|
||||
<Button variant="secondary" onclick={() => selectedTaskStore.set(null)}>
|
||||
{$t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -427,7 +113,7 @@
|
||||
<!-- #endregion ActiveTaskSection -->
|
||||
{:else}
|
||||
<!-- #region TaskHistoryPanel -->
|
||||
{#if showTaskHistory}
|
||||
{#if model.showTaskHistory}
|
||||
<div class="mb-6 overflow-hidden transition-all">
|
||||
<TaskHistory pluginId="superset-migration" />
|
||||
</div>
|
||||
@@ -444,21 +130,21 @@
|
||||
{ step: 4, label: $t.migration?.start || "Migrate" },
|
||||
] as s}
|
||||
<button
|
||||
onclick={() => goToStep(s.step)}
|
||||
disabled={s.step > currentStep && !((s.step === 2 && stepReady[1]) || (s.step === 3 && stepReady[1] && stepReady[2]) || (s.step === 4 && dryRunResult))}
|
||||
onclick={() => model.goToStep(s.step)}
|
||||
disabled={s.step > model.currentStep && !((s.step === 2 && model.stepReady[1]) || (s.step === 3 && model.stepReady[1] && model.stepReady[2]) || (s.step === 4 && model.dryRunResult))}
|
||||
class="flex flex-col items-center group"
|
||||
>
|
||||
<div class={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold border-2 transition-all
|
||||
${currentStep > s.step
|
||||
${model.currentStep > s.step
|
||||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||||
: currentStep === s.step
|
||||
: model.currentStep === s.step
|
||||
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
|
||||
: 'bg-white border-gray-300 text-gray-400'
|
||||
}
|
||||
${(s.step <= currentStep || (s.step === 2 && stepReady[1]) || (s.step === 3 && stepReady[1] && stepReady[2])) ? 'cursor-pointer hover:border-indigo-400' : 'cursor-not-allowed'}
|
||||
${(s.step <= model.currentStep || (s.step === 2 && model.stepReady[1]) || (s.step === 3 && model.stepReady[1] && model.stepReady[2])) ? 'cursor-pointer hover:border-indigo-400' : 'cursor-not-allowed'}
|
||||
`}>
|
||||
{#if currentStep > s.step}
|
||||
{#if model.currentStep > s.step}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
@@ -467,13 +153,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
<span class="mt-2 text-xs font-medium hidden sm:block
|
||||
${currentStep === s.step ? 'text-indigo-700' : 'text-gray-500'}
|
||||
${model.currentStep === s.step ? 'text-indigo-700' : 'text-gray-500'}
|
||||
">{s.label}</span>
|
||||
</button>
|
||||
{#if s.step < 4}
|
||||
<div class="flex-1 mx-2 mb-6">
|
||||
<div class="h-0.5 bg-gray-200 rounded">
|
||||
<div class="h-0.5 bg-indigo-500 rounded transition-all duration-500" style="width: {currentStep > s.step ? '100%' : '0%'}"></div>
|
||||
<div class="h-0.5 bg-indigo-500 rounded transition-all duration-500" style="width: {model.currentStep > s.step ? '100%' : '0%'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -483,7 +169,7 @@
|
||||
<!-- #endregion StepProgressBar -->
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if loading}
|
||||
{#if model.loading}
|
||||
<div class="space-y-6">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
@@ -495,7 +181,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
{:else if error}
|
||||
{:else if model.error}
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-5 h-5 text-red-500 mt-0.5">
|
||||
@@ -504,9 +190,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800">{error}</p>
|
||||
<p class="text-sm font-medium text-red-800">{model.error}</p>
|
||||
</div>
|
||||
<button onclick={() => error = ""} class="text-red-400 hover:text-red-600" aria-label={$t.common?.close || "Close"}>
|
||||
<button onclick={() => model.clearError()} class="text-red-400 hover:text-red-600" aria-label={$t.common?.close || "Close"}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -516,7 +202,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- #region Step1: Environment Selection -->
|
||||
{#if currentStep === 1}
|
||||
{#if model.currentStep === 1}
|
||||
<Card padding="none" class="mb-6">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
@@ -531,8 +217,9 @@
|
||||
</div>
|
||||
<EnvSelector
|
||||
label={$t.migration?.source_env || "Source Environment"}
|
||||
bind:selectedId={sourceEnvId}
|
||||
{environments}
|
||||
selectedId={model.sourceEnvId}
|
||||
environments={model.environments}
|
||||
onchange={({ id }) => model.selectSourceEnv(id)}
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4 border border-gray-200 rounded-xl bg-white hover:border-indigo-300 transition-colors">
|
||||
@@ -542,12 +229,13 @@
|
||||
</div>
|
||||
<EnvSelector
|
||||
label={$t.migration?.target_env || "Target Environment"}
|
||||
bind:selectedId={targetEnvId}
|
||||
{environments}
|
||||
selectedId={model.targetEnvId}
|
||||
environments={model.environments}
|
||||
onchange={({ id }) => model.selectTargetEnv(id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if sourceEnvId && targetEnvId && sourceEnvId === targetEnvId}
|
||||
{#if model.sourceEnvId && model.targetEnvId && model.sourceEnvId === model.targetEnvId}
|
||||
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p class="text-sm text-amber-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -558,7 +246,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-6 flex justify-end">
|
||||
<Button onclick={() => goToStep(2)} disabled={!stepReady[1]}>
|
||||
<Button onclick={() => model.goToStep(2)} disabled={!model.stepReady[1]}>
|
||||
{$t.dashboard?.next || "Next"} →
|
||||
</Button>
|
||||
</div>
|
||||
@@ -568,7 +256,7 @@
|
||||
<!-- #endregion Step1: Environment Selection -->
|
||||
|
||||
<!-- #region Step2: Dashboard Selection & Options -->
|
||||
{#if currentStep === 2}
|
||||
{#if model.currentStep === 2}
|
||||
<Card padding="none" class="mb-6">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
@@ -576,19 +264,19 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900">{$t.migration?.select_dashboards_title || "Select Dashboards"}</h3>
|
||||
</div>
|
||||
|
||||
{#if sourceEnvId}
|
||||
{#if model.sourceEnvId}
|
||||
<div class="mb-6">
|
||||
<DashboardGrid
|
||||
{dashboards}
|
||||
bind:selectedIds={selectedDashboardIds}
|
||||
environmentId={sourceEnvId}
|
||||
dashboards={model.dashboards}
|
||||
bind:selectedIds={model.selectedDashboardIds}
|
||||
environmentId={model.sourceEnvId}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{:else}
|
||||
<p class="text-gray-500 italic">
|
||||
{$t.dashboard?.select_source || "Select a source environment to view dashboards."}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Migration Options -->
|
||||
<div class="border-t border-gray-100 pt-4 mt-2">
|
||||
@@ -597,7 +285,8 @@
|
||||
<label class="flex items-start gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={fixCrossFilters}
|
||||
checked={model.fixCrossFilters}
|
||||
onchange={(e) => model.setFixCrossFilters(e.currentTarget.checked)}
|
||||
class="mt-0.5 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<div>
|
||||
@@ -609,7 +298,8 @@
|
||||
<label class="flex items-start gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={replaceDb}
|
||||
checked={model.replaceDb}
|
||||
onchange={(e) => model.setReplaceDb(e.currentTarget.checked)}
|
||||
class="mt-0.5 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<div>
|
||||
@@ -621,53 +311,53 @@
|
||||
</div>
|
||||
|
||||
<!-- Database Mapping Section -->
|
||||
{#if replaceDb}
|
||||
{#if model.replaceDb}
|
||||
<div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700">{$t.migration?.database_mappings || "Database Mappings"}</h4>
|
||||
<button
|
||||
onclick={fetchDatabases}
|
||||
disabled={fetchingDbs || !sourceEnvId || !targetEnvId}
|
||||
onclick={() => model.fetchDatabases()}
|
||||
disabled={model.fetchingDbs || !model.sourceEnvId || !model.targetEnvId}
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-800 disabled:text-gray-400 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" class:animate-spin={fetchingDbs} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3.5 h-3.5" class:animate-spin={model.fetchingDbs} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{$t.migration?.fetch_dbs || "Fetch Databases"}
|
||||
</button>
|
||||
</div>
|
||||
{#if fetchingDbs}
|
||||
{#if model.fetchingDbs}
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{$t.migration?.loading_dbs || "Loading databases and suggestions..."}</span>
|
||||
</div>
|
||||
{:else if sourceDatabases.length > 0}
|
||||
{:else if model.sourceDatabases.length > 0}
|
||||
<MappingTable
|
||||
{sourceDatabases}
|
||||
{targetDatabases}
|
||||
{mappings}
|
||||
{suggestions}
|
||||
onupdate={handleMappingUpdate}
|
||||
sourceDatabases={model.sourceDatabases}
|
||||
targetDatabases={model.targetDatabases}
|
||||
mappings={model.mappings}
|
||||
suggestions={model.suggestions}
|
||||
onupdate={(e) => model.saveMapping(e.detail.sourceUuid, e.detail.targetUuid)}
|
||||
/>
|
||||
{:else if sourceEnvId && targetEnvId}
|
||||
{:else if model.sourceEnvId && model.targetEnvId}
|
||||
<p class="text-sm text-gray-500">{$t.migration?.mapping_hint || "Select environments and click \"Fetch Databases\" to start mapping."}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<Button variant="secondary" onclick={() => goToStep(1)}>
|
||||
<Button variant="secondary" onclick={() => model.goToStep(1)}>
|
||||
← {$t.common.back}
|
||||
</Button>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={startDryRun}
|
||||
disabled={!stepReady[2] || dryRunLoading}
|
||||
onclick={() => model.calculateDryRun()}
|
||||
disabled={!model.stepReady[2] || model.dryRunLoading}
|
||||
>
|
||||
{#if dryRunLoading}
|
||||
{#if model.dryRunLoading}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
{/if}
|
||||
{$t.dashboard?.view_summary || "Dry Run"}
|
||||
@@ -680,7 +370,7 @@
|
||||
<!-- #endregion Step2: Dashboard Selection & Options -->
|
||||
|
||||
<!-- #region Step3: Review & Dry-Run Results -->
|
||||
{#if currentStep === 3}
|
||||
{#if model.currentStep === 3}
|
||||
<Card padding="none" class="mb-6">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
@@ -688,7 +378,7 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900">{$t.tasks?.summary_report || "Review & Validate"}</h3>
|
||||
</div>
|
||||
|
||||
{#if dryRunResult}
|
||||
{#if model.dryRunResult}
|
||||
<!-- Selection Summary -->
|
||||
<div class="mb-6 p-4 bg-indigo-50 border border-indigo-100 rounded-xl">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
@@ -698,7 +388,7 @@
|
||||
<h3 class="text-sm font-semibold text-indigo-800">{$t.dashboard?.selected_dashboards || "Selected Dashboards"}</h3>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each dryRunResult.selected_dashboard_titles || [] as title}
|
||||
{#each model.dryRunResult.selected_dashboard_titles || [] as title}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700">
|
||||
{title}
|
||||
</span>
|
||||
@@ -721,15 +411,15 @@
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-emerald-600 font-medium">Create</span>
|
||||
<span class="font-semibold text-emerald-600">{dryRunResult.summary.dashboards.create}</span>
|
||||
<span class="font-semibold text-emerald-600">{model.dryRunResult.summary.dashboards.create}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-amber-600 font-medium">Update</span>
|
||||
<span class="font-semibold text-amber-600">{dryRunResult.summary.dashboards.update}</span>
|
||||
<span class="font-semibold text-amber-600">{model.dryRunResult.summary.dashboards.update}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-red-600 font-medium">Delete</span>
|
||||
<span class="font-semibold text-red-600">{dryRunResult.summary.dashboards.delete}</span>
|
||||
<span class="font-semibold text-red-600">{model.dryRunResult.summary.dashboards.delete}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -747,15 +437,15 @@
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-emerald-600 font-medium">Create</span>
|
||||
<span class="font-semibold text-emerald-600">{dryRunResult.summary.charts.create}</span>
|
||||
<span class="font-semibold text-emerald-600">{model.dryRunResult.summary.charts.create}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-amber-600 font-medium">Update</span>
|
||||
<span class="font-semibold text-amber-600">{dryRunResult.summary.charts.update}</span>
|
||||
<span class="font-semibold text-amber-600">{model.dryRunResult.summary.charts.update}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-red-600 font-medium">Delete</span>
|
||||
<span class="font-semibold text-red-600">{dryRunResult.summary.charts.delete}</span>
|
||||
<span class="font-semibold text-red-600">{model.dryRunResult.summary.charts.delete}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -773,15 +463,15 @@
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-emerald-600 font-medium">Create</span>
|
||||
<span class="font-semibold text-emerald-600">{dryRunResult.summary.datasets.create}</span>
|
||||
<span class="font-semibold text-emerald-600">{model.dryRunResult.summary.datasets.create}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-amber-600 font-medium">Update</span>
|
||||
<span class="font-semibold text-amber-600">{dryRunResult.summary.datasets.update}</span>
|
||||
<span class="font-semibold text-amber-600">{model.dryRunResult.summary.datasets.update}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-red-600 font-medium">Delete</span>
|
||||
<span class="font-semibold text-red-600">{dryRunResult.summary.datasets.delete}</span>
|
||||
<span class="font-semibold text-red-600">{model.dryRunResult.summary.datasets.delete}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -797,26 +487,24 @@
|
||||
<h3 class="text-sm font-semibold text-gray-700">{$t.tasks?.issues || "Risk Assessment"}</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Risk Score Badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">Score:</span>
|
||||
<div class={`
|
||||
px-2.5 py-1 rounded-full text-xs font-bold
|
||||
${dryRunResult.risk.score <= 20 ? 'bg-emerald-100 text-emerald-700' :
|
||||
dryRunResult.risk.score <= 50 ? 'bg-amber-100 text-amber-700' :
|
||||
${model.dryRunResult.risk.score <= 20 ? 'bg-emerald-100 text-emerald-700' :
|
||||
model.dryRunResult.risk.score <= 50 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-red-100 text-red-700'}
|
||||
`}>
|
||||
{dryRunResult.risk.score}/100
|
||||
{model.dryRunResult.risk.score}/100
|
||||
</div>
|
||||
</div>
|
||||
<!-- Risk Level -->
|
||||
<div class={`
|
||||
px-2.5 py-1 rounded-full text-xs font-bold
|
||||
${dryRunResult.risk.level === 'low' ? 'bg-emerald-100 text-emerald-700' :
|
||||
dryRunResult.risk.level === 'medium' ? 'bg-amber-100 text-amber-700' :
|
||||
${model.dryRunResult.risk.level === 'low' ? 'bg-emerald-100 text-emerald-700' :
|
||||
model.dryRunResult.risk.level === 'medium' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-red-100 text-red-700'}
|
||||
`}>
|
||||
{dryRunResult.risk.level.toUpperCase()}
|
||||
{model.dryRunResult.risk.level.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -825,17 +513,17 @@
|
||||
<div class="w-full h-2 bg-gray-100 rounded-full mb-4 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-700"
|
||||
class:bg-emerald-500={dryRunResult.risk.score <= 20}
|
||||
class:bg-amber-500={dryRunResult.risk.score > 20 && dryRunResult.risk.score <= 50}
|
||||
class:bg-red-500={dryRunResult.risk.score > 50}
|
||||
style="width: {dryRunResult.risk.score}%"
|
||||
class:bg-emerald-500={model.dryRunResult.risk.score <= 20}
|
||||
class:bg-amber-500={model.dryRunResult.risk.score > 20 && model.dryRunResult.risk.score <= 50}
|
||||
class:bg-red-500={model.dryRunResult.risk.score > 50}
|
||||
style="width: {model.dryRunResult.risk.score}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Risk Items -->
|
||||
{#if dryRunResult.risk.items && dryRunResult.risk.items.length > 0}
|
||||
{#if model.dryRunResult.risk.items && model.dryRunResult.risk.items.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each dryRunResult.risk.items as item}
|
||||
{#each model.dryRunResult.risk.items as item}
|
||||
<div class="flex items-start gap-3 p-2.5 rounded-lg text-sm
|
||||
${item.severity === 'high' ? 'bg-red-50' :
|
||||
item.severity === 'medium' ? 'bg-amber-50' : 'bg-blue-50'}
|
||||
@@ -857,7 +545,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON Details (collapsible) -->
|
||||
<!-- Raw JSON Details -->
|
||||
<details class="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<summary class="px-4 py-3 bg-gray-50 cursor-pointer text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -865,7 +553,7 @@
|
||||
</svg>
|
||||
{$t.tasks?.diff_preview || "Diff JSON"}
|
||||
</summary>
|
||||
<pre class="p-4 max-h-72 overflow-auto text-xs bg-gray-50 text-gray-700">{JSON.stringify(dryRunResult, null, 2)}</pre>
|
||||
<pre class="p-4 max-h-72 overflow-auto text-xs bg-gray-50 text-gray-700">{JSON.stringify(model.dryRunResult, null, 2)}</pre>
|
||||
</details>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-8 text-gray-400">
|
||||
@@ -877,15 +565,15 @@
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex items-center justify-between border-t border-gray-100 pt-6">
|
||||
<Button variant="secondary" onclick={() => goToStep(2)}>
|
||||
<Button variant="secondary" onclick={() => model.goToStep(2)}>
|
||||
← {$t.common.back}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
currentStep = 4;
|
||||
startMigration();
|
||||
model.goToStep(4);
|
||||
model.executeMigration();
|
||||
}}
|
||||
disabled={!dryRunResult}
|
||||
disabled={!model.dryRunResult}
|
||||
>
|
||||
{$t.migration?.start || "Start Migration"}
|
||||
</Button>
|
||||
@@ -896,7 +584,7 @@
|
||||
<!-- #endregion Step3: Review & Dry-Run Results -->
|
||||
|
||||
<!-- #region Step4: Migration in Progress (handled by ActiveTaskSection above) -->
|
||||
{#if currentStep === 4 && !$selectedTask}
|
||||
{#if model.currentStep === 4 && !taskSnap.current}
|
||||
<Card padding="none" class="mb-6">
|
||||
<div class="flex flex-col items-center justify-center p-12">
|
||||
<svg class="w-16 h-16 text-indigo-400 mb-4 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -904,23 +592,23 @@
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-2">{$t.migration?.starting || "Starting Migration..."}</h3>
|
||||
<p class="text-sm text-gray-500">{$t.common.loading || "Please wait..."}</p>
|
||||
<Button variant="secondary" class="mt-6" onclick={() => currentStep = 3}>
|
||||
<Button variant="secondary" class="mt-6" onclick={() => model.goToStep(3)}>
|
||||
← {$t.common.back}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
<!-- #endregion Step4: Migration in Progress (handled by ActiveTaskSection above) -->
|
||||
<!-- #endregion Step4: Migration in Progress -->
|
||||
|
||||
<!-- Quick Actions when no step is active -->
|
||||
{#if !currentStep || currentStep === 0}
|
||||
{#if !model.currentStep || model.currentStep === 0}
|
||||
<Card class="mb-6">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
<p class="text-sm">{$t.dashboard?.migrate_modal_title || "Select environments to start"}</p>
|
||||
<button onclick={() => currentStep = 1} class="mt-4 text-indigo-600 hover:text-indigo-800 text-sm font-medium">
|
||||
<button onclick={() => model.goToStep(1)} class="mt-4 text-indigo-600 hover:text-indigo-800 text-sm font-medium">
|
||||
{$t.migration?.source_env || "Get Started"} →
|
||||
</button>
|
||||
</div>
|
||||
@@ -931,24 +619,18 @@
|
||||
|
||||
<!-- #region MigrationModals -->
|
||||
<TaskLogViewer
|
||||
bind:show={showLogViewer}
|
||||
taskId={logViewerTaskId}
|
||||
taskStatus={logViewerTaskStatus}
|
||||
onclose={() => {
|
||||
console.info("[MigrationModals][REASON] Closing log viewer");
|
||||
showLogViewer = false;
|
||||
}}
|
||||
show={model.showLogViewer}
|
||||
taskId={model.logViewerTaskId}
|
||||
taskStatus={model.logViewerTaskStatus}
|
||||
onclose={() => model.closeLogViewer()}
|
||||
/>
|
||||
|
||||
<PasswordPrompt
|
||||
bind:show={showPasswordPrompt}
|
||||
databases={passwordPromptDatabases}
|
||||
errorMessage={passwordPromptErrorMessage}
|
||||
onresume={handleResumeMigration}
|
||||
oncancel={() => {
|
||||
console.info("[MigrationModals][REASON] User cancelled password prompt");
|
||||
showPasswordPrompt = false;
|
||||
}}
|
||||
show={model.showPasswordPrompt}
|
||||
databases={model.passwordPromptDatabases}
|
||||
errorMessage={model.passwordPromptErrorMessage}
|
||||
onresume={(e) => model.resumeMigration(e.detail.passwords)}
|
||||
oncancel={() => model.showPasswordPrompt = false}
|
||||
/>
|
||||
<!-- #endregion MigrationModals -->
|
||||
|
||||
|
||||
Reference in New Issue
Block a user