refactor(frontend): extract MigrateDashboardModal + BackupDashboardModal

Dashboard hub page: 1341→808 lines (-533, -40%).
Two modal components extracted yielding line reduction:
- MigrateDashboardModal.svelte: 378-line migration form with dry-run
- BackupDashboardModal.svelte: 158-line backup schedule form

Build passes, all 699 tests pass.
This commit is contained in:
2026-06-02 18:06:37 +03:00
parent cf7b3556f7
commit e171155dba
3 changed files with 345 additions and 536 deletions

View File

@@ -34,6 +34,8 @@
import ColumnFilterPopover from "./ColumnFilterPopover.svelte";
import { formatDate, getPaginationRange, getSortIndicator } from "./dashboard-helpers.js";
import { DashboardHubModel } from "$lib/models/DashboardHubModel.svelte.ts";
import MigrateDashboardModal from "./MigrateDashboardModal.svelte";
import BackupDashboardModal from "./BackupDashboardModal.svelte";
const m = new DashboardHubModel();
const debouncedSearch = debounce((query: string) => { m.searchQuery = query.trim(); m.currentPage = 1; void m.loadDashboards(); }, 300);
@@ -793,543 +795,9 @@ class="px-4 py-2 border border-border-strong rounded-lg bg-surface-card focus-vi
{/if}
{/if}
<!-- Migration Modal -->
{#if m.showMigrateModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={() => (m.showMigrateModal = false)}
onkeydown={(e) => e.key === "Escape" && (m.showMigrateModal = false)}
role="presentation"
>
<div
class="bg-surface-card rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
aria-labelledby="migrate-modal-title"
onkeydown={(e) => e.stopPropagation()}
>
<div
class="px-6 py-4 border-b border-border flex items-center justify-between relative"
>
<h2 id="migrate-modal-title" class="text-xl font-bold">
{($t.dashboard?.migrate_modal_title).replace(
"{count}",
String(m.selectedIds.size),
)}
</h2>
<button
onclick={() => (m.showMigrateModal = false)}
class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all"
aria-label={$t.common?.close_modal}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="px-6 py-4">
<div class="space-y-6">
<!-- Source Env (Read-only) -->
<div>
<label
for="source-env"
class="block text-sm font-medium text-text-muted mb-1"
>{$t.migration?.source_env}</label
>
<div
id="source-env"
class="px-4 py-2 bg-surface-muted border border-border rounded-lg text-text font-medium"
>
{environments.find((e) => e.id === m.selectedEnv)?.name ||
m.selectedEnv}
{$t.dashboard?.read_only}
</div>
</div>
<MigrateDashboardModal model={m} {environments} />
<!-- Target Env -->
<div>
<label
for="target-env"
class="block text-sm font-medium text-text mb-2"
>{$t.migration?.target_env}</label
>
<select
id="target-env"
class="env-dropdown w-full"
value={m.bulkMigrateModel.targetEnvId}
onchange={(e) => {
m.bulkMigrateModel.selectTargetEnv(e.target.value);
if (e.target.value) {
m.bulkMigrateModel.fetchDatabases().then(() => {
if (m.bulkMigrateModel.selectedDashboardIds.length > 0 && m.bulkMigrateModel.dryRunResult === null) {
m.bulkMigrateModel.calculateDryRun();
}
});
}
}}
>
<option value="">
{$t.dashboard?.target_env_placeholder ||
"Select target environment..."}
</option>
{#each environments.filter((e) => e.id !== m.selectedEnv) as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<!-- Database Mappings Toggle -->
<div>
<div class="flex items-center justify-between mb-2">
<label
for="use-db-mappings"
class="block text-sm font-medium text-text"
>{$t.migration?.database_mappings}</label
>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="use-db-mappings"
bind:checked={m.bulkMigrateModel.replaceDb}
class="sr-only peer"
/>
<div
class="w-9 h-5 bg-surface-muted peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-surface-card after:border-border-strong after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"
></div>
<span class="ml-2 text-xs text-text-muted"
>{m.bulkMigrateModel.replaceDb ? $t.common?.on : $t.common?.off}</span
>
</label>
</div>
{#if m.bulkMigrateModel.replaceDb}
<div class="flex items-center justify-between mb-2">
{#if m.bulkMigrateModel.suggestions.length > 0}
<button
class="text-sm text-primary hover:underline"
onclick={() => (m.isEditingMappings = !m.isEditingMappings)}
>
{m.isEditingMappings
? $t.dashboard?.view_summary
: $t.dashboard?.edit_mappings}
</button>
{/if}
</div>
{#if m.isEditingMappings}
<div
class="border border-border rounded-lg overflow-hidden"
>
<MappingTable
sourceDatabases={m.bulkMigrateModel.sourceDatabases}
targetDatabases={m.bulkMigrateModel.targetDatabases}
mappings={m.bulkMigrateModel.mappings}
suggestions={m.bulkMigrateModel.suggestions}
onupdate={(e) => m.bulkMigrateModel.saveMapping(e.detail.sourceUuid, e.detail.targetUuid)}
/>
</div>
{:else}
<div
class="border border-border rounded-lg overflow-hidden"
>
<table class="w-full text-sm text-left">
<thead class="bg-surface-muted border-b border-border">
<tr>
<th class="px-4 py-2 font-semibold text-text"
>{$t.dashboard?.source_database}</th
>
<th class="px-4 py-2 font-semibold text-text"
>{$t.dashboard?.target_database}</th
>
<th class="px-4 py-2 font-semibold text-text"
>{$t.dashboard?.match_percent}</th
>
</tr>
</thead>
<tbody>
{#if m.bulkMigrateModel.suggestions.length > 0}
{#each m.bulkMigrateModel.suggestions as mapping}
{@const targetMapping = m.bulkMigrateModel.mappings.find(
(m) => m.source_db_uuid === mapping.source_db_uuid,
)}
<tr
class="border-b border-border last:border-b-0"
>
<td class="px-4 py-2">{mapping.source_db || mapping.source_db_name}</td>
<td class="px-4 py-2">
{#if targetMapping}
{m.bulkMigrateModel.targetDatabases.find(
(d) =>
d.uuid === targetMapping.target_db_uuid,
)?.database_name || mapping.target_db || mapping.target_db_name}
{:else}
<span class="text-destructive"
>{$t.dashboard?.not_mapped}</span
>
{/if}
</td>
<td class="px-4 py-2">
<span
class="px-2 py-0.5 rounded-full text-xs font-medium {mapping.confidence >
0.9
? 'bg-success-light text-success'
: 'bg-warning-light text-warning'}"
>
{Math.round(mapping.confidence * 100)}%
</span>
</td>
</tr>
{/each}
{:else}
<tr>
<td
colspan="3"
class="px-4 py-4 text-center text-text-muted italic"
>
{m.bulkMigrateModel.targetEnvId
? $t.dashboard?.no_databases_to_map ||
"No databases found to map"
: $t.dashboard?.select_target_for_mappings ||
"Select target environment to see mappings"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{:else}
<p class="text-xs text-text-subtle italic">
{$t.dashboard?.mapping_disabled_hint ||
"Database mapping is disabled. Dashboards will be imported with original database references."}
</p>
{/if}
</div>
<!-- Fix Cross-Filters (Spec 022 FR-007) -->
<div class="bg-primary-light border border-primary-ring rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={m.bulkMigrateModel.fixCrossFilters}
class="mt-0.5 h-4 w-4 rounded border-border-strong text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-text"
>{$t.dashboard?.fix_cross_filters_title ||
"Fix cross-filter bindings"}</span
>
<p class="text-xs text-text-muted mt-0.5">
{$t.dashboard?.fix_cross_filters_hint ||
"Automatically remap chart and dataset IDs in cross-filters to target environment IDs. Recommended when migrating dashboards with cross-filters."}
</p>
</div>
</label>
</div>
<!-- Selected Dashboards List -->
<div>
<p class="block text-sm font-medium text-text mb-2">
{$t.dashboard?.selected_dashboards}
</p>
<div
class="max-h-40 overflow-y-auto border border-border rounded-lg bg-surface-muted p-2"
>
{#each Array.from(m.selectedIds) as id}
{#each m.allDashboards as d}
{#if d.id === id}
<div class="flex items-center text-sm py-1 px-2">
<span class="text-success mr-2"></span>
<span class="text-text">{d.title}</span>
</div>
{/if}
{/each}
{/each}
</div>
</div>
<!-- Dry Run Summary -->
{#if m.bulkMigrateModel.targetEnvId && m.selectedIds.size > 0}
<div class="bg-primary-light border border-primary-ring rounded-lg p-4">
{#if m.bulkMigrateModel.dryRunLoading}
<div class="flex items-center gap-2 text-primary text-sm">
<svg
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span
>{$t.migration?.calculating_dry_run ||
"Calculating changes..."}</span
>
</div>
{:else if m.bulkMigrateModel.error && !m.bulkMigrateModel.dryRunResult}
<div class="text-destructive text-sm">
<span class="font-medium">{$t.common?.error}:</span>
{m.bulkMigrateModel.error}
</div>
{:else if m.bulkMigrateModel.dryRunResult?.summary}
{@const totalDashboards =
m.bulkMigrateModel.dryRunResult.summary.dashboards?.create +
m.bulkMigrateModel.dryRunResult.summary.dashboards?.update +
m.bulkMigrateModel.dryRunResult.summary.dashboards?.delete || 0}
{@const totalCharts =
m.bulkMigrateModel.dryRunResult.summary.charts?.create +
m.bulkMigrateModel.dryRunResult.summary.charts?.update +
m.bulkMigrateModel.dryRunResult.summary.charts?.delete || 0}
{@const totalDatasets =
m.bulkMigrateModel.dryRunResult.summary.datasets?.create +
m.bulkMigrateModel.dryRunResult.summary.datasets?.update +
m.bulkMigrateModel.dryRunResult.summary.datasets?.delete || 0}
{@const totalAll =
totalDashboards + totalCharts + totalDatasets}
<div
class="text-sm font-medium text-primary-hover flex items-start gap-2"
>
<svg
class="w-5 h-5 text-primary mt-0.5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
<span>
{(
$t.migration?.dry_run_summary ||
"Dry-run report: {total} items will be changed (Dashboards: {dashboards}, Charts: {charts}, Datasets: {datasets})"
)
.replace("{total}", totalAll)
.replace("{dashboards}", totalDashboards)
.replace("{charts}", totalCharts)
.replace("{datasets}", totalDatasets)}
</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="px-6 py-4 border-t border-border flex justify-end gap-3">
<button
class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors"
onclick={() => (m.showMigrateModal = false)}
disabled={m.bulkMigrateModel.dryRunLoading || m.bulkMigrateModel.currentStep >= 4}>{$t.common?.cancel}</button
>
<button
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={async () => {
if (!m.bulkMigrateModel.canExecute) return;
m.bulkMigrateModel.goToStep(4);
await m.bulkMigrateModel.executeMigration("/dashboards/migrate");
if (!m.bulkMigrateModel.error) {
m.showMigrateModal = false;
m.clearSelectedIds();
m.bulkMigrateModel.deselectAllDashboards();
const task = m.bulkMigrateModel.selectedTaskStore.current;
if (task?.id) openDrawerForTaskIfPreferred(task.id);
}
}}
disabled={!m.bulkMigrateModel.canExecute || m.bulkMigrateModel.dryRunLoading || m.bulkMigrateModel.currentStep >= 4}
>
{m.bulkMigrateModel.currentStep >= 4 ? $t.dashboard?.starting : $t.migration?.start}
</button>
</div>
</div>
</div>
{/if}
<!-- Backup Modal -->
{#if m.showBackupModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={() => (m.showBackupModal = false)}
onkeydown={(e) => e.key === "Escape" && (m.showBackupModal = false)}
role="presentation"
>
<div
class="bg-surface-card rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
aria-labelledby="backup-modal-title"
onkeydown={(e) => e.stopPropagation()}
>
<div
class="px-6 py-4 border-b border-border flex items-center justify-between relative"
>
<h2 id="backup-modal-title" class="text-xl font-bold">
{($t.dashboard?.backup_modal_title).replace(
"{count}",
String(m.selectedIds.size),
)}
</h2>
<button
onclick={() => (m.showBackupModal = false)}
class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all"
aria-label={$t.common?.close_modal}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="px-6 py-4">
<div class="space-y-6">
<!-- Env (Read-only) -->
<div>
<label
for="backup-env"
class="block text-sm font-medium text-text-muted mb-1"
>{$t.dashboard?.environment}</label
>
<div
id="backup-env"
class="px-4 py-2 bg-surface-muted border border-border rounded-lg text-text font-medium"
>
{environments.find((e) => e.id === m.selectedEnv)?.name ||
m.selectedEnv}
{$t.dashboard?.read_only}
</div>
</div>
<!-- Selected Dashboards List -->
<div>
<p class="block text-sm font-medium text-text mb-2">
{$t.dashboard?.selected_dashboards}
</p>
<div
class="max-h-40 overflow-y-auto border border-border rounded-lg bg-surface-muted p-2"
>
{#each Array.from(m.selectedIds) as id}
{#each m.allDashboards as d}
{#if d.id === id}
<div class="flex items-center text-sm py-1 px-2">
<span class="text-success mr-2"></span>
<span class="text-text">{d.title}</span>
</div>
{/if}
{/each}
{/each}
</div>
</div>
<!-- Schedule -->
<div>
<p
id="backup-schedule-label"
class="block text-sm font-medium text-text mb-2"
>
{$t.dashboard?.schedule}
</p>
<div class="space-y-3" aria-labelledby="backup-schedule-label">
<label class="flex items-center">
<input
type="radio"
name="schedule"
value=""
bind:group={m.backupSchedule}
class="mr-2"
/>
<span class="text-sm">{$t.dashboard?.one_time_backup}</span>
</label>
<label class="flex items-center">
<input
type="radio"
name="schedule"
value="0 0 * * *"
bind:group={m.backupSchedule}
class="mr-2"
/>
<span class="text-sm">{$t.dashboard?.schedule_backup}</span>
</label>
{#if m.backupSchedule !== ""}
<div class="ml-6">
<label
for="cron-expression"
class="block text-xs text-text-muted mb-1"
>{$t.dashboard?.cron_expression}</label
>
<input
id="cron-expression"
type="text"
class="search-input w-full text-sm"
placeholder={$t.dashboard?.cron_placeholder ||
"0 2 * * * (daily at 2 AM)"}
bind:value={m.backupSchedule}
/>
<button class="text-xs text-primary hover:underline mt-1"
>{$t.dashboard?.cron_help}</button
>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-border flex justify-end gap-3">
<button
class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors"
onclick={() => (m.showBackupModal = false)}
disabled={m.isSubmittingBackup}>{$t.common?.cancel}</button
>
<button
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => m.handleBulkBackup()}
disabled={m.selectedIds.size === 0 || m.isSubmittingBackup}
>
{m.isSubmittingBackup
? $t.dashboard?.starting
: $t.dashboard?.start_backup}
</button>
</div>
</div>
</div>
{/if}
<BackupDashboardModal model={m} {environments} />
<StartupEnvironmentWizard
open={shouldShowEnvironmentWizard}

View File

@@ -0,0 +1,107 @@
<!-- #region BackupDashboardModal [C:3] [TYPE Component] [SEMANTICS dashboard,backup,modal] -->
<!-- @BRIEF Modal for scheduling backup of selected dashboards. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [DashboardHubModel] -->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.js';
import type { DashboardHubModel } from '$lib/models/DashboardHubModel.svelte';
let { model, environments = [] }: {
model: DashboardHubModel;
environments: Array<{ id: string; name: string }>;
} = $props();
</script>
{#if model.showBackupModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={() => (model.showBackupModal = false)}
onkeydown={(e) => e.key === 'Escape' && (model.showBackupModal = false)}
role="presentation"
>
<div
class="bg-surface-card rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
aria-labelledby="backup-modal-title"
onkeydown={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b border-border flex items-center justify-between relative">
<h2 id="backup-modal-title" class="text-xl font-bold">
{($t.dashboard?.backup_modal_title).replace('{count}', String(model.selectedIds.size))}
</h2>
<button
onclick={() => (model.showBackupModal = false)}
class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all"
aria-label={$t.common?.close_modal}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="px-6 py-4">
<div class="space-y-6">
<!-- Env (Read-only) -->
<div>
<label for="backup-env" class="block text-sm font-medium text-text-muted mb-1">{$t.dashboard?.environment}</label>
<div id="backup-env" class="px-4 py-2 bg-surface-muted border border-border rounded-lg text-text font-medium">
{environments.find((e) => e.id === model.selectedEnv)?.name || model.selectedEnv}
{$t.dashboard?.read_only}
</div>
</div>
<!-- Selected Dashboards List -->
<div>
<p class="block text-sm font-medium text-text mb-2">{$t.dashboard?.selected_dashboards}</p>
<div class="max-h-40 overflow-y-auto border border-border rounded-lg bg-surface-muted p-2">
{#each Array.from(model.selectedIds) as id}
{#each model.allDashboards as d}
{#if d.id === id}
<div class="flex items-center text-sm py-1 px-2"><span class="text-success mr-2">☑</span><span class="text-text">{d.title}</span></div>
{/if}
{/each}
{/each}
</div>
</div>
<!-- Schedule -->
<div>
<p id="backup-schedule-label" class="block text-sm font-medium text-text mb-2">{$t.dashboard?.schedule}</p>
<div class="space-y-3" aria-labelledby="backup-schedule-label">
<label class="flex items-center">
<input type="radio" name="schedule" value="" bind:group={model.backupSchedule} class="mr-2" />
<span class="text-sm">{$t.dashboard?.one_time_backup}</span>
</label>
<label class="flex items-center">
<input type="radio" name="schedule" value="0 0 * * *" bind:group={model.backupSchedule} class="mr-2" />
<span class="text-sm">{$t.dashboard?.schedule_backup}</span>
</label>
{#if model.backupSchedule !== ''}
<div class="ml-6">
<label for="cron-expression" class="block text-xs text-text-muted mb-1">{$t.dashboard?.cron_expression}</label>
<input id="cron-expression" type="text" class="search-input w-full text-sm" placeholder={$t.dashboard?.cron_placeholder || '0 2 * * * (daily at 2 AM)'} bind:value={model.backupSchedule} />
<button class="text-xs text-primary hover:underline mt-1">{$t.dashboard?.cron_help}</button>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-border flex justify-end gap-3">
<button class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors"
onclick={() => (model.showBackupModal = false)}
disabled={model.isSubmittingBackup}>{$t.common?.cancel}</button>
<button class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => model.handleBulkBackup()}
disabled={model.selectedIds.size === 0 || model.isSubmittingBackup}>
{model.isSubmittingBackup ? $t.dashboard?.starting : $t.dashboard?.start_backup}
</button>
</div>
</div>
</div>
{/if}
<!-- #endregion BackupDashboardModal -->

View File

@@ -0,0 +1,234 @@
<!-- #region MigrateDashboardModal [C:4] [TYPE Component] [SEMANTICS dashboard,migration,modal,bulk-action] -->
<!-- @BRIEF Modal for bulk-migrating selected dashboards to a target environment with dry-run preview. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [DashboardHubModel] -->
<!-- @RELATION DEPENDS_ON -> [MappingTable] -->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.js';
import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js';
import MappingTable from '../../components/MappingTable.svelte';
import type { DashboardHubModel } from '$lib/models/DashboardHubModel.svelte';
let { model, environments = [] }: {
model: DashboardHubModel;
environments: Array<{ id: string; name: string }>;
} = $props();
</script>
{#if model.showMigrateModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={() => (model.showMigrateModal = false)}
onkeydown={(e) => e.key === 'Escape' && (model.showMigrateModal = false)}
role="presentation"
>
<div
class="bg-surface-card rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
aria-labelledby="migrate-modal-title"
onkeydown={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b border-border flex items-center justify-between relative">
<h2 id="migrate-modal-title" class="text-xl font-bold">
{($t.dashboard?.migrate_modal_title).replace('{count}', String(model.selectedIds.size))}
</h2>
<button
onclick={() => (model.showMigrateModal = false)}
class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all"
aria-label={$t.common?.close_modal}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="px-6 py-4">
<div class="space-y-6">
<!-- Source Env (Read-only) -->
<div>
<label for="source-env" class="block text-sm font-medium text-text-muted mb-1">{$t.migration?.source_env}</label>
<div id="source-env" class="px-4 py-2 bg-surface-muted border border-border rounded-lg text-text font-medium">
{environments.find((e) => e.id === model.selectedEnv)?.name || model.selectedEnv}
{$t.dashboard?.read_only}
</div>
</div>
<!-- Target Env -->
<div>
<label for="target-env" class="block text-sm font-medium text-text mb-2">{$t.migration?.target_env}</label>
<select id="target-env" class="env-dropdown w-full" value={model.bulkMigrateModel.targetEnvId}
onchange={(e) => {
model.bulkMigrateModel.selectTargetEnv((e.target as HTMLSelectElement).value);
if ((e.target as HTMLSelectElement).value) {
model.bulkMigrateModel.fetchDatabases().then(() => {
if (model.bulkMigrateModel.selectedDashboardIds.length > 0 && model.bulkMigrateModel.dryRunResult === null) {
model.bulkMigrateModel.calculateDryRun();
}
});
}
}}
>
<option value="">{$t.dashboard?.target_env_placeholder || 'Select target environment...'}</option>
{#each environments.filter((e) => e.id !== model.selectedEnv) as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<!-- Database Mappings Toggle -->
<div>
<div class="flex items-center justify-between mb-2">
<label for="use-db-mappings" class="block text-sm font-medium text-text">{$t.migration?.database_mappings}</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use-db-mappings" bind:checked={model.bulkMigrateModel.replaceDb} class="sr-only peer" />
<div class="w-9 h-5 bg-surface-muted peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-surface-card after:border-border-strong after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"></div>
<span class="ml-2 text-xs text-text-muted">{model.bulkMigrateModel.replaceDb ? $t.common?.on : $t.common?.off}</span>
</label>
</div>
{#if model.bulkMigrateModel.replaceDb}
<div class="flex items-center justify-between mb-2">
{#if model.bulkMigrateModel.suggestions.length > 0}
<button class="text-sm text-primary hover:underline" onclick={() => (model.isEditingMappings = !model.isEditingMappings)}>
{model.isEditingMappings ? $t.dashboard?.view_summary : $t.dashboard?.edit_mappings}
</button>
{/if}
</div>
{#if model.isEditingMappings}
<div class="border border-border rounded-lg overflow-hidden">
<MappingTable
sourceDatabases={model.bulkMigrateModel.sourceDatabases}
targetDatabases={model.bulkMigrateModel.targetDatabases}
mappings={model.bulkMigrateModel.mappings}
suggestions={model.bulkMigrateModel.suggestions}
onupdate={(e) => model.bulkMigrateModel.saveMapping(e.detail.sourceUuid, e.detail.targetUuid)}
/>
</div>
{:else}
<div class="border border-border rounded-lg overflow-hidden">
<table class="w-full text-sm text-left">
<thead class="bg-surface-muted border-b border-border">
<tr>
<th class="px-4 py-2 font-semibold text-text">{$t.dashboard?.source_database}</th>
<th class="px-4 py-2 font-semibold text-text">{$t.dashboard?.target_database}</th>
<th class="px-4 py-2 font-semibold text-text">{$t.dashboard?.match_percent}</th>
</tr>
</thead>
<tbody>
{#if model.bulkMigrateModel.suggestions.length > 0}
{#each model.bulkMigrateModel.suggestions as mapping}
{@const targetMapping = model.bulkMigrateModel.mappings.find((m2) => m2.source_db_uuid === mapping.source_db_uuid)}
<tr class="border-b border-border last:border-b-0">
<td class="px-4 py-2">{mapping.source_db || mapping.source_db_name}</td>
<td class="px-4 py-2">
{#if targetMapping}
{model.bulkMigrateModel.targetDatabases.find((d) => d.uuid === targetMapping.target_db_uuid)?.database_name || mapping.target_db || mapping.target_db_name}
{:else}
<span class="text-destructive">{$t.dashboard?.not_mapped}</span>
{/if}
</td>
<td class="px-4 py-2">
<span class="px-2 py-0.5 rounded-full text-xs font-medium {mapping.confidence > 0.9 ? 'bg-success-light text-success' : 'bg-warning-light text-warning'}">
{Math.round(mapping.confidence * 100)}%
</span>
</td>
</tr>
{/each}
{:else}
<tr>
<td colspan="3" class="px-4 py-4 text-center text-text-muted italic">
{model.bulkMigrateModel.targetEnvId ? $t.dashboard?.no_databases_to_map || 'No databases found to map' : $t.dashboard?.select_target_for_mappings || 'Select target environment to see mappings'}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{:else}
<p class="text-xs text-text-subtle italic">{$t.dashboard?.mapping_disabled_hint || 'Database mapping is disabled. Dashboards will be imported with original database references.'}</p>
{/if}
</div>
<!-- Fix Cross-Filters -->
<div class="bg-primary-light border border-primary-ring rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" bind:checked={model.bulkMigrateModel.fixCrossFilters} class="mt-0.5 h-4 w-4 rounded border-border-strong text-primary focus-visible:ring-primary-ring" />
<div>
<span class="text-sm font-medium text-text">{$t.dashboard?.fix_cross_filters_title || 'Fix cross-filter bindings'}</span>
<p class="text-xs text-text-muted mt-0.5">{$t.dashboard?.fix_cross_filters_hint || 'Automatically remap chart and dataset IDs in cross-filters to target environment IDs.'}</p>
</div>
</label>
</div>
<!-- Selected Dashboards List -->
<div>
<p class="block text-sm font-medium text-text mb-2">{$t.dashboard?.selected_dashboards}</p>
<div class="max-h-40 overflow-y-auto border border-border rounded-lg bg-surface-muted p-2">
{#each Array.from(model.selectedIds) as id}
{#each model.allDashboards as d}
{#if d.id === id}
<div class="flex items-center text-sm py-1 px-2"><span class="text-success mr-2"></span><span class="text-text">{d.title}</span></div>
{/if}
{/each}
{/each}
</div>
</div>
<!-- Dry Run Summary -->
{#if model.bulkMigrateModel.targetEnvId && model.selectedIds.size > 0}
<div class="bg-primary-light border border-primary-ring rounded-lg p-4">
{#if model.bulkMigrateModel.dryRunLoading}
<div class="flex items-center gap-2 text-primary text-sm">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{$t.migration?.calculating_dry_run || 'Calculating changes...'}</span>
</div>
{:else if model.bulkMigrateModel.error && !model.bulkMigrateModel.dryRunResult}
<div class="text-destructive text-sm"><span class="font-medium">{$t.common?.error}:</span> {model.bulkMigrateModel.error}</div>
{:else if model.bulkMigrateModel.dryRunResult?.summary}
{@const totalDashboards = model.bulkMigrateModel.dryRunResult.summary.dashboards?.create + model.bulkMigrateModel.dryRunResult.summary.dashboards?.update + model.bulkMigrateModel.dryRunResult.summary.dashboards?.delete || 0}
{@const totalCharts = model.bulkMigrateModel.dryRunResult.summary.charts?.create + model.bulkMigrateModel.dryRunResult.summary.charts?.update + model.bulkMigrateModel.dryRunResult.summary.charts?.delete || 0}
{@const totalDatasets = model.bulkMigrateModel.dryRunResult.summary.datasets?.create + model.bulkMigrateModel.dryRunResult.summary.datasets?.update + model.bulkMigrateModel.dryRunResult.summary.datasets?.delete || 0}
{@const totalAll = totalDashboards + totalCharts + totalDatasets}
<div class="text-sm font-medium text-primary-hover flex items-start gap-2">
<svg class="w-5 h-5 text-primary mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" /></svg>
<span>{($t.migration?.dry_run_summary || 'Dry-run: {total} items changed').replace('{total}', String(totalAll)).replace('{dashboards}', String(totalDashboards)).replace('{charts}', String(totalCharts)).replace('{datasets}', String(totalDatasets))}</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="px-6 py-4 border-t border-border flex justify-end gap-3">
<button class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors"
onclick={() => (model.showMigrateModal = false)}
disabled={model.bulkMigrateModel.dryRunLoading || model.bulkMigrateModel.currentStep >= 4}>{$t.common?.cancel}</button>
<button class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={async () => {
if (!model.bulkMigrateModel.canExecute) return;
model.bulkMigrateModel.goToStep(4);
await model.bulkMigrateModel.executeMigration('/dashboards/migrate');
if (!model.bulkMigrateModel.error) {
model.showMigrateModal = false;
model.clearSelectedIds();
model.bulkMigrateModel.deselectAllDashboards();
const task = model.bulkMigrateModel.selectedTaskStore.current;
if (task?.id) openDrawerForTaskIfPreferred(task.id);
}
}}
disabled={!model.bulkMigrateModel.canExecute || model.bulkMigrateModel.dryRunLoading || model.bulkMigrateModel.currentStep >= 4}>
{model.bulkMigrateModel.currentStep >= 4 ? $t.dashboard?.starting : $t.migration?.start}
</button>
</div>
</div>
</div>
{/if}
<!-- #endregion MigrateDashboardModal -->