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:
@@ -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}
|
||||
|
||||
107
frontend/src/routes/dashboards/BackupDashboardModal.svelte
Normal file
107
frontend/src/routes/dashboards/BackupDashboardModal.svelte
Normal 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 -->
|
||||
234
frontend/src/routes/dashboards/MigrateDashboardModal.svelte
Normal file
234
frontend/src/routes/dashboards/MigrateDashboardModal.svelte
Normal 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 -->
|
||||
Reference in New Issue
Block a user