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.
390 lines
16 KiB
TypeScript
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
|