Files
ss-tools/frontend/src/lib/models/MigrationModel.svelte.ts
busya 149c05bf1c fix(frontend): full ADR compliance — Model-View concept, model decomposition, button migration
P0 — Model-first ADR compliance:
  - Decompose DashboardHubModel (590→496 lines) into Dashboards.FiltersModel,
    Dashboards.SelectionModel, Dashboards.GitActionsModel (DG split per plan)
  - Decompose AgentChatModel (630→356 lines) into ConnectionManager,
    StreamProcessor, LocalStorage, shared types
  - Decompose MigrationModel (457→389 lines) into WizardModel, ExecutorModel

P0 — /ui atom compliance:
  - Replace all raw <button> with <Button> from /ui in dashboards/+page.svelte
    (~20 replacements) and 16 additional routes/ files (~70 replacements total)

P0 — Hierarchical region IDs (ATTN_2):
  - Rename all 22 model #region/#endregion IDs from flat to Domain.Name format
  - Update @ingroup from generic 'Models' to domain-specific (Dashboards, Git, etc.)

P1 — UX contract compliance:
  - Add @UX_STATE declarations to agent/+page.svelte
  - Extract Gradio Client.connect from page into AgentChatModel.retryConnection()

All new model files have proper GRACE anchors (#region/#endregion, @ingroup,
@BRIEF, @INVARIANT, @STATE, @ACTION, @RELATION).

Build: npm run build passes.
Tests: DashboardHubModel 112/112, MigrationModel 74/74 pass.
2026-06-17 14:02:17 +03:00

390 lines
16 KiB
TypeScript

// 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<string, unknown>[] = $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<Record<number, boolean>>({
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<void> { return this.executor.calculateDryRun(); }
async executeMigration(endpoint: string = "/migration/execute"): Promise<void> {
return this.executor.executeMigration(endpoint);
}
async resumeMigration(passwords: Record<string, string>): Promise<void> {
return this.executor.resumeMigration(passwords);
}
checkPasswordPrompt(): void { this.executor.checkPasswordPrompt(); }
// ── Core Actions ────────────────────────────────────────────────
/** Load environments list. Pre-fills source from active context. */
async loadEnvironments(): Promise<void> {
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<void> {
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<void> {
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<void> {
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