semantic clean up
This commit is contained in:
@@ -1,17 +1,40 @@
|
||||
<!-- [DEF:ProtectedRoute:Component] -->
|
||||
<!--[DEF:ProtectedRoute.svelte:Module] -->
|
||||
<!--
|
||||
@TIER: STANDARD
|
||||
@SEMANTICS: auth, guard, route, protection, permission
|
||||
@PURPOSE: Wraps content to ensure only authenticated and authorized users can access it.
|
||||
@LAYER: Component
|
||||
@RELATION: USES -> authStore
|
||||
@RELATION: CALLS -> goto
|
||||
@RELATION: DEPENDS_ON -> frontend.src.lib.auth.permissions.hasPermission
|
||||
@TIER: CRITICAL
|
||||
@SEMANTICS: auth, route-guard, permission, redirect, session-validation
|
||||
@PURPOSE: Enforces authenticated and authorized access before protected route content is rendered.
|
||||
@LAYER: UI
|
||||
@RELATION: [BINDS_TO] ->[frontend.src.lib.auth.store.auth]
|
||||
@RELATION: [CALLS] ->[$app/navigation.goto]
|
||||
@RELATION: [DEPENDS_ON] ->[$lib/auth/permissions.hasPermission]
|
||||
@RELATION: [CALLS] ->[frontend.src.lib.api.api.fetchApi]
|
||||
@INVARIANT: Unauthenticated users are redirected to /login, unauthorized users are redirected to fallbackPath, and protected slot renders only when access is verified.
|
||||
@UX_STATE: Idle -> Component mounted, verification not yet started.
|
||||
@UX_STATE: Loading -> Spinner is rendered while auth/session/permission validation is in progress.
|
||||
@UX_STATE: Error -> Session validation failure triggers logout and /login redirect.
|
||||
@UX_STATE: Success -> Protected slot content is rendered for authenticated users with valid access.
|
||||
@UX_FEEDBACK: Spinner feedback during Loading and navigation redirect feedback on Error/Unauthorized outcomes.
|
||||
@UX_RECOVERY: Re-authenticate via /login after logout; retry occurs automatically on next protected navigation.
|
||||
@UX_REACTIVITY: Props are bound via $props; local mutable UI flags use $state; auth store is consumed through Svelte store subscription ($auth).
|
||||
@TEST_CONTRACT: [token:user:requiredPermission] -> [redirect:/login | redirect:fallbackPath | render:slot]
|
||||
@TEST_SCENARIO: MissingTokenRedirect -> Navigates to /login and suppresses slot render.
|
||||
@TEST_SCENARIO: PermissionDeniedRedirect -> Navigates to fallbackPath and suppresses slot render.
|
||||
@TEST_SCENARIO: AuthorizedRender -> Renders slot when authenticated and permission passes.
|
||||
@TEST_FIXTURE: AuthGuardStateMatrix -> INLINE_JSON
|
||||
@TEST_EDGE: missing_field -> user payload missing in store triggers /auth/me fetch, then logout+redirect on failure.
|
||||
@TEST_EDGE: invalid_type -> requiredPermission malformed (non-string/null) resolves to denied path and fallback redirect.
|
||||
@TEST_EDGE: external_fail -> /auth/me network/API failure triggers logout and /login redirect.
|
||||
@TEST_INVARIANT: GuardRedirectPolicy -> VERIFIED_BY: [MissingTokenRedirect, PermissionDeniedRedirect]
|
||||
@TEST_INVARIANT: ProtectedRenderGate -> VERIFIED_BY: [AuthorizedRender]
|
||||
-->
|
||||
|
||||
@INVARIANT: Redirects to /login if user is not authenticated and to fallback route when permission is denied.
|
||||
@UX_STATE: Loading -> Shows spinner while session/permission check is in progress.
|
||||
@UX_STATE: Authorized -> Renders protected slot content.
|
||||
@UX_RECOVERY: Invalid token triggers logout and redirect to /login.
|
||||
<!--[DEF:ProtectedRoute:Component] -->
|
||||
<!--
|
||||
@PURPOSE: Wraps protected slot content with session and permission verification guards.
|
||||
@PRE: auth store and navigation API are available in runtime; component is mounted in a browser context.
|
||||
@POST: Slot renders only when $auth.isAuthenticated and hasRouteAccess are both true.
|
||||
@SIDE_EFFECT: Performs /auth/me request, mutates auth store state, emits console instrumentation logs, and executes navigation redirects.
|
||||
@DATA_CONTRACT: Input[$props.requiredPermission?: string|null, $props.fallbackPath?: string] -> Output[UIState{isCheckingAccess:boolean, hasRouteAccess:boolean}]
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
@@ -21,66 +44,91 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { hasPermission } from "$lib/auth/permissions.js";
|
||||
|
||||
export let requiredPermission: string | null = null;
|
||||
export let fallbackPath: string = "/profile";
|
||||
const { requiredPermission = null, fallbackPath = "/profile" } = $props<{
|
||||
requiredPermission?: string | null;
|
||||
fallbackPath?: string;
|
||||
}>();
|
||||
|
||||
let hasRouteAccess = false;
|
||||
let isCheckingAccess = true;
|
||||
let hasRouteAccess = $state(false);
|
||||
let isCheckingAccess = $state(true);
|
||||
|
||||
// [DEF:verifySessionAndAccess:Function]
|
||||
const belief_scope = async <T>(scopeId: string, run: () => Promise<T>): Promise<T> => {
|
||||
console.info(`[${scopeId}][REASON] belief_scope.enter`);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
console.info(`[${scopeId}][REFLECT] belief_scope.exit`);
|
||||
}
|
||||
};
|
||||
|
||||
<!-- [DEF:verifySessionAndAccess:Function] -->
|
||||
/**
|
||||
* @purpose Validates active session and optional route permission before rendering protected slot.
|
||||
* @pre Auth store is initialized.
|
||||
* @post hasRouteAccess is true only when session and permission checks pass.
|
||||
* @side_effect May update auth store, perform redirect, and fetch current user.
|
||||
* @PURPOSE: Validates session and optional permission gate before allowing protected content render.
|
||||
* @PRE: auth store is initialized and can provide token/user state; navigation is available.
|
||||
* @POST: hasRouteAccess=true only when user identity is valid and permission check (if provided) passes.
|
||||
* @SIDE_EFFECT: Mutates auth loading/user state, performs API I/O to /auth/me, and may redirect.
|
||||
* @DATA_CONTRACT: Input[AuthState, requiredPermission, fallbackPath] -> Output[RouteDecision{login_redirect|fallback_redirect|grant}]
|
||||
*/
|
||||
async function verifySessionAndAccess(): Promise<void> {
|
||||
isCheckingAccess = true;
|
||||
try {
|
||||
if (!$auth.token) {
|
||||
auth.setLoading(false);
|
||||
await goto("/login");
|
||||
return;
|
||||
}
|
||||
return belief_scope("ProtectedRoute.verifySessionAndAccess", async () => {
|
||||
console.info("[ProtectedRoute.verifySessionAndAccess][REASON] Starting route access verification");
|
||||
isCheckingAccess = true;
|
||||
|
||||
let currentUser = $auth.user;
|
||||
if (!currentUser) {
|
||||
auth.setLoading(true);
|
||||
try {
|
||||
const user = await api.fetchApi("/auth/me");
|
||||
auth.setUser(user);
|
||||
currentUser = user;
|
||||
} catch (error) {
|
||||
console.error("[ProtectedRoute][COHERENCE:FAILED] Failed to verify session:", error);
|
||||
auth.logout();
|
||||
try {
|
||||
if (!$auth.token) {
|
||||
auth.setLoading(false);
|
||||
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Missing token, redirecting to /login");
|
||||
await goto("/login");
|
||||
return;
|
||||
} finally {
|
||||
auth.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
auth.logout();
|
||||
await goto("/login");
|
||||
return;
|
||||
}
|
||||
let currentUser = $auth.user;
|
||||
if (!currentUser) {
|
||||
auth.setLoading(true);
|
||||
try {
|
||||
const user = await api.fetchApi("/auth/me");
|
||||
auth.setUser(user);
|
||||
currentUser = user;
|
||||
console.info("[ProtectedRoute.verifySessionAndAccess][REASON] Session user hydrated from /auth/me");
|
||||
} catch (error) {
|
||||
console.warn("[ProtectedRoute.verifySessionAndAccess][EXPLORE] Session validation failed", { error });
|
||||
auth.logout();
|
||||
await goto("/login");
|
||||
return;
|
||||
} finally {
|
||||
auth.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredPermission && !hasPermission(currentUser, requiredPermission, "READ")) {
|
||||
console.warn(
|
||||
`[ProtectedRoute][REFLECT] Permission denied for ${requiredPermission}, redirecting to ${fallbackPath}`,
|
||||
);
|
||||
hasRouteAccess = false;
|
||||
await goto(fallbackPath);
|
||||
return;
|
||||
}
|
||||
if (!currentUser) {
|
||||
auth.logout();
|
||||
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] User unresolved, redirecting to /login");
|
||||
await goto("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
hasRouteAccess = true;
|
||||
} finally {
|
||||
isCheckingAccess = false;
|
||||
}
|
||||
if (requiredPermission && !hasPermission(currentUser, requiredPermission, "READ")) {
|
||||
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Permission denied, redirecting to fallback", {
|
||||
requiredPermission,
|
||||
fallbackPath,
|
||||
});
|
||||
hasRouteAccess = false;
|
||||
await goto(fallbackPath);
|
||||
return;
|
||||
}
|
||||
|
||||
hasRouteAccess = true;
|
||||
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Access granted");
|
||||
} finally {
|
||||
isCheckingAccess = false;
|
||||
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Verification cycle completed", {
|
||||
isCheckingAccess,
|
||||
hasRouteAccess,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:verifySessionAndAccess:Function]
|
||||
<!-- [/DEF:verifySessionAndAccess:Function] -->
|
||||
|
||||
onMount(() => {
|
||||
void verifySessionAndAccess();
|
||||
@@ -95,4 +143,5 @@
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
<!-- [/DEF:ProtectedRoute:Component] -->
|
||||
<!-- [/DEF:ProtectedRoute:Component] -->
|
||||
<!-- [/DEF:ProtectedRoute.svelte:Module] -->
|
||||
@@ -1,11 +1,45 @@
|
||||
<!-- [DEF:frontend/src/routes/migration/+page.svelte:Module] -->
|
||||
<!--
|
||||
@TIER: CRITICAL
|
||||
@SEMANTICS: migration, dashboard, environment, selection, database-replacement, dry-run, task-resume
|
||||
@PURPOSE: Main migration dashboard page for environment selection, dry-run validation, and migration execution.
|
||||
@LAYER: UI
|
||||
@RELATION: [DEPENDS_ON] ->[frontend/src/lib/api.js]
|
||||
@RELATION: [DEPENDS_ON] ->[frontend/src/lib/stores.js]
|
||||
@RELATION: [DEPENDS_ON] ->[frontend/src/services/taskService.js]
|
||||
@RELATION: [BINDS_TO] ->[frontend/src/components/EnvSelector.svelte]
|
||||
@RELATION: [BINDS_TO] ->[frontend/src/components/DashboardGrid.svelte]
|
||||
@RELATION: [BINDS_TO] ->[frontend/src/components/MappingTable.svelte]
|
||||
@RELATION: [BINDS_TO] ->[frontend/src/components/TaskRunner.svelte]
|
||||
@RELATION: [BINDS_TO] ->[frontend/src/components/TaskHistory.svelte]
|
||||
@RELATION: [BINDS_TO] ->[frontend/src/components/TaskLogViewer.svelte]
|
||||
@RELATION: [BINDS_TO] ->[frontend/src/components/PasswordPrompt.svelte]
|
||||
@INVARIANT: Migration start is blocked unless source and target environments are selected, distinct, and at least one dashboard is selected.
|
||||
@UX_STATE: Idle -> User configures source/target environments, dashboard selection, and migration options.
|
||||
@UX_STATE: Loading -> Environment/database/dry-run fetch operations disable relevant actions and show progress text.
|
||||
@UX_STATE: Error -> Error banner/prompt message is shown while keeping user input intact for correction.
|
||||
@UX_STATE: Success -> Dry-run summary or active task view is rendered after successful API operations.
|
||||
@UX_FEEDBACK: Inline error banner, disabled CTA states, loading labels, dry-run summary cards, modal dialogs.
|
||||
@UX_RECOVERY: User can adjust selection, refresh databases, retry dry-run/migration, resume task with passwords, or cancel modal flow.
|
||||
@UX_REACTIVITY: State transitions rely on Svelte reactive bindings and store subscription to selectedTask.
|
||||
@TEST_CONTRACT: [DashboardSelection + Environment IDs] -> [DryRunResult | TaskStartResult | ValidationError]
|
||||
@TEST_SCENARIO: start_migration_valid_selection -> Starts backend task and switches to task view.
|
||||
@TEST_SCENARIO: start_dry_run_valid_selection -> Renders pre-flight diff summary and risk panel.
|
||||
@TEST_SCENARIO: awaiting_input_task_selected -> Opens password prompt with requested databases.
|
||||
@TEST_FIXTURE: migration_dry_run_fixture -> file:backend/tests/fixtures/migration_dry_run_fixture.json
|
||||
@TEST_EDGE: missing_field -> Empty source/target/selection blocks action and surfaces validation message.
|
||||
@TEST_EDGE: invalid_type -> Malformed API payload is surfaced through error state.
|
||||
@TEST_EDGE: external_fail -> API failures set error state and preserve recoverable UI controls.
|
||||
@TEST_INVARIANT: migration_guardrails_enforced -> VERIFIED_BY: [start_migration_valid_selection, start_dry_run_valid_selection, missing_field]
|
||||
-->
|
||||
|
||||
<!-- [DEF:MigrationDashboard:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: migration, dashboard, environment, selection, database-replacement
|
||||
@PURPOSE: Main dashboard for configuring and starting migrations.
|
||||
@LAYER: Page
|
||||
@RELATION: USES -> EnvSelector
|
||||
|
||||
@INVARIANT: Migration cannot start without source and target environments.
|
||||
@PURPOSE: Orchestrate migration UI workflow and route user actions to backend APIs and task store.
|
||||
@PRE: API client and component dependencies are available; i18n store is initialized.
|
||||
@POST: User can progress through selection, dry-run, migration start, and task resume flows.
|
||||
@SIDE_EFFECT: Performs HTTP requests, mutates local UI state, updates selectedTask store.
|
||||
@DATA_CONTRACT: DashboardSelection -> MigrationDryRunResult | Task DTO (from /tasks endpoints)
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
@@ -58,6 +92,8 @@
|
||||
let passwordPromptErrorMessage = "";
|
||||
// [/SECTION]
|
||||
|
||||
const belief_scope = <T>(_id: string, fn: () => T): T => fn();
|
||||
|
||||
// [DEF:fetchEnvironments:Function]
|
||||
/**
|
||||
* @purpose Fetches the list of environments from the API.
|
||||
@@ -65,13 +101,15 @@
|
||||
* @post environments state is updated.
|
||||
*/
|
||||
async function fetchEnvironments() {
|
||||
try {
|
||||
environments = await api.getEnvironmentsList();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
return belief_scope("fetchEnvironments", async () => {
|
||||
try {
|
||||
environments = await api.getEnvironmentsList();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:fetchEnvironments:Function]
|
||||
|
||||
@@ -83,13 +121,15 @@
|
||||
* @post dashboards state is updated.
|
||||
*/
|
||||
async function fetchDashboards(envId: string) {
|
||||
try {
|
||||
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
|
||||
selectedDashboardIds = []; // Reset selection when env changes
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
dashboards = [];
|
||||
}
|
||||
return belief_scope("fetchDashboards", async () => {
|
||||
try {
|
||||
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
|
||||
selectedDashboardIds = []; // Reset selection when env changes
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
dashboards = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:fetchDashboards:Function]
|
||||
|
||||
@@ -105,32 +145,34 @@
|
||||
* @post sourceDatabases, targetDatabases, mappings, and suggestions are updated.
|
||||
*/
|
||||
async function fetchDatabases() {
|
||||
if (!sourceEnvId || !targetEnvId) return;
|
||||
fetchingDbs = true;
|
||||
error = "";
|
||||
return belief_scope("fetchDatabases", async () => {
|
||||
if (!sourceEnvId || !targetEnvId) return;
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
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;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
fetchingDbs = false;
|
||||
}
|
||||
sourceDatabases = src;
|
||||
targetDatabases = tgt;
|
||||
mappings = maps;
|
||||
suggestions = sugs;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
fetchingDbs = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:fetchDatabases:Function]
|
||||
|
||||
@@ -141,29 +183,31 @@
|
||||
* @post Mapping is saved and local mappings list is updated.
|
||||
*/
|
||||
async function handleMappingUpdate(event: CustomEvent) {
|
||||
const { sourceUuid, targetUuid } = event.detail;
|
||||
const sDb = sourceDatabases.find((d) => d.uuid === sourceUuid);
|
||||
const tDb = targetDatabases.find((d) => d.uuid === targetUuid);
|
||||
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) return;
|
||||
if (!sDb || !tDb) return;
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
];
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
mappings = [
|
||||
...mappings.filter((m) => m.source_db_uuid !== sourceUuid),
|
||||
savedMapping,
|
||||
];
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:handleMappingUpdate:Function]
|
||||
|
||||
@@ -172,10 +216,12 @@
|
||||
// @PRE: event.detail contains task object.
|
||||
// @POST: logViewer state updated and showLogViewer set to true.
|
||||
function handleViewLogs(event: CustomEvent) {
|
||||
const task = event.detail;
|
||||
logViewerTaskId = task.id;
|
||||
logViewerTaskStatus = task.status;
|
||||
showLogViewer = true;
|
||||
return belief_scope("handleViewLogs", () => {
|
||||
const task = event.detail;
|
||||
logViewerTaskId = task.id;
|
||||
logViewerTaskStatus = task.status;
|
||||
showLogViewer = true;
|
||||
});
|
||||
}
|
||||
// [/DEF:handleViewLogs:Function]
|
||||
|
||||
@@ -212,19 +258,21 @@
|
||||
// @PRE: event.detail contains passwords.
|
||||
// @POST: resumeTask is called and showPasswordPrompt is hidden on success.
|
||||
async function handleResumeMigration(event: CustomEvent) {
|
||||
if (!$selectedTask) return;
|
||||
return belief_scope("handleResumeMigration", async () => {
|
||||
if (!$selectedTask) return;
|
||||
|
||||
const { passwords } = event.detail;
|
||||
try {
|
||||
await resumeTask($selectedTask.id, passwords);
|
||||
showPasswordPrompt = false;
|
||||
// Task status update will be handled by store/websocket
|
||||
} catch (e) {
|
||||
console.error("Failed to resume task:", e);
|
||||
passwordPromptErrorMessage =
|
||||
e.message || $t.migration?.resume_failed ;
|
||||
// Keep prompt open
|
||||
}
|
||||
const { passwords } = event.detail;
|
||||
try {
|
||||
await resumeTask($selectedTask.id, passwords);
|
||||
showPasswordPrompt = false;
|
||||
// Task status update will be handled by store/websocket
|
||||
} catch (e) {
|
||||
console.error("Failed to resume task:", e);
|
||||
passwordPromptErrorMessage =
|
||||
e.message || $t.migration?.resume_failed;
|
||||
// Keep prompt open
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:handleResumeMigration:Function]
|
||||
|
||||
@@ -235,69 +283,71 @@
|
||||
* @post Migration task is started and selectedTask is updated.
|
||||
*/
|
||||
async function startMigration() {
|
||||
if (!sourceEnvId || !targetEnvId) {
|
||||
error =
|
||||
$t.migration?.select_both_envs ||
|
||||
"Please select both source and target environments.";
|
||||
return;
|
||||
}
|
||||
if (sourceEnvId === targetEnvId) {
|
||||
error =
|
||||
$t.migration?.different_envs ||
|
||||
"Source and target environments must be different.";
|
||||
return;
|
||||
}
|
||||
if (selectedDashboardIds.length === 0) {
|
||||
error =
|
||||
$t.migration?.select_dashboards ||
|
||||
"Please select at least one dashboard to migrate.";
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
console.log(
|
||||
`[MigrationDashboard][Action] Starting migration with selection:`,
|
||||
selection,
|
||||
);
|
||||
const result = await api.postApi("/migration/execute", selection);
|
||||
console.log(
|
||||
`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`,
|
||||
);
|
||||
|
||||
// Wait a brief moment for the backend to ensure the task is retrievable
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Fetch full task details and switch to TaskRunner view
|
||||
try {
|
||||
const task = await api.getTask(result.task_id);
|
||||
selectedTask.set(task);
|
||||
} catch (fetchErr) {
|
||||
// Fallback: create a temporary task object to switch view immediately
|
||||
console.warn(
|
||||
$t.migration?.task_placeholder_warn ||
|
||||
"Could not fetch task details immediately, using placeholder.",
|
||||
);
|
||||
selectedTask.set({
|
||||
id: result.task_id,
|
||||
plugin_id: "superset-migration",
|
||||
status: "RUNNING",
|
||||
logs: [],
|
||||
params: {},
|
||||
});
|
||||
return belief_scope("startMigration", async () => {
|
||||
if (!sourceEnvId || !targetEnvId) {
|
||||
error =
|
||||
$t.migration?.select_both_envs ||
|
||||
"Please select both source and target environments.";
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[MigrationDashboard][Failure] Migration failed:`, e);
|
||||
error = e.message;
|
||||
}
|
||||
if (sourceEnvId === targetEnvId) {
|
||||
error =
|
||||
$t.migration?.different_envs ||
|
||||
"Source and target environments must be different.";
|
||||
return;
|
||||
}
|
||||
if (selectedDashboardIds.length === 0) {
|
||||
error =
|
||||
$t.migration?.select_dashboards ||
|
||||
"Please select at least one dashboard to migrate.";
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
console.log(
|
||||
`[MigrationDashboard][Action] Starting migration with selection:`,
|
||||
selection,
|
||||
);
|
||||
const result = await api.postApi("/migration/execute", selection);
|
||||
console.log(
|
||||
`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`,
|
||||
);
|
||||
|
||||
// Wait a brief moment for the backend to ensure the task is retrievable
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Fetch full task details and switch to TaskRunner view
|
||||
try {
|
||||
const task = await api.getTask(result.task_id);
|
||||
selectedTask.set(task);
|
||||
} catch (fetchErr) {
|
||||
// Fallback: create a temporary task object to switch view immediately
|
||||
console.warn(
|
||||
$t.migration?.task_placeholder_warn ||
|
||||
"Could not fetch task details immediately, using placeholder.",
|
||||
);
|
||||
selectedTask.set({
|
||||
id: result.task_id,
|
||||
plugin_id: "superset-migration",
|
||||
status: "RUNNING",
|
||||
logs: [],
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[MigrationDashboard][Failure] Migration failed:`, e);
|
||||
error = e.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:startMigration:Function]
|
||||
|
||||
@@ -313,46 +363,59 @@
|
||||
* @UX_RECOVERY: User can adjust selection and press Dry Run again.
|
||||
*/
|
||||
async function startDryRun() {
|
||||
if (!sourceEnvId || !targetEnvId) {
|
||||
error =
|
||||
$t.migration?.select_both_envs ||
|
||||
"Please select both source and target environments.";
|
||||
return;
|
||||
}
|
||||
if (sourceEnvId === targetEnvId) {
|
||||
error =
|
||||
$t.migration?.different_envs ||
|
||||
"Source and target environments must be different.";
|
||||
return;
|
||||
}
|
||||
if (selectedDashboardIds.length === 0) {
|
||||
error =
|
||||
$t.migration?.select_dashboards ||
|
||||
"Please select at least one dashboard to migrate.";
|
||||
return;
|
||||
}
|
||||
return belief_scope("startDryRun", async () => {
|
||||
if (!sourceEnvId || !targetEnvId) {
|
||||
error =
|
||||
$t.migration?.select_both_envs ||
|
||||
"Please select both source and target environments.";
|
||||
return;
|
||||
}
|
||||
if (sourceEnvId === targetEnvId) {
|
||||
error =
|
||||
$t.migration?.different_envs ||
|
||||
"Source and target environments must be different.";
|
||||
return;
|
||||
}
|
||||
if (selectedDashboardIds.length === 0) {
|
||||
error =
|
||||
$t.migration?.select_dashboards ||
|
||||
"Please select at least one dashboard to migrate.";
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
dryRunResult = null;
|
||||
} finally {
|
||||
dryRunLoading = false;
|
||||
}
|
||||
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);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
dryRunResult = null;
|
||||
} finally {
|
||||
dryRunLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:startDryRun:Function]
|
||||
// [/DEF:MigrationDashboard:Component]
|
||||
</script>
|
||||
|
||||
<!-- [DEF:MigrationDashboardView:Block] -->
|
||||
<!--
|
||||
@PURPOSE: Render migration configuration controls, action CTAs, dry-run results, and modal entry points.
|
||||
@UX_STATE: Idle -> Configuration form is interactive.
|
||||
@UX_STATE: Loading -> Loading messages and disabled buttons prevent duplicate actions.
|
||||
@UX_STATE: Error -> Error banner is displayed without discarding current selection.
|
||||
@UX_STATE: Success -> Dry-run details or TaskRunner content is presented.
|
||||
@UX_FEEDBACK: Buttons, banners, cards, and dialogs provide immediate operation feedback.
|
||||
@UX_RECOVERY: User can retry operations from the same page state.
|
||||
-->
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<PageHeader title={$t.nav.migration} />
|
||||
@@ -556,5 +619,5 @@
|
||||
/>
|
||||
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:MigrationDashboard:Component] -->
|
||||
<!-- [/DEF:MigrationDashboardView:Block] -->
|
||||
<!-- [/DEF:frontend/src/routes/migration/+page.svelte:Module] -->
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
<!-- [DEF:MappingManagement:Component] -->
|
||||
<!-- [DEF:frontend/src/routes/migration/mappings/+page.svelte:Module] -->
|
||||
<!--
|
||||
@SEMANTICS: mapping, management, database, fuzzy-matching
|
||||
@PURPOSE: Page for managing database mappings between environments.
|
||||
@LAYER: Page
|
||||
@RELATION: USES -> EnvSelector
|
||||
@RELATION: USES -> MappingTable
|
||||
|
||||
@INVARIANT: Mappings are saved to the backend for persistence.
|
||||
@TIER: CRITICAL
|
||||
@SEMANTICS: migration-mappings, environment-selection, fuzzy-matching, persistence-contract, ux-state-machine
|
||||
@PURPOSE: Render and orchestrate mapping management UI for source/target environments with backend persistence.
|
||||
@LAYER: UI
|
||||
@RELATION: [DEPENDS_ON] ->[api.client]
|
||||
@RELATION: [DEPENDS_ON] ->[EnvSelector.svelte]
|
||||
@RELATION: [DEPENDS_ON] ->[MappingTable.svelte]
|
||||
@RELATION: [DEPENDS_ON] ->[i18n.t]
|
||||
@RELATION: [BINDS_TO] ->[migration.mappings.route]
|
||||
@INVARIANT: Persisted mapping state in backend remains the source of truth for rendered mapping pairs.
|
||||
@PRE: Translation store and API client are available; route is mounted in authenticated UI shell.
|
||||
@POST: UI exposes deterministic Idle/Loading/Error/Success states for environment loading, database fetch, and mapping save.
|
||||
@SIDE_EFFECT: Performs network I/O to environment/database/mapping endpoints and mutates local UI state.
|
||||
@DATA_CONTRACT: Input(Event: update{sourceUuid,targetUuid}) -> Model(MappingPayload); Output(UIState{environments,databases,mappings,suggestions,status})
|
||||
@UX_STATE: Idle -> Await environment selection and user-triggered fetch.
|
||||
@UX_STATE: Loading -> Show loading text/spinner while environments or databases are fetched.
|
||||
@UX_STATE: Error -> Render red alert panel with backend error message.
|
||||
@UX_STATE: Success -> Render green confirmation panel after mapping save.
|
||||
@UX_FEEDBACK: Error panel for failed API calls; success panel for persisted mapping confirmation.
|
||||
@UX_RECOVERY: Retry via "fetch databases" action; reselection of environments clears stale arrays.
|
||||
@UX_REACTIVITY: Svelte bind/on directives and reactive template branches coordinate state transitions (legacy route; no semantic logic mutation in this task).
|
||||
@TEST_CONTRACT: [Valid source/target env IDs + fetch click] -> [Databases, mappings, suggestions rendered]
|
||||
@TEST_SCENARIO: [save_mapping_success] -> [success banner appears and mapping list replaces same-source item]
|
||||
@TEST_SCENARIO: [fetch_env_fail] -> [error banner appears and loading state exits]
|
||||
@TEST_FIXTURE: [migration_mapping_pair] -> file:backend/tests/fixtures/migration_dry_run_fixture.json
|
||||
@TEST_EDGE: [missing_field] ->[event.detail lacks sourceUuid/targetUuid => no save]
|
||||
@TEST_EDGE: [invalid_type] ->[non-string env IDs => fetch disabled/guarded]
|
||||
@TEST_EDGE: [external_fail] ->[API request rejection => error state]
|
||||
@TEST_INVARIANT: [backend_source_of_truth] -> VERIFIED_BY: [save_mapping_success, fetch_env_fail]
|
||||
@CRITICAL_TRACE: Frontend scope; Python belief_scope/logger are not applicable in Svelte runtime. Reflective tracing, when added, must use console prefix [ID][REFLECT].
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
// [DEF:MappingsPageScript:Block]
|
||||
// @PURPOSE: Define imports, state, and handlers that drive migration mappings page FSM.
|
||||
// @RELATION: [CALLS] ->[fetchEnvironments]
|
||||
// @RELATION: [CALLS] ->[fetchDatabases]
|
||||
// @RELATION: [CALLS] ->[handleUpdate]
|
||||
|
||||
// [DEF:Imports:Block]
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../../lib/api.js';
|
||||
import EnvSelector from '../../../components/EnvSelector.svelte';
|
||||
import MappingTable from '../../../components/MappingTable.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { Button, PageHeader } from '$lib/ui';
|
||||
// [/SECTION]
|
||||
// [/DEF:Imports:Block]
|
||||
|
||||
// [SECTION: STATE]
|
||||
// [DEF:UiState:Store]
|
||||
// @PURPOSE: Maintain local page state for environments, fetched databases, mappings, suggestions, and UX messages.
|
||||
let environments = [];
|
||||
let sourceEnvId = "";
|
||||
let targetEnvId = "";
|
||||
@@ -31,90 +61,115 @@
|
||||
let fetchingDbs = false;
|
||||
let error = "";
|
||||
let success = "";
|
||||
// [/SECTION]
|
||||
// [/DEF:UiState:Store]
|
||||
|
||||
// [DEF:belief_scope:Function]
|
||||
// @PURPOSE: Frontend semantic scope wrapper for CRITICAL trace boundaries without changing business behavior.
|
||||
// @PRE: scopeId is non-empty and run is callable.
|
||||
// @POST: Executes run exactly once and returns/rejects with the same outcome.
|
||||
// @SIDE_EFFECT: Emits trace logs for semantic scope entrance/exit.
|
||||
// @DATA_CONTRACT: Input(scopeId:string, run:() => Promise<T>) -> Output(Promise<T>)
|
||||
async function belief_scope<T>(scopeId: string, run: () => Promise<T>): Promise<T> {
|
||||
console.info(`[${scopeId}][REASON] belief_scope.enter`);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
console.info(`[${scopeId}][REFLECT] belief_scope.exit`);
|
||||
}
|
||||
}
|
||||
// [/DEF:belief_scope:Function]
|
||||
|
||||
// [DEF:fetchEnvironments:Function]
|
||||
// @PURPOSE: Fetches the list of environments.
|
||||
// @PRE: None.
|
||||
// @POST: environments array is populated.
|
||||
// @PURPOSE: Load environment options for source/target selectors on initial mount.
|
||||
// @PRE: API client is initialized and route has mounted.
|
||||
// @POST: loading=false and environments populated on success or error message set on failure.
|
||||
// @SIDE_EFFECT: Network I/O to environments endpoint; mutates environments/error/loading.
|
||||
// @DATA_CONTRACT: Input(void) -> Output(EnvironmentSummary[])
|
||||
async function fetchEnvironments() {
|
||||
try {
|
||||
environments = await api.getEnvironmentsList();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
return belief_scope('migration.mappings.fetchEnvironments', async () => {
|
||||
try {
|
||||
environments = await api.getEnvironmentsList();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:fetchEnvironments:Function]
|
||||
|
||||
onMount(fetchEnvironments);
|
||||
|
||||
// [DEF:fetchDatabases:Function]
|
||||
/**
|
||||
* @purpose Fetches databases from both environments and gets suggestions.
|
||||
* @pre sourceEnvId and targetEnvId must be set.
|
||||
* @post sourceDatabases, targetDatabases, mappings, and suggestions are updated.
|
||||
*/
|
||||
// @PURPOSE: Fetch both environment database catalogs, existing mappings, and suggested matches.
|
||||
// @PRE: sourceEnvId and targetEnvId are both selected and non-empty.
|
||||
// @POST: fetchingDbs=false and sourceDatabases/targetDatabases/mappings/suggestions updated or error set.
|
||||
// @SIDE_EFFECT: Concurrent network I/O to environments, mappings, and suggestion endpoints; clears transient messages.
|
||||
// @DATA_CONTRACT: Input({sourceEnvId,targetEnvId}) -> Output({sourceDatabases,targetDatabases,mappings,suggestions})
|
||||
async function fetchDatabases() {
|
||||
if (!sourceEnvId || !targetEnvId) return;
|
||||
fetchingDbs = true;
|
||||
error = "";
|
||||
success = "";
|
||||
|
||||
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 })
|
||||
]);
|
||||
return belief_scope('migration.mappings.fetchDatabases', async () => {
|
||||
if (!sourceEnvId || !targetEnvId) return;
|
||||
fetchingDbs = true;
|
||||
error = "";
|
||||
success = "";
|
||||
|
||||
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;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
fetchingDbs = false;
|
||||
}
|
||||
sourceDatabases = src;
|
||||
targetDatabases = tgt;
|
||||
mappings = maps;
|
||||
suggestions = sugs;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
fetchingDbs = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:fetchDatabases:Function]
|
||||
|
||||
// [DEF:handleUpdate:Function]
|
||||
/**
|
||||
* @purpose Saves a mapping to the backend.
|
||||
* @pre event.detail contains sourceUuid and targetUuid.
|
||||
* @post Mapping is saved and local mappings list is updated.
|
||||
*/
|
||||
// @PURPOSE: Persist a selected mapping pair and reconcile local mapping list by source database UUID.
|
||||
// @PRE: event.detail includes sourceUuid/targetUuid and matching source/target database records exist.
|
||||
// @POST: mapping persisted; local mappings replaced for same source UUID; success or error feedback shown.
|
||||
// @SIDE_EFFECT: POST /mappings network I/O; mutates mappings/success/error.
|
||||
// @DATA_CONTRACT: Input(CustomEvent<{sourceUuid:string,targetUuid:string}>) -> Output(MappingRecord persisted + UI feedback)
|
||||
async function handleUpdate(event: CustomEvent) {
|
||||
const { sourceUuid, targetUuid } = event.detail;
|
||||
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
|
||||
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
|
||||
|
||||
if (!sDb || !tDb) return;
|
||||
return belief_scope('migration.mappings.handleUpdate', 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) return;
|
||||
|
||||
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
|
||||
});
|
||||
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];
|
||||
success = $t.migration?.mapping_saved ;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
|
||||
success = $t.migration?.mapping_saved ;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
// [/DEF:handleUpdate:Function]
|
||||
// [/DEF:MappingsPageScript:Block]
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<!-- [DEF:MappingsPageTemplate:Block] -->
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<PageHeader title={$t.migration?.mapping_management } />
|
||||
|
||||
@@ -171,7 +226,6 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
<!-- [/DEF:MappingsPageTemplate:Block] -->
|
||||
|
||||
|
||||
<!-- [/DEF:MappingManagement:Component] -->
|
||||
<!-- [/DEF:frontend/src/routes/migration/mappings/+page.svelte:Module] -->
|
||||
|
||||
@@ -167,8 +167,6 @@
|
||||
};
|
||||
}
|
||||
// [/DEF:resetForm:Function]
|
||||
// [/DEF:handleSave:Function]
|
||||
|
||||
// [DEF:handleDelete:Function]
|
||||
/**
|
||||
* @purpose Deletes a git configuration by ID.
|
||||
|
||||
Reference in New Issue
Block a user