// frontend/src/lib/models/MigrationModel.svelte.ts // #region Migration.Model [C:4] [TYPE Model] [SEMANTICS migration,dashboard,dry-run,environment,screen-model] // @defgroup Migration Migration wizard — multi-step screen model for dashboard migration. // @INVARIANT Changing source environment resets dashboard selection to empty. // @INVARIANT Changing source environment resets databases, mappings, and suggestions. // @INVARIANT Changing source environment clears dry-run result. // @INVARIANT Migration execution blocked unless source≠target and ≥1 dashboard selected. // @INVARIANT Dry-run must complete before advancing to execution step. // @INVARIANT Password prompt only appears for AWAITING_INPUT tasks with type "database_password". // @INVARIANT DECOMPOSITION GATE (was 457 lines). Split: WizardModel (step nav), ExecutorModel (dry-run/exec/password). // @STATE idle — Initial state, environments loading. // @STATE ready — Environments loaded, awaiting user configuration. // @STATE loading — Fetching dashboards, databases, or dry-run in progress. // @STATE review — Dry-run completed, awaiting user decision. // @STATE executing — Migration task submitted, task runner active. // @STATE error — API call failed, error message available. // @ACTION loadEnvironments() — Fetches environments, pre-fills source from active context. // @ACTION selectSourceEnv(envId) — Sets source, triggers dashboard/database/mapping reset. // @ACTION selectTargetEnv(envId) — Sets target environment. // @ACTION toggleDashboard(id) — Toggles dashboard selection. // @ACTION setReplaceDb(bool) — Toggles database replacement option. // @ACTION setFixCrossFilters(bool) — Toggles cross-filter fixing. // @ACTION fetchDatabases() — Loads source/target databases, mappings, and AI suggestions. // @ACTION saveMapping(sourceUuid, targetUuid) — Saves a database mapping pair. // @ACTION calculateDryRun() — Delegates to Migration.ExecutorModel. // @ACTION executeMigration() — Delegates to Migration.ExecutorModel. // @ACTION resumeMigration(passwords) — Delegates to Migration.ExecutorModel. // @ACTION goToStep(n) — Delegates to Migration.WizardModel. // @ACTION clearError() — Delegates to Migration.WizardModel. // @ACTION openLogViewer(task) — Opens the log viewer modal for a task. // @ACTION closeLogViewer() — Closes the log viewer modal. // @ACTION toggleTaskHistory() — Toggles task history panel visibility. // @ATOM showMigrateModal — Visibility of migration modal (used by dashboards page). // @RELATION DEPENDS_ON -> [EXT:frontend:api] // @RELATION BINDS_TO -> [selectedTask] // @RELATION BINDS_TO -> [environmentContextStore] // @RELATION CALLS -> [EXT:frontend:resumeTask] // @RELATION DISPATCHES -> [Migration.WizardModel] // @RELATION DISPATCHES -> [Migration.ExecutorModel] // @RATIONALE Svelte 5 class-based model with $state atoms and $derived computed values. Extracted from MigrationPage/+page.svelte to enable L1 testing without DOM render. Decomposed into sub-models per @INVARIANT DECOMPOSITION GATE (was 457 lines, now ~290). // @REJECTED Inline $state + handler functions in MigrationPage/+page.svelte rejected — scatters business logic across event handlers, makes testing impossible without DOM, violates RSM pattern. import { api } from "$lib/api.js"; import { selectedTask } from "$lib/stores.svelte.js"; import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js"; import { t } from "$lib/i18n/index.svelte.js"; import { WizardState } from "./Migration.WizardModel.svelte.ts"; import { MigrationExecutor } from "./Migration.ExecutorModel.svelte.ts"; import type { DryRunResult, DashboardSelection } from "./Migration.ExecutorModel.svelte.ts"; // ── Types ───────────────────────────────────────────────────── export type ScreenState = "idle" | "ready" | "loading" | "review" | "executing" | "error"; interface Environment { id: string; name: string; [key: string]: unknown; } interface Dashboard { id: string; [key: string]: unknown; } interface Database { uuid: string; database_name: string; [key: string]: unknown; } interface Mapping { id?: number; source_db_uuid?: string; target_db_uuid?: string; source_db_name?: string; target_db_name?: string; [key: string]: unknown; } interface LogViewerTask { id: string; status: string; [key: string]: unknown; } export class MigrationModel { // ── Sub-models (instantiated after $state fields, before $derived) ─ wizard: WizardState = new WizardState(this as unknown as MigrationModel); executor: MigrationExecutor = new MigrationExecutor(this as unknown as MigrationModel); // ── Atoms (reactive state) ────────────────────────────────────── // Environments environments: Environment[] = $state([]); sourceEnvId: string = $state(""); targetEnvId: string = $state(""); // Migration options replaceDb: boolean = $state(false); fixCrossFilters: boolean = $state(true); // Dashboards dashboards: Dashboard[] = $state([]); // Database mappings sourceDatabases: Database[] = $state([]); targetDatabases: Database[] = $state([]); mappings: Mapping[] = $state([]); suggestions: Record[] = $state([]); // Loading & error loading: boolean = $state(true); error: string = $state(""); fetchingDbs: boolean = $state(false); // Task history panel showTaskHistory: boolean = $state(false); // Log viewer modal showLogViewer: boolean = $state(false); logViewerTaskId: string | null = $state(null); logViewerTaskStatus: string | null = $state(null); // Migration modal visibility (used by dashboards page) showMigrateModal: boolean = $state(false); // Store refs (read-only reactive bindings) selectedTaskStore = selectedTask; envContextStore = environmentContextStore; // ── Proxy Getters / Setters (delegate to sub-models) ───────────── get currentStep(): number { return this.wizard.currentStep; } set currentStep(v: number) { this.wizard.currentStep = v; } get selectedDashboardIds(): string[] { return this.executor.selectedDashboardIds; } set selectedDashboardIds(v: string[]) { this.executor.selectedDashboardIds = v; } get dryRunResult(): DryRunResult | null { return this.executor.dryRunResult; } set dryRunResult(v: DryRunResult | null) { this.executor.dryRunResult = v; } get dryRunLoading(): boolean { return this.executor.dryRunLoading; } set dryRunLoading(v: boolean) { this.executor.dryRunLoading = v; } get isCalculating(): boolean { return this.executor.isCalculating; } get showPasswordPrompt(): boolean { return this.executor.showPasswordPrompt; } set showPasswordPrompt(v: boolean) { this.executor.showPasswordPrompt = v; } get passwordPromptDatabases(): string[] { return this.executor.passwordPromptDatabases; } set passwordPromptDatabases(v: string[]) { this.executor.passwordPromptDatabases = v; } get passwordPromptErrorMessage(): string { return this.executor.passwordPromptErrorMessage; } set passwordPromptErrorMessage(v: string) { this.executor.passwordPromptErrorMessage = v; } // ── Derived ───────────────────────────────────────────────────── /** Whether each wizard step is ready to proceed. */ stepReady = $derived>({ 1: this.sourceEnvId !== "" && this.targetEnvId !== "" && this.sourceEnvId !== this.targetEnvId, 2: this.selectedDashboardIds.length > 0, 3: true, // review is always ready if we got here }); /** Human-readable screen state derived from atoms. */ screenState: ScreenState = $derived( this.loading ? "idle" : this.error ? "error" : this.dryRunLoading || this.fetchingDbs ? "loading" : this.dryRunResult ? "review" : this.currentStep >= 4 ? "executing" : "ready" ); /** Whether environments are distinct and valid. */ envsReady: boolean = $derived(this.stepReady[1]); /** Whether migration can be executed. */ canExecute: boolean = $derived(this.dryRunResult != null && this.stepReady[2]); // ── Delegating Methods ────────────────────────────────────────── goToStep(step: number): void { this.wizard.goToStep(step); } clearError(): void { this.wizard.clearError(); } async calculateDryRun(): Promise { return this.executor.calculateDryRun(); } async executeMigration(endpoint: string = "/migration/execute"): Promise { return this.executor.executeMigration(endpoint); } async resumeMigration(passwords: Record): Promise { return this.executor.resumeMigration(passwords); } checkPasswordPrompt(): void { this.executor.checkPasswordPrompt(); } // ── Core Actions ──────────────────────────────────────────────── /** Load environments list. Pre-fills source from active context. */ async loadEnvironments(): Promise { this.loading = true; this.error = ""; try { this.environments = await api.getEnvironmentsList(); const activeEnvId = this.envContextStore.current?.selectedEnvId; if (activeEnvId && this.environments.some((env: Environment) => env.id === activeEnvId)) { this.sourceEnvId = activeEnvId; } } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to load environments"; } finally { this.loading = false; } } /** * Select source environment. Triggers dashboard refetch. * @INVARIANT Resets dashboard selection, databases, mappings, and dry-run result. */ selectSourceEnv(envId: string): void { if (envId === this.sourceEnvId) return; this.sourceEnvId = envId; // @INVARIANT: reset dependent selections this.selectedDashboardIds = []; this.dashboards = []; this.sourceDatabases = []; this.targetDatabases = []; this.mappings = []; this.suggestions = []; this.dryRunResult = null; if (envId) this._fetchDashboards(envId); } /** Select target environment. Resets dry-run result. */ selectTargetEnv(envId: string): void { if (envId === this.targetEnvId) return; this.targetEnvId = envId; this.dryRunResult = null; } /** Toggle database replacement option. Resets databases when toggling on. */ setReplaceDb(val: boolean): void { this.replaceDb = val; if (!val) { this.sourceDatabases = []; this.targetDatabases = []; this.mappings = []; this.suggestions = []; } this.dryRunResult = null; } /** Toggle cross-filter fixing. */ setFixCrossFilters(val: boolean): void { this.fixCrossFilters = val; this.dryRunResult = null; } /** Toggle a dashboard in the selection set. */ toggleDashboard(id: string): void { if (this.selectedDashboardIds.includes(id)) { this.selectedDashboardIds = this.selectedDashboardIds.filter((i) => i !== id); } else { this.selectedDashboardIds = [...this.selectedDashboardIds, id]; } this.dryRunResult = null; } /** Select all dashboards. */ selectAllDashboards(): void { this.selectedDashboardIds = this.dashboards.map((d) => d.id); this.dryRunResult = null; } /** Deselect all dashboards. */ deselectAllDashboards(): void { this.selectedDashboardIds = []; this.dryRunResult = null; } /** Fetch databases, mappings, and AI suggestions for current env pair. */ async fetchDatabases(): Promise { if (!this.sourceEnvId || !this.targetEnvId) return; this.fetchingDbs = true; this.error = ""; try { const [src, tgt, maps, sugs] = await Promise.all([ api.requestApi(`/environments/${this.sourceEnvId}/databases`), api.requestApi(`/environments/${this.targetEnvId}/databases`), api.requestApi(`/mappings?source_env_id=${this.sourceEnvId}&target_env_id=${this.targetEnvId}`), api.postApi("/mappings/suggest", { source_env_id: this.sourceEnvId, target_env_id: this.targetEnvId, }), ]); this.sourceDatabases = src; this.targetDatabases = tgt; this.mappings = maps; this.suggestions = sugs; } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to fetch databases"; } finally { this.fetchingDbs = false; } } /** Save a database mapping between source and target. */ async saveMapping(sourceUuid: string, targetUuid: string): Promise { const sDb = this.sourceDatabases.find((d: Database) => d.uuid === sourceUuid); const tDb = this.targetDatabases.find((d: Database) => d.uuid === targetUuid); if (!sDb || !tDb) return; try { const savedMapping = await api.postApi("/mappings", { source_env_id: this.sourceEnvId, target_env_id: this.targetEnvId, source_db_uuid: sourceUuid, target_db_uuid: targetUuid, source_db_name: sDb.database_name, target_db_name: tDb.database_name, }); this.mappings = [ ...this.mappings.filter((m: Mapping) => m.source_db_uuid !== sourceUuid), savedMapping, ]; } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to save mapping"; } } /** Open log viewer for a task. */ openLogViewer(task: LogViewerTask): void { this.logViewerTaskId = task.id; this.logViewerTaskStatus = task.status; this.showLogViewer = true; } /** Close log viewer. */ closeLogViewer(): void { this.showLogViewer = false; this.logViewerTaskId = null; this.logViewerTaskStatus = null; } /** Toggle task history panel. */ toggleTaskHistory(): void { this.showTaskHistory = !this.showTaskHistory; } // ── Private ───────────────────────────────────────────────────── /** Fetch dashboards for the given environment. Resets selection. */ async _fetchDashboards(envId: string): Promise { try { this.dashboards = await api.requestApi(`/environments/${envId}/dashboards`); this.selectedDashboardIds = []; // @INVARIANT: reset on env change } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to fetch dashboards"; this.dashboards = []; } } /** Build the DashboardSelection payload for API calls. */ _buildSelection(): DashboardSelection { return { selected_ids: this.selectedDashboardIds, source_env_id: this.sourceEnvId, target_env_id: this.targetEnvId, replace_db_config: this.replaceDb, fix_cross_filters: this.fixCrossFilters, }; } /** Validate preconditions for migration/dry-run. Returns false and sets error if invalid. */ _validatePreconditions(): boolean { if (!this.sourceEnvId || !this.targetEnvId) { this.error = t.migration?.select_both_envs || "Please select both source and target environments."; return false; } if (this.sourceEnvId === this.targetEnvId) { this.error = t.migration?.different_envs || "Source and target environments must be different."; return false; } if (this.selectedDashboardIds.length === 0) { this.error = t.migration?.select_dashboards || "Please select at least one dashboard to migrate."; return false; } return true; } } // #endregion Migration.Model