1673 lines
59 KiB
Svelte
1673 lines
59 KiB
Svelte
<!-- [DEF:DashboardHub:Page] -->
|
|
<script>
|
|
/**
|
|
* @TIER: CRITICAL
|
|
* @PURPOSE: Dashboard Hub - Central hub for managing dashboards with Git status and task actions
|
|
* @LAYER: UI
|
|
* @RELATION: BINDS_TO -> sidebarStore, taskDrawerStore
|
|
* @INVARIANT: Always shows environment selector and dashboard grid
|
|
*
|
|
* @UX_STATE: Loading -> Shows skeleton loader
|
|
* @UX_STATE: Loaded -> Shows dashboard grid with status badges
|
|
* @UX_STATE: Error -> Shows error banner with retry button
|
|
* @UX_STATE: Selecting -> Checkboxes checked, floating action panel appears
|
|
* @UX_STATE: BulkAction-Modal -> Migration or Backup modal open
|
|
* @UX_FEEDBACK: Clicking task status opens Task Drawer
|
|
* @UX_FEEDBACK: Floating panel slides up from bottom when items selected
|
|
* @UX_RECOVERY: Refresh button reloads dashboard list
|
|
*/
|
|
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { t } from "$lib/i18n";
|
|
import { openDrawerForTask } from "$lib/stores/taskDrawer.js";
|
|
import { api } from "$lib/api.js";
|
|
import { debounce } from "$lib/utils/debounce.js";
|
|
import { addToast } from "$lib/toasts.js";
|
|
import { gitService } from "../../services/gitService.js";
|
|
import MappingTable from "$components/MappingTable.svelte";
|
|
import {
|
|
environmentContextStore,
|
|
initializeEnvironmentContext,
|
|
setSelectedEnvironment,
|
|
} from "$lib/stores/environmentContext.js";
|
|
|
|
// State
|
|
let selectedEnv = null;
|
|
let dashboards = [];
|
|
let isLoading = true;
|
|
let error = null;
|
|
|
|
// Pagination state
|
|
let currentPage = 1;
|
|
let pageSize = 10;
|
|
let totalPages = 1;
|
|
let total = 0;
|
|
|
|
// Selection state
|
|
let selectedIds = new Set();
|
|
let isAllSelected = false;
|
|
let isAllVisibleSelected = false;
|
|
|
|
// Search state
|
|
let searchQuery = "";
|
|
|
|
// Bulk action modal state
|
|
let showMigrateModal = false;
|
|
let showBackupModal = false;
|
|
let targetEnvId = null;
|
|
let backupSchedule = "";
|
|
let isSubmittingMigrate = false;
|
|
let isSubmittingBackup = false;
|
|
let dbMappings = {};
|
|
let availableDbMappings = [];
|
|
let sourceDatabases = [];
|
|
let targetDatabases = [];
|
|
let isEditingMappings = false;
|
|
let useDbMappings = true;
|
|
let fixCrossFilters = true;
|
|
|
|
// Individual action dropdown state
|
|
let openActionDropdown = null; // stores dashboard ID
|
|
|
|
// Validation state
|
|
let validatingIds = new Set();
|
|
let gitBusyIds = new Set();
|
|
let cachedGitConfigs = [];
|
|
|
|
// Environment options - will be loaded from API
|
|
let environments = [];
|
|
|
|
// Debounced search function
|
|
const debouncedSearch = debounce((query) => {
|
|
searchQuery = query;
|
|
loadDashboards();
|
|
}, 300);
|
|
|
|
// Load environments and dashboards on mount
|
|
onMount(async () => {
|
|
await initializeEnvironmentContext();
|
|
if (!selectedEnv && $environmentContextStore?.selectedEnvId) {
|
|
selectedEnv = $environmentContextStore.selectedEnvId;
|
|
}
|
|
await loadDashboards();
|
|
});
|
|
|
|
// Handle document click to close dropdowns
|
|
function handleDocumentClick(event) {
|
|
if (!event.target.closest(".action-dropdown")) {
|
|
closeActionDropdown();
|
|
}
|
|
}
|
|
|
|
// Add document click listener
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("click", handleDocumentClick);
|
|
}
|
|
|
|
// Load dashboards from API
|
|
async function loadDashboards() {
|
|
if (!selectedEnv) return;
|
|
|
|
isLoading = true;
|
|
error = null;
|
|
try {
|
|
const response = await api.getDashboards(selectedEnv, {
|
|
search: searchQuery || undefined,
|
|
page: currentPage,
|
|
page_size: pageSize,
|
|
});
|
|
|
|
// Preserve selected IDs across pagination
|
|
const newSelectedIds = new Set();
|
|
response.dashboards.forEach((d) => {
|
|
if (selectedIds.has(d.id)) {
|
|
newSelectedIds.add(d.id);
|
|
}
|
|
});
|
|
selectedIds = newSelectedIds;
|
|
|
|
dashboards = response.dashboards.map((d) => ({
|
|
id: d.id,
|
|
title: d.title,
|
|
slug: d.slug,
|
|
git: {
|
|
status: d.git_status?.sync_status?.toLowerCase() || "no_repo",
|
|
branch: d.git_status?.branch || null,
|
|
hasRepo: d.git_status?.has_repo === true,
|
|
hasChangesForCommit: d.git_status?.has_changes_for_commit === true,
|
|
},
|
|
lastTask: d.last_task
|
|
? {
|
|
status: d.last_task.status?.toLowerCase() || null,
|
|
id: d.last_task.task_id,
|
|
}
|
|
: null,
|
|
actions: ["migrate", "backup"], // All dashboards have migrate and backup options
|
|
}));
|
|
|
|
// Update pagination state
|
|
total = response.total;
|
|
totalPages = response.total_pages;
|
|
|
|
// Update selection state
|
|
updateSelectionState();
|
|
} catch (err) {
|
|
error = err.message || $t.dashboard?.load_failed ;
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
// Handle environment change
|
|
function handleEnvChange(event) {
|
|
selectedEnv = event.target.value;
|
|
setSelectedEnvironment(selectedEnv);
|
|
currentPage = 1;
|
|
selectedIds.clear();
|
|
loadDashboards();
|
|
}
|
|
|
|
// Handle search input
|
|
function handleSearch(event) {
|
|
debouncedSearch(event.target.value);
|
|
}
|
|
|
|
// Handle page change
|
|
function handlePageChange(page) {
|
|
currentPage = page;
|
|
loadDashboards();
|
|
}
|
|
|
|
// Handle page size change
|
|
function handlePageSizeChange(event) {
|
|
pageSize = parseInt(event.target.value);
|
|
currentPage = 1;
|
|
loadDashboards();
|
|
}
|
|
|
|
// Update selection state based on current selection
|
|
function updateSelectionState() {
|
|
const visibleCount = dashboards.length;
|
|
const totalCount = total;
|
|
|
|
isAllSelected = selectedIds.size === totalCount && totalCount > 0;
|
|
isAllVisibleSelected =
|
|
selectedIds.size === visibleCount && visibleCount > 0;
|
|
}
|
|
|
|
// Handle checkbox change for individual dashboard
|
|
function handleCheckboxChange(dashboard, event) {
|
|
if (event.target.checked) {
|
|
selectedIds.add(dashboard.id);
|
|
} else {
|
|
selectedIds.delete(dashboard.id);
|
|
}
|
|
selectedIds = selectedIds; // Trigger reactivity
|
|
updateSelectionState();
|
|
}
|
|
|
|
// Handle select all
|
|
function handleSelectAll() {
|
|
if (isAllSelected) {
|
|
selectedIds.clear();
|
|
} else {
|
|
dashboards.forEach((d) => selectedIds.add(d.id));
|
|
}
|
|
selectedIds = selectedIds; // Trigger reactivity
|
|
updateSelectionState();
|
|
}
|
|
|
|
// Handle select visible
|
|
function handleSelectVisible() {
|
|
if (isAllVisibleSelected) {
|
|
dashboards.forEach((d) => selectedIds.delete(d.id));
|
|
} else {
|
|
dashboards.forEach((d) => selectedIds.add(d.id));
|
|
}
|
|
selectedIds = selectedIds; // Trigger reactivity
|
|
updateSelectionState();
|
|
}
|
|
|
|
// Toggle action dropdown
|
|
function toggleActionDropdown(dashboardId, event) {
|
|
event.stopPropagation();
|
|
openActionDropdown =
|
|
openActionDropdown === dashboardId ? null : dashboardId;
|
|
}
|
|
|
|
// Close action dropdown when clicking outside
|
|
function closeActionDropdown() {
|
|
openActionDropdown = null;
|
|
}
|
|
|
|
// Handle action click
|
|
async function handleAction(dashboard, action) {
|
|
console.log(
|
|
`[DashboardHub][Action] ${action} on dashboard ${dashboard.title}`,
|
|
);
|
|
closeActionDropdown();
|
|
|
|
try {
|
|
if (action === "migrate") {
|
|
// Select only this dashboard for migration
|
|
selectedIds.clear();
|
|
selectedIds.add(dashboard.id);
|
|
showMigrateModal = true;
|
|
targetEnvId = null;
|
|
dbMappings = {};
|
|
availableDbMappings = []; // Reset mappings until target env is selected
|
|
sourceDatabases = [];
|
|
targetDatabases = [];
|
|
isEditingMappings = false;
|
|
} else if (action === "backup") {
|
|
// Select only this dashboard for backup
|
|
selectedIds.clear();
|
|
selectedIds.add(dashboard.id);
|
|
showBackupModal = true;
|
|
backupSchedule = "";
|
|
// Force reactivity by updating selectedIds
|
|
selectedIds = new Set(selectedIds);
|
|
} else if (action === "commit") {
|
|
// Open commit modal or trigger commit task
|
|
// For now, we'll trigger a commit via git API
|
|
const response = await api.postApi(
|
|
`/git/repositories/${dashboard.id}/commit`,
|
|
{
|
|
message: `Update dashboard ${dashboard.title}`,
|
|
},
|
|
);
|
|
console.log("[DashboardHub][Action] Commit created:", response);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
// Error toast is already shown by api.postApi
|
|
}
|
|
}
|
|
|
|
// Handle validate - LLM dashboard validation
|
|
async function handleValidate(dashboard) {
|
|
if (validatingIds.has(dashboard.id)) return;
|
|
|
|
validatingIds.add(dashboard.id);
|
|
validatingIds = new Set(validatingIds); // Trigger reactivity
|
|
|
|
closeActionDropdown();
|
|
|
|
try {
|
|
const response = await api.postApi("/tasks", {
|
|
plugin_id: "llm_dashboard_validation",
|
|
params: {
|
|
dashboard_id: dashboard.id.toString(),
|
|
environment_id: selectedEnv,
|
|
},
|
|
});
|
|
|
|
console.log("[DashboardHub][Action] Validation task started:", response);
|
|
|
|
// Open task drawer if task was created
|
|
if (response.task_id || response.id) {
|
|
const taskId = response.task_id || response.id;
|
|
openDrawerForTask(taskId);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed] Validation failed:", err);
|
|
alert(
|
|
($t.dashboard?.validation_start_failed ) +
|
|
": " +
|
|
(err.message || $t.dashboard?.unknown_error ),
|
|
);
|
|
} finally {
|
|
validatingIds.delete(dashboard.id);
|
|
validatingIds = new Set(validatingIds);
|
|
}
|
|
}
|
|
|
|
// Handle target environment change
|
|
async function handleTargetEnvChange(event) {
|
|
targetEnvId = event.target.value;
|
|
if (targetEnvId) {
|
|
await Promise.all([loadDbMappings(), loadDatabases()]);
|
|
} else {
|
|
availableDbMappings = [];
|
|
dbMappings = {};
|
|
sourceDatabases = [];
|
|
targetDatabases = [];
|
|
}
|
|
}
|
|
|
|
// Load databases for both environments
|
|
async function loadDatabases() {
|
|
if (!selectedEnv || !targetEnvId) return;
|
|
try {
|
|
const [sourceRes, targetRes] = await Promise.all([
|
|
api.getEnvironmentDatabases(selectedEnv),
|
|
api.getEnvironmentDatabases(targetEnvId),
|
|
]);
|
|
sourceDatabases = sourceRes || [];
|
|
targetDatabases = targetRes || [];
|
|
} catch (err) {
|
|
console.error(
|
|
"[DashboardHub][Coherence:Failed] Failed to load databases:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Handle mapping update from MappingTable
|
|
function handleMappingUpdate(event) {
|
|
const { sourceUuid, targetUuid } = event.detail;
|
|
dbMappings[sourceUuid] = targetUuid;
|
|
dbMappings = dbMappings; // Trigger reactivity
|
|
}
|
|
|
|
// Load database mappings for migration modal
|
|
async function loadDbMappings() {
|
|
if (!selectedEnv || !targetEnvId) return;
|
|
|
|
try {
|
|
console.log(
|
|
`[DashboardHub][Action] Loading DB mappings from ${selectedEnv} to ${targetEnvId}`,
|
|
);
|
|
const response = await api.getDatabaseMappings(selectedEnv, targetEnvId);
|
|
availableDbMappings = response.mappings || [];
|
|
console.log(
|
|
`[DashboardHub][Action] Loaded mappings:`,
|
|
availableDbMappings,
|
|
);
|
|
|
|
// Auto-select mappings with high confidence
|
|
dbMappings = {};
|
|
availableDbMappings.forEach((m) => {
|
|
if (m.confidence > 0.8) {
|
|
dbMappings[m.source_db_uuid] = m.target_db_uuid;
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error(
|
|
"[DashboardHub][Coherence:Failed] Failed to load DB mappings:",
|
|
err,
|
|
);
|
|
availableDbMappings = [];
|
|
}
|
|
}
|
|
|
|
// Handle bulk migrate
|
|
async function handleBulkMigrate() {
|
|
if (isSubmittingMigrate) return;
|
|
if (selectedIds.size === 0) return;
|
|
if (!targetEnvId) {
|
|
alert(
|
|
$t.dashboard?.target_env_required ,
|
|
);
|
|
return;
|
|
}
|
|
|
|
isSubmittingMigrate = true;
|
|
try {
|
|
const response = await api.postApi("/dashboards/migrate", {
|
|
source_env_id: selectedEnv,
|
|
target_env_id: targetEnvId,
|
|
dashboard_ids: Array.from(selectedIds),
|
|
db_mappings: useDbMappings ? dbMappings : {},
|
|
replace_db_config: useDbMappings && Object.keys(dbMappings).length > 0,
|
|
fix_cross_filters: fixCrossFilters,
|
|
});
|
|
console.log(
|
|
"[DashboardHub][Action] Bulk migration task created:",
|
|
response.task_id,
|
|
);
|
|
|
|
// Close modal and open task drawer
|
|
showMigrateModal = false;
|
|
selectedIds.clear();
|
|
updateSelectionState();
|
|
|
|
if (response.task_id) {
|
|
const taskId =
|
|
typeof response.task_id === "object"
|
|
? response.task_id.id || response.task_id.task_id
|
|
: response.task_id;
|
|
openDrawerForTask(taskId);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
alert($t.dashboard?.migration_task_failed );
|
|
} finally {
|
|
isSubmittingMigrate = false;
|
|
}
|
|
}
|
|
|
|
// Handle bulk backup
|
|
async function handleBulkBackup() {
|
|
if (isSubmittingBackup) return;
|
|
if (selectedIds.size === 0) return;
|
|
|
|
isSubmittingBackup = true;
|
|
try {
|
|
const response = await api.postApi("/dashboards/backup", {
|
|
env_id: selectedEnv,
|
|
dashboard_ids: Array.from(selectedIds),
|
|
schedule: backupSchedule || undefined,
|
|
});
|
|
console.log(
|
|
"[DashboardHub][Action] Bulk backup task created:",
|
|
response.task_id,
|
|
);
|
|
|
|
// Close modal and open task drawer
|
|
showBackupModal = false;
|
|
selectedIds.clear();
|
|
updateSelectionState();
|
|
|
|
if (response.task_id) {
|
|
const taskId =
|
|
typeof response.task_id === "object"
|
|
? response.task_id.id || response.task_id.task_id
|
|
: response.task_id;
|
|
openDrawerForTask(taskId);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
alert($t.dashboard?.backup_task_failed );
|
|
} finally {
|
|
isSubmittingBackup = false;
|
|
}
|
|
}
|
|
|
|
// Handle task status click - open Task Drawer
|
|
function handleTaskStatusClick(dashboard) {
|
|
if (dashboard.lastTask?.id) {
|
|
console.log(
|
|
`[DashboardHub][Action] Open task drawer for task ${dashboard.lastTask.id}`,
|
|
);
|
|
openDrawerForTask(dashboard.lastTask.id);
|
|
}
|
|
}
|
|
|
|
function navigateToDashboardDetail(dashboardId) {
|
|
goto(
|
|
`/dashboards/${dashboardId}?env_id=${encodeURIComponent(selectedEnv)}`,
|
|
);
|
|
}
|
|
|
|
// Get status badge class
|
|
function getStatusBadgeClass(status) {
|
|
if (!status) return "bg-gray-100 text-gray-800";
|
|
switch (status.toLowerCase()) {
|
|
case "ok":
|
|
return "bg-green-100 text-green-800";
|
|
case "diff":
|
|
return "bg-yellow-100 text-yellow-800";
|
|
default:
|
|
return "bg-gray-100 text-gray-800";
|
|
}
|
|
}
|
|
|
|
function isGitBusy(dashboardId) {
|
|
return gitBusyIds.has(dashboardId);
|
|
}
|
|
|
|
function setGitBusy(dashboardId, busy) {
|
|
if (busy) {
|
|
gitBusyIds.add(dashboardId);
|
|
} else {
|
|
gitBusyIds.delete(dashboardId);
|
|
}
|
|
gitBusyIds = new Set(gitBusyIds);
|
|
}
|
|
|
|
async function ensureGitConfigs() {
|
|
if (cachedGitConfigs.length > 0) return cachedGitConfigs;
|
|
cachedGitConfigs = await gitService.getConfigs();
|
|
return cachedGitConfigs;
|
|
}
|
|
|
|
function updateDashboardGitState(dashboardId, nextGit) {
|
|
dashboards = dashboards.map((dashboard) =>
|
|
dashboard.id === dashboardId
|
|
? { ...dashboard, git: { ...dashboard.git, ...nextGit } }
|
|
: dashboard,
|
|
);
|
|
}
|
|
|
|
async function refreshDashboardGitState(dashboardId) {
|
|
try {
|
|
const status = await gitService.getStatus(dashboardId);
|
|
updateDashboardGitState(dashboardId, {
|
|
status: status?.is_dirty ? "diff" : "ok",
|
|
branch: status?.current_branch || null,
|
|
hasRepo: true,
|
|
hasChangesForCommit: Boolean(status?.is_dirty),
|
|
});
|
|
} catch (_err) {
|
|
updateDashboardGitState(dashboardId, {
|
|
status: "no_repo",
|
|
branch: null,
|
|
hasRepo: false,
|
|
hasChangesForCommit: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleGitInit(dashboard) {
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
const configs = await ensureGitConfigs();
|
|
if (!configs.length) {
|
|
addToast($t.git?.no_servers_configured || "No Git config found", "error");
|
|
return;
|
|
}
|
|
|
|
const config = configs[0];
|
|
const defaultRemote = config?.default_repository
|
|
? `${String(config.url || "").replace(/\/$/, "")}/${config.default_repository}.git`
|
|
: "";
|
|
const remoteUrl = prompt($t.git?.remote_url || "Remote URL", defaultRemote);
|
|
if (!remoteUrl) return;
|
|
|
|
await gitService.initRepository(dashboard.id, config.id, remoteUrl.trim());
|
|
addToast($t.git?.init_success || "Repository initialized", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git init failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitSync(dashboard) {
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.sync(dashboard.id, selectedEnv || null);
|
|
addToast($t.git?.sync_success || "Synced", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git sync failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitCommit(dashboard) {
|
|
if (!dashboard.git?.hasRepo) {
|
|
addToast($t.git?.not_linked || "Repository not linked", "error");
|
|
return;
|
|
}
|
|
if (!dashboard.git?.hasChangesForCommit) {
|
|
addToast($t.git?.nothing_to_commit || "No changes to commit", "error");
|
|
return;
|
|
}
|
|
|
|
const message = prompt(
|
|
$t.git?.commit_message || "Commit message",
|
|
`Update dashboard ${dashboard.title}`,
|
|
);
|
|
if (!message?.trim()) return;
|
|
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.commit(dashboard.id, message.trim());
|
|
addToast($t.git?.commit_success || "Committed", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git commit failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitPull(dashboard) {
|
|
if (!dashboard.git?.hasRepo) return;
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.pull(dashboard.id);
|
|
addToast($t.git?.pull_success || "Pulled", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git pull failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitPush(dashboard) {
|
|
if (!dashboard.git?.hasRepo) return;
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.push(dashboard.id);
|
|
addToast($t.git?.push_success || "Pushed", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git push failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
// Get task status icon
|
|
function getTaskStatusIcon(status) {
|
|
if (!status) return "";
|
|
switch (status.toLowerCase()) {
|
|
case "running":
|
|
return '<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 0 0 1-8 8z"/></svg>';
|
|
case "success":
|
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>';
|
|
case "error":
|
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>';
|
|
case "waiting_input":
|
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>';
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Generate pagination range with ellipsis
|
|
function getPaginationRange(current, total, maxVisible = 5) {
|
|
if (total <= maxVisible) {
|
|
return Array.from({ length: total }, (_, i) => i + 1);
|
|
}
|
|
|
|
const range = [];
|
|
const halfVisible = Math.floor(maxVisible / 2);
|
|
|
|
let start = Math.max(1, current - halfVisible);
|
|
let end = Math.min(total, current + halfVisible);
|
|
|
|
if (current <= halfVisible + 1) {
|
|
end = maxVisible;
|
|
} else if (current >= total - halfVisible) {
|
|
start = total - maxVisible + 1;
|
|
}
|
|
|
|
// Add first page and ellipsis if needed
|
|
if (start > 1) {
|
|
range.push(1);
|
|
if (start > 2) {
|
|
range.push("...");
|
|
}
|
|
}
|
|
|
|
// Add visible pages
|
|
for (let i = start; i <= end; i++) {
|
|
range.push(i);
|
|
}
|
|
|
|
// Add ellipsis and last page if needed
|
|
if (end < total) {
|
|
if (end < total - 1) {
|
|
range.push("...");
|
|
}
|
|
range.push(total);
|
|
}
|
|
|
|
return range;
|
|
}
|
|
|
|
$: environments = $environmentContextStore?.environments || [];
|
|
$: if (
|
|
$environmentContextStore?.selectedEnvId &&
|
|
selectedEnv !== $environmentContextStore.selectedEnvId
|
|
) {
|
|
selectedEnv = $environmentContextStore.selectedEnvId;
|
|
currentPage = 1;
|
|
selectedIds.clear();
|
|
loadDashboards();
|
|
}
|
|
</script>
|
|
|
|
<div class="mx-auto w-full max-w-7xl space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<h1 class="text-2xl font-bold text-gray-900">
|
|
{$t.nav?.dashboards }
|
|
</h1>
|
|
<div class="flex items-center space-x-4">
|
|
<select
|
|
class="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
bind:value={selectedEnv}
|
|
on:change={handleEnvChange}
|
|
>
|
|
{#each environments as env}
|
|
<option value={env.id}>{env.name}</option>
|
|
{/each}
|
|
</select>
|
|
<button
|
|
class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
|
|
on:click={loadDashboards}
|
|
>
|
|
{$t.common?.refresh }
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error Banner -->
|
|
{#if error}
|
|
<div
|
|
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between"
|
|
>
|
|
<span>{error}</span>
|
|
<button
|
|
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
|
|
on:click={loadDashboards}
|
|
>
|
|
{$t.common?.retry }
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Loading State -->
|
|
{#if isLoading}
|
|
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
<div
|
|
class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700"
|
|
>
|
|
<div class="col-span-1 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-2 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
</div>
|
|
{#each Array(5) as _}
|
|
<div
|
|
class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div class="col-span-1 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-2 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else if dashboards.length === 0}
|
|
<!-- Empty State -->
|
|
<div class="py-12 text-center text-gray-500">
|
|
<svg
|
|
class="w-16 h-16 mx-auto mb-4 text-gray-400"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M3 3h18v18H3V3zm16 16V5H5v14h14z" />
|
|
</svg>
|
|
<p>{$t.dashboard?.empty }</p>
|
|
</div>
|
|
{:else}
|
|
<!-- Toolbar -->
|
|
<div class="flex items-center justify-between mb-4 gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={handleSelectAll}
|
|
disabled={total === 0}
|
|
>
|
|
{isAllSelected
|
|
? $t.dashboard?.deselect_all
|
|
: $t.dashboard?.select_all }
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={handleSelectVisible}
|
|
disabled={dashboards.length === 0}
|
|
>
|
|
{isAllVisibleSelected
|
|
? $t.dashboard?.deselect_visible
|
|
: $t.dashboard?.select_visible }
|
|
</button>
|
|
{#if selectedIds.size > 0}
|
|
<span class="text-sm text-gray-600">
|
|
{(
|
|
$t.dashboard?.selected_count
|
|
).replace("{count}", String(selectedIds.size))}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<div>
|
|
<input
|
|
type="text"
|
|
class="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder={$t.dashboard?.search }
|
|
on:input={handleSearch}
|
|
value={searchQuery}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dashboard Grid -->
|
|
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
<!-- Grid Header -->
|
|
<div
|
|
class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700"
|
|
>
|
|
<div class="col-span-1"></div>
|
|
<div class="col-span-3 font-medium text-gray-900">
|
|
{$t.dashboard?.title }
|
|
</div>
|
|
<div class="col-span-2">
|
|
{$t.dashboard?.git_status }
|
|
</div>
|
|
<div class="col-span-3">{$t.dashboard?.last_task }</div>
|
|
<div class="col-span-3 flex items-center">
|
|
{$t.dashboard?.actions }
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Grid Rows -->
|
|
{#each dashboards as dashboard}
|
|
<div
|
|
class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<!-- Checkbox -->
|
|
<div class="col-span-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(dashboard.id)}
|
|
on:change={(e) => handleCheckboxChange(dashboard, e)}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Title -->
|
|
<div class="col-span-3 font-medium text-gray-900">
|
|
<button
|
|
class="text-left text-blue-700 hover:text-blue-900 hover:underline transition-colors"
|
|
on:click={() => navigateToDashboardDetail(dashboard.id)}
|
|
title={$t.dashboard?.open_overview }
|
|
>
|
|
{dashboard.title}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Git Status -->
|
|
<div class="col-span-2">
|
|
<div class="flex flex-col gap-1">
|
|
<span
|
|
class="status-badge {dashboard.git?.hasRepo ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-700'}"
|
|
>
|
|
{dashboard.git?.hasRepo
|
|
? ($t.dashboard?.status_repo || "Repo")
|
|
: ($t.dashboard?.status_no_repo || "No Repo")}
|
|
</span>
|
|
{#if dashboard.git?.hasRepo}
|
|
<span
|
|
class="status-badge {dashboard.git?.hasChangesForCommit ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}"
|
|
>
|
|
{dashboard.git?.hasChangesForCommit
|
|
? ($t.dashboard?.status_changes || "Diff")
|
|
: ($t.dashboard?.status_no_changes || "Synced")}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last Task -->
|
|
<div class="col-span-3">
|
|
{#if dashboard.lastTask}
|
|
<div
|
|
class="task-status-badge {dashboard.lastTask.status.toLowerCase()}"
|
|
on:click={() => handleTaskStatusClick(dashboard)}
|
|
on:keydown={(e) =>
|
|
(e.key === "Enter" || e.key === " ") &&
|
|
handleTaskStatusClick(dashboard)}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label={$t.dashboard?.view_task }
|
|
>
|
|
{@html getTaskStatusIcon(dashboard.lastTask.status)}
|
|
<span class="ml-1">
|
|
{#if dashboard.lastTask.status.toLowerCase() === "running"}
|
|
{$t.dashboard?.task_running }
|
|
{:else if dashboard.lastTask.status.toLowerCase() === "success"}
|
|
{$t.dashboard?.task_done }
|
|
{:else if dashboard.lastTask.status.toLowerCase() === "error"}
|
|
{$t.dashboard?.task_failed }
|
|
{:else if dashboard.lastTask.status.toLowerCase() === "waiting_input"}
|
|
{$t.dashboard?.task_waiting }
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<span class="text-gray-400">-</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="col-span-3 flex items-center">
|
|
<div class="flex items-center gap-1">
|
|
{#if !dashboard.git?.hasRepo}
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitInit(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.init_repo || "Init Git repository"}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
{:else}
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitSync(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.sync || "Sync from Superset"}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 1 1-3-6.7" />
|
|
<polyline points="21 3 21 9 15 9" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitCommit(dashboard)}
|
|
disabled={isGitBusy(dashboard.id) || !dashboard.git?.hasChangesForCommit}
|
|
title={$t.git?.commit || "Commit"}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 3v12" />
|
|
<path d="M7 10l5 5 5-5" />
|
|
<path d="M5 21h14" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitPull(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.pull || "Pull"}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 3v12" />
|
|
<path d="M17 10l-5 5-5-5" />
|
|
<path d="M5 21h14" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitPush(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.push || "Push"}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 21V9" />
|
|
<path d="M7 14l5-5 5 5" />
|
|
<path d="M5 3h14" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleAction(dashboard, "migrate")}
|
|
title={$t.dashboard?.action_migrate }
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleValidate(dashboard)}
|
|
disabled={validatingIds.has(dashboard.id)}
|
|
title={$t.dashboard?.action_validate }
|
|
>
|
|
{#if validatingIds.has(dashboard.id)}
|
|
<svg
|
|
class="animate-spin"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke-dasharray="32"
|
|
stroke-dashoffset="12"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M9 12l2 2 4-4" />
|
|
<circle cx="12" cy="12" r="10" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleAction(dashboard, "backup")}
|
|
title={$t.dashboard?.action_backup }
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="7,10 12,15 17,10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
{#if totalPages > 1}
|
|
<div
|
|
class="flex items-center justify-between px-4 py-3 bg-gray-50 border-t border-gray-200"
|
|
>
|
|
<div class="text-sm text-gray-600">
|
|
{($t.dashboard?.showing )
|
|
.replace("{start}", String((currentPage - 1) * pageSize + 1))
|
|
.replace("{end}", String(Math.min(currentPage * pageSize, total)))
|
|
.replace("{total}", String(total))}
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
{$t.common?.first }
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
{$t.dashboard?.previous }
|
|
</button>
|
|
{#each getPaginationRange(currentPage, totalPages) as pageNum}
|
|
{#if pageNum === "..."}
|
|
<span class="px-2 py-1 text-gray-500">...</span>
|
|
{:else}
|
|
<button
|
|
class="page-btn {pageNum === currentPage ? 'active' : ''}"
|
|
on:click={() => handlePageChange(pageNum)}
|
|
>
|
|
{pageNum}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
{$t.dashboard?.next }
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(totalPages)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
{$t.common?.last }
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<select
|
|
class="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
value={pageSize}
|
|
on:change={handlePageSizeChange}
|
|
>
|
|
<option value={5}>
|
|
{($t.dashboard?.per_page_option ).replace(
|
|
"{count}",
|
|
"5",
|
|
)}
|
|
</option>
|
|
<option value={10}>
|
|
{($t.dashboard?.per_page_option ).replace(
|
|
"{count}",
|
|
"10",
|
|
)}
|
|
</option>
|
|
<option value={25}>
|
|
{($t.dashboard?.per_page_option ).replace(
|
|
"{count}",
|
|
"25",
|
|
)}
|
|
</option>
|
|
<option value={50}>
|
|
{($t.dashboard?.per_page_option ).replace(
|
|
"{count}",
|
|
"50",
|
|
)}
|
|
</option>
|
|
<option value={100}>
|
|
{($t.dashboard?.per_page_option ).replace(
|
|
"{count}",
|
|
"100",
|
|
)}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Floating Bulk Action Panel -->
|
|
{#if selectedIds.size > 0}
|
|
<div
|
|
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg p-4 transition-transform transform translate-y-0"
|
|
>
|
|
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
|
<div class="flex items-center gap-4">
|
|
<span class="font-medium">
|
|
✓ {(
|
|
$t.dashboard?.selected_count
|
|
).replace("{count}", String(selectedIds.size))}
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button
|
|
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors"
|
|
on:click={() => {
|
|
showMigrateModal = true;
|
|
targetEnvId = null;
|
|
availableDbMappings = [];
|
|
dbMappings = {};
|
|
sourceDatabases = [];
|
|
targetDatabases = [];
|
|
isEditingMappings = false;
|
|
}}
|
|
>
|
|
{$t.dashboard?.bulk_migrate }
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors"
|
|
on:click={() => (showBackupModal = true)}
|
|
>
|
|
{$t.dashboard?.bulk_backup }
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={() => selectedIds.clear()}
|
|
>
|
|
{$t.common?.cancel }
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Migration Modal -->
|
|
{#if showMigrateModal}
|
|
<div
|
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
on:click={() => (showMigrateModal = false)}
|
|
on:keydown={(e) => e.key === "Escape" && (showMigrateModal = false)}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
|
|
on:click|stopPropagation
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
aria-labelledby="migrate-modal-title"
|
|
on:keydown={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
|
|
>
|
|
<h2 id="migrate-modal-title" class="text-xl font-bold">
|
|
{(
|
|
$t.dashboard?.migrate_modal_title
|
|
).replace("{count}", String(selectedIds.size))}
|
|
</h2>
|
|
<button
|
|
on:click={() => (showMigrateModal = false)}
|
|
class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 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-gray-500 mb-1"
|
|
>{$t.migration?.source_env }</label
|
|
>
|
|
<div
|
|
id="source-env"
|
|
class="px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 font-medium"
|
|
>
|
|
{environments.find((e) => e.id === selectedEnv)?.name ||
|
|
selectedEnv} {$t.dashboard?.read_only }
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Target Env -->
|
|
<div>
|
|
<label
|
|
for="target-env"
|
|
class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.migration?.target_env }</label
|
|
>
|
|
<select
|
|
id="target-env"
|
|
class="env-dropdown w-full"
|
|
bind:value={targetEnvId}
|
|
on:change={handleTargetEnvChange}
|
|
>
|
|
<option value="">
|
|
{$t.dashboard?.target_env_placeholder ||
|
|
"Select target environment..."}
|
|
</option>
|
|
{#each environments.filter((e) => e.id !== 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-gray-700"
|
|
>{$t.migration?.database_mappings }</label
|
|
>
|
|
<label class="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
id="use-db-mappings"
|
|
bind:checked={useDbMappings}
|
|
class="sr-only peer"
|
|
/>
|
|
<div
|
|
class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 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-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"
|
|
></div>
|
|
<span class="ml-2 text-xs text-gray-500"
|
|
>{useDbMappings
|
|
? $t.common?.on
|
|
: $t.common?.off }</span
|
|
>
|
|
</label>
|
|
</div>
|
|
|
|
{#if useDbMappings}
|
|
<div class="flex items-center justify-between mb-2">
|
|
{#if availableDbMappings.length > 0}
|
|
<button
|
|
class="text-sm text-blue-600 hover:underline"
|
|
on:click={() => (isEditingMappings = !isEditingMappings)}
|
|
>
|
|
{isEditingMappings
|
|
? $t.dashboard?.view_summary
|
|
: $t.dashboard?.edit_mappings }
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isEditingMappings}
|
|
<div
|
|
class="border border-gray-200 rounded-lg overflow-hidden"
|
|
>
|
|
<MappingTable
|
|
{sourceDatabases}
|
|
{targetDatabases}
|
|
mappings={Object.entries(dbMappings).map(([s, t]) => ({
|
|
source_db_uuid: s,
|
|
target_db_uuid: t,
|
|
}))}
|
|
suggestions={availableDbMappings}
|
|
on:update={handleMappingUpdate}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="border border-gray-200 rounded-lg overflow-hidden"
|
|
>
|
|
<table class="w-full text-sm text-left">
|
|
<thead class="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th class="px-4 py-2 font-semibold text-gray-700"
|
|
>{$t.dashboard?.source_database }</th
|
|
>
|
|
<th class="px-4 py-2 font-semibold text-gray-700"
|
|
>{$t.dashboard?.target_database }</th
|
|
>
|
|
<th class="px-4 py-2 font-semibold text-gray-700"
|
|
>{$t.dashboard?.match_percent }</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#if availableDbMappings.length > 0}
|
|
{#each availableDbMappings as mapping}
|
|
<tr
|
|
class="border-b border-gray-200 last:border-b-0"
|
|
>
|
|
<td class="px-4 py-2">{mapping.source_db}</td>
|
|
<td class="px-4 py-2">
|
|
{#if dbMappings[mapping.source_db_uuid]}
|
|
{targetDatabases.find(
|
|
(d) =>
|
|
d.uuid ===
|
|
dbMappings[mapping.source_db_uuid],
|
|
)?.database_name || mapping.target_db}
|
|
{:else}
|
|
<span class="text-red-500"
|
|
>{$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-green-100 text-green-800'
|
|
: 'bg-yellow-100 text-yellow-800'}"
|
|
>
|
|
{Math.round(mapping.confidence * 100)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{:else}
|
|
<tr>
|
|
<td
|
|
colspan="3"
|
|
class="px-4 py-4 text-center text-gray-500 italic"
|
|
>
|
|
{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-gray-400 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-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<label class="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={fixCrossFilters}
|
|
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<div>
|
|
<span class="text-sm font-medium text-gray-900"
|
|
>{$t.dashboard?.fix_cross_filters_title ||
|
|
"Fix cross-filter bindings"}</span
|
|
>
|
|
<p class="text-xs text-gray-500 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>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.dashboard?.selected_dashboards }</label
|
|
>
|
|
<div
|
|
class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg bg-gray-50 p-2"
|
|
>
|
|
{#each Array.from(selectedIds) as id}
|
|
{#each dashboards as d}
|
|
{#if d.id === id}
|
|
<div class="flex items-center text-sm py-1 px-2">
|
|
<span class="text-green-600 mr-2">☑</span>
|
|
<span class="text-gray-700">{d.title}</span>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={() => (showMigrateModal = false)}
|
|
disabled={isSubmittingMigrate}
|
|
>{$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"
|
|
on:click={handleBulkMigrate}
|
|
disabled={!targetEnvId ||
|
|
selectedIds.size === 0 ||
|
|
isSubmittingMigrate}
|
|
>
|
|
{isSubmittingMigrate
|
|
? $t.dashboard?.starting
|
|
: $t.migration?.start }
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Backup Modal -->
|
|
{#if showBackupModal}
|
|
<div
|
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
on:click={() => (showBackupModal = false)}
|
|
on:keydown={(e) => e.key === "Escape" && (showBackupModal = false)}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
|
|
on:click|stopPropagation
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
aria-labelledby="backup-modal-title"
|
|
on:keydown={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
|
|
>
|
|
<h2 id="backup-modal-title" class="text-xl font-bold">
|
|
{(
|
|
$t.dashboard?.backup_modal_title
|
|
).replace("{count}", String(selectedIds.size))}
|
|
</h2>
|
|
<button
|
|
on:click={() => (showBackupModal = false)}
|
|
class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 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-gray-500 mb-1"
|
|
>{$t.dashboard?.environment }</label
|
|
>
|
|
<div
|
|
id="backup-env"
|
|
class="px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 font-medium"
|
|
>
|
|
{environments.find((e) => e.id === selectedEnv)?.name ||
|
|
selectedEnv} {$t.dashboard?.read_only }
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selected Dashboards List -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.dashboard?.selected_dashboards }</label
|
|
>
|
|
<div
|
|
class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg bg-gray-50 p-2"
|
|
>
|
|
{#each Array.from(selectedIds) as id}
|
|
{#each dashboards as d}
|
|
{#if d.id === id}
|
|
<div class="flex items-center text-sm py-1 px-2">
|
|
<span class="text-green-600 mr-2">☑</span>
|
|
<span class="text-gray-700">{d.title}</span>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Schedule -->
|
|
<div>
|
|
<label
|
|
id="backup-schedule-label"
|
|
class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.dashboard?.schedule }</label
|
|
>
|
|
<div class="space-y-3" aria-labelledby="backup-schedule-label">
|
|
<label class="flex items-center">
|
|
<input
|
|
type="radio"
|
|
name="schedule"
|
|
value=""
|
|
bind:group={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={backupSchedule}
|
|
class="mr-2"
|
|
/>
|
|
<span class="text-sm"
|
|
>{$t.dashboard?.schedule_backup }</span
|
|
>
|
|
</label>
|
|
{#if backupSchedule !== ""}
|
|
<div class="ml-6">
|
|
<label
|
|
for="cron-expression"
|
|
class="block text-xs text-gray-500 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={backupSchedule}
|
|
/>
|
|
<button class="text-xs text-blue-600 hover:underline mt-1"
|
|
>{$t.dashboard?.cron_help }</button
|
|
>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={() => (showBackupModal = false)}
|
|
disabled={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"
|
|
on:click={handleBulkBackup}
|
|
disabled={selectedIds.size === 0 || isSubmittingBackup}
|
|
>
|
|
{isSubmittingBackup
|
|
? $t.dashboard?.starting
|
|
: $t.dashboard?.start_backup }
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- [/DEF:DashboardHub:Page] -->
|