diff --git a/backend/src/api/routes/dashboards.py b/backend/src/api/routes/dashboards.py index f02ecc7..05042ac 100644 --- a/backend/src/api/routes/dashboards.py +++ b/backend/src/api/routes/dashboards.py @@ -199,7 +199,11 @@ async def get_dashboards( all_tasks = task_manager.get_all_tasks() # Fetch dashboards with status using ResourceService - dashboards = await resource_service.get_dashboards_with_status(env, all_tasks) + dashboards = await resource_service.get_dashboards_with_status( + env, + all_tasks, + include_git_status=False, + ) # Apply search filter if provided if search: diff --git a/backend/src/services/resource_service.py b/backend/src/services/resource_service.py index 286018b..383a568 100644 --- a/backend/src/services/resource_service.py +++ b/backend/src/services/resource_service.py @@ -44,7 +44,8 @@ class ResourceService: async def get_dashboards_with_status( self, env: Any, - tasks: Optional[List[Task]] = None + tasks: Optional[List[Task]] = None, + include_git_status: bool = True, ) -> List[Dict[str, Any]]: with belief_scope("get_dashboards_with_status", f"env={env.id}"): client = SupersetClient(env) @@ -57,9 +58,12 @@ class ResourceService: dashboard_dict = dashboard dashboard_id = dashboard_dict.get('id') - # Get Git status if repo exists - git_status = self._get_git_status_for_dashboard(dashboard_id) - dashboard_dict['git_status'] = git_status + # Git status can be skipped for list endpoints and loaded lazily on UI side. + if include_git_status: + git_status = self._get_git_status_for_dashboard(dashboard_id) + dashboard_dict['git_status'] = git_status + else: + dashboard_dict['git_status'] = None # Show status of the latest LLM validation for this dashboard. last_task = self._get_last_llm_task_for_dashboard( diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte index b0f61fc..4c2407c 100644 --- a/frontend/src/routes/dashboards/+page.svelte +++ b/frontend/src/routes/dashboards/+page.svelte @@ -14,6 +14,7 @@ * @UX_STATE: BulkAction-Modal -> Migration or Backup modal open * @UX_FEEDBACK: Floating panel slides up from bottom when items selected * @UX_RECOVERY: Refresh button reloads dashboard list + * @UX_REATIVITY: State: $state, Derived: $derived. Никаких устаревших export let. * * @TEST_CONTRACT Page_DashboardHub -> * { @@ -46,70 +47,77 @@ } from "$lib/stores/environmentContext.js"; // State - let selectedEnv = null; - let allDashboards = []; - let dashboards = []; - let filteredDashboards = []; - let isLoading = true; - let error = null; + let selectedEnv = $state(null); + let allDashboards = $state([]); + let dashboards = $state([]); + let filteredDashboards = $state([]); + let isLoading = $state(true); + let error = $state(null); // Pagination state - let currentPage = 1; - let pageSize = 10; - let totalPages = 1; - let total = 0; + let currentPage = $state(1); + let pageSize = $state(10); + let totalPages = $state(1); + let total = $state(0); // Selection state - let selectedIds = new Set(); - let isAllSelected = false; - let isAllVisibleSelected = false; + let selectedIds = $state(new Set()); + let isAllSelected = $state(false); + let isAllVisibleSelected = $state(false); // Search state - let searchQuery = ""; - let sortColumn = "title"; - let sortDirection = "asc"; - let openFilterColumn = null; - let filterDropdownPosition = { left: 0, top: 0 }; - let columnFilterSearch = { + let searchQuery = $state(""); + let sortColumn = $state("title"); + let sortDirection = $state("asc"); + let openFilterColumn = $state(null); + let filterDropdownPosition = $state({ left: 0, top: 0 }); + let columnFilterSearch = $state({ title: "", git_status: "", llm_status: "", changed_on: "", actor: "", - }; - let columnFilters = { + }); + let columnFilters = $state({ title: new Set(), git_status: new Set(), llm_status: new Set(), changed_on: new Set(), actor: new Set(), - }; + }); // 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; + let showMigrateModal = $state(false); + let showBackupModal = $state(false); + let targetEnvId = $state(null); + let backupSchedule = $state(""); + let isSubmittingMigrate = $state(false); + let isSubmittingBackup = $state(false); + let dbMappings = $state({}); + let availableDbMappings = $state([]); + let sourceDatabases = $state([]); + let targetDatabases = $state([]); + let isEditingMappings = $state(false); + let useDbMappings = $state(true); + let fixCrossFilters = $state(true); // Individual action dropdown state - let openActionDropdown = null; // stores dashboard ID + let openActionDropdown = $state(null); // stores dashboard ID // Validation state - let validatingIds = new Set(); - let gitBusyIds = new Set(); - let cachedGitConfigs = []; + let validatingIds = $state(new Set()); + let gitBusyIds = $state(new Set()); + let gitResolvedIds = $state(new Set()); + let gitLoadingIds = $state(new Set()); + let cachedGitConfigs = $state([]); + + // Dry run state + let isDryRunLoading = $state(false); + let dryRunData = $state(null); + let dryRunError = $state(null); // Environment options - will be loaded from API - let environments = []; + let environments = $derived($environmentContextStore?.environments || []); // Debounced search function const debouncedSearch = debounce((query) => { @@ -182,7 +190,8 @@ * @POST: returns deterministic tailwind class string. */ function getValidationBadgeClass(level) { - if (level === "pass") return "bg-emerald-100 text-emerald-700 border-emerald-200"; + if (level === "pass") + return "bg-emerald-100 text-emerald-700 border-emerald-200"; if (level === "fail") return "bg-rose-100 text-rose-700 border-rose-200"; if (level === "warn") return "bg-amber-100 text-amber-700 border-amber-200"; return "bg-slate-100 text-slate-700 border-slate-200"; @@ -221,8 +230,10 @@ const trimmed = owner.trim(); if (trimmed) label = trimmed; } else if (owner && typeof owner === "object") { - const firstName = typeof owner.first_name === "string" ? owner.first_name.trim() : ""; - const lastName = typeof owner.last_name === "string" ? owner.last_name.trim() : ""; + const firstName = + typeof owner.first_name === "string" ? owner.first_name.trim() : ""; + const lastName = + typeof owner.last_name === "string" ? owner.last_name.trim() : ""; const fullName = [firstName, lastName].filter(Boolean).join(" "); label = fullName || @@ -277,8 +288,12 @@ const rawDashboards = allPages.flatMap((response) => response.dashboards); + gitResolvedIds = new Set(); + gitLoadingIds = new Set(); + allDashboards = rawDashboards.map((d) => { const owners = normalizeOwners(d.owners); + const hasGitStatus = !!d.git_status; return { id: d.id, title: d.title, @@ -287,12 +302,20 @@ changedOnLabel: formatDate(d.last_modified), owners, actorLabel: owners.length > 0 ? owners.join(", ") : "-", - 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, - }, + git: hasGitStatus + ? { + 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, + } + : { + status: "pending", + branch: null, + hasRepo: null, + hasChangesForCommit: false, + }, lastTask: d.last_task ? { status: normalizeTaskStatus(d.last_task.status), @@ -315,7 +338,10 @@ updateSelectionState(); } catch (err) { error = err.message || $t.dashboard?.load_failed; - console.error("[DashboardHub][Coherence:Failed]", err); + console.error( + "[DashboardHub][COHERENCE:FAILED] Load dashboards Error:", + err, + ); } finally { isLoading = false; } @@ -406,6 +432,9 @@ * @POST: returns localized summary string. */ function getGitSummaryLabel(dashboard) { + if (dashboard.git?.hasRepo === null) { + return $t.common?.loading || "Loading"; + } if (!dashboard.git?.hasRepo) { return $t.dashboard?.status_no_repo || "No Repo"; } @@ -423,7 +452,9 @@ * @POST: returns UNKNOWN fallback for missing status. */ function getLlmSummaryLabel(dashboard) { - return getValidationLabel(dashboard.lastTask?.validationStatus || "unknown"); + return getValidationLabel( + dashboard.lastTask?.validationStatus || "unknown", + ); } // [/DEF:DashboardHub.getLlmSummaryLabel:Function] @@ -492,8 +523,12 @@ const trigger = event?.currentTarget; if (trigger?.getBoundingClientRect) { const rect = trigger.getBoundingClientRect(); - const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1920; - const safeLeft = Math.max(8, Math.min(rect.left, viewportWidth - panelWidth - 8)); + const viewportWidth = + typeof window !== "undefined" ? window.innerWidth : 1920; + const safeLeft = Math.max( + 8, + Math.min(rect.left, viewportWidth - panelWidth - 8), + ); filterDropdownPosition = { left: safeLeft, top: rect.bottom + 8, @@ -606,9 +641,12 @@ */ function getSortValue(dashboard, column) { if (column === "title") return (dashboard.title || "").toLowerCase(); - if (column === "git_status") return (getGitSummaryLabel(dashboard) || "").toLowerCase(); - if (column === "llm_status") return (getLlmSummaryLabel(dashboard) || "").toLowerCase(); - if (column === "changed_on") return dashboard.changedOn ? new Date(dashboard.changedOn).getTime() : 0; + if (column === "git_status") + return (getGitSummaryLabel(dashboard) || "").toLowerCase(); + if (column === "llm_status") + return (getLlmSummaryLabel(dashboard) || "").toLowerCase(); + if (column === "changed_on") + return dashboard.changedOn ? new Date(dashboard.changedOn).getTime() : 0; if (column === "actor") return (dashboard.actorLabel || "").toLowerCase(); return ""; } @@ -691,6 +729,7 @@ const end = start + pageSize; dashboards = filteredDashboards.slice(start, end); updateSelectionState(); + void hydrateVisibleGitStatuses(); } // [/DEF:DashboardHub.applyGridTransforms:Function] @@ -709,7 +748,7 @@ // Handle action click async function handleAction(dashboard, action) { console.log( - `[DashboardHub][Action] ${action} on dashboard ${dashboard.title}`, + `[DashboardHub][REASON] ${action} on dashboard ${dashboard.title}`, ); closeActionDropdown(); @@ -742,10 +781,10 @@ message: `Update dashboard ${dashboard.title}`, }, ); - console.log("[DashboardHub][Action] Commit created:", response); + console.log("[DashboardHub][REASON] Commit created:", response); } } catch (err) { - console.error("[DashboardHub][Coherence:Failed]", err); + console.error("[DashboardHub][COHERENCE:FAILED] Action Error:", err); // Error toast is already shown by api.postApi } } @@ -768,7 +807,7 @@ }, }); - console.log("[DashboardHub][Action] Validation task started:", response); + console.log("[DashboardHub][REASON] Validation task started:", response); // Open task drawer if task was created if (response.task_id || response.id) { @@ -776,7 +815,7 @@ openDrawerForTask(taskId); } } catch (err) { - console.error("[DashboardHub][Coherence:Failed] Validation failed:", err); + console.error("[DashboardHub][COHERENCE:FAILED] Validation failed:", err); alert( $t.dashboard?.validation_start_failed + ": " + @@ -813,7 +852,7 @@ targetDatabases = targetRes || []; } catch (err) { console.error( - "[DashboardHub][Coherence:Failed] Failed to load databases:", + "[DashboardHub][COHERENCE:FAILED] Failed to load databases:", err, ); } @@ -832,12 +871,12 @@ try { console.log( - `[DashboardHub][Action] Loading DB mappings from ${selectedEnv} to ${targetEnvId}`, + `[DashboardHub][EXPLORE] Loading DB mappings from ${selectedEnv} to ${targetEnvId}`, ); const response = await api.getDatabaseMappings(selectedEnv, targetEnvId); availableDbMappings = response.mappings || []; console.log( - `[DashboardHub][Action] Loaded mappings:`, + `[DashboardHub][REFLECT] Loaded mappings:`, availableDbMappings, ); @@ -850,13 +889,64 @@ }); } catch (err) { console.error( - "[DashboardHub][Coherence:Failed] Failed to load DB mappings:", + "[DashboardHub][COHERENCE:FAILED] Failed to load DB mappings:", err, ); availableDbMappings = []; } } + // Calculate Migration Dry Run + async function calculateDryRun() { + if (!targetEnvId || selectedIds.size === 0) { + dryRunData = null; + return; + } + + isDryRunLoading = true; + dryRunError = null; + + try { + const payload = { + selected_ids: Array.from(selectedIds).map(Number), + source_env_id: selectedEnv, + target_env_id: targetEnvId, + replace_db_config: useDbMappings, + fix_cross_filters: fixCrossFilters, + }; + + const response = await api.calculateMigrationDryRun(payload); + dryRunData = response; + } catch (err) { + console.error( + "[DashboardHub][COHERENCE:FAILED] Dry run calculation failed:", + err, + ); + dryRunError = + err.message || $t.common?.error || "Failed to calculate changes"; + dryRunData = null; + } finally { + isDryRunLoading = false; + } + } + + // Reactive effect to recalculate dry run when inputs change + $effect(() => { + // track dependencies + const isOpen = showMigrateModal; + const target = targetEnvId; + const ids = Array.from(selectedIds).join(","); + const useDbs = useDbMappings; + const fixFilters = fixCrossFilters; + + if (isOpen && target && ids) { + calculateDryRun(); + } else if (!isOpen || !target || !ids) { + dryRunData = null; + dryRunError = null; + } + }); + // Handle bulk migrate async function handleBulkMigrate() { if (isSubmittingMigrate) return; @@ -877,7 +967,7 @@ fix_cross_filters: fixCrossFilters, }); console.log( - "[DashboardHub][Action] Bulk migration task created:", + "[DashboardHub][REASON] Bulk migration task created:", response.task_id, ); @@ -894,7 +984,10 @@ openDrawerForTask(taskId); } } catch (err) { - console.error("[DashboardHub][Coherence:Failed]", err); + console.error( + "[DashboardHub][COHERENCE:FAILED] Bulk migrate error:", + err, + ); alert($t.dashboard?.migration_task_failed); } finally { isSubmittingMigrate = false; @@ -914,7 +1007,7 @@ schedule: backupSchedule || undefined, }); console.log( - "[DashboardHub][Action] Bulk backup task created:", + "[DashboardHub][REASON] Bulk backup task created:", response.task_id, ); @@ -931,7 +1024,7 @@ openDrawerForTask(taskId); } } catch (err) { - console.error("[DashboardHub][Coherence:Failed]", err); + console.error("[DashboardHub][COHERENCE:FAILED] Bulk backup error:", err); alert($t.dashboard?.backup_task_failed); } finally { isSubmittingBackup = false; @@ -942,7 +1035,7 @@ function handleTaskStatusClick(dashboard) { if (dashboard.lastTask?.id) { console.log( - `[DashboardHub][Action] Open task drawer for task ${dashboard.lastTask.id}`, + `[DashboardHub][EXPLORE] Open task drawer for task ${dashboard.lastTask.id}`, ); openDrawerForTask(dashboard.lastTask.id); } @@ -987,14 +1080,30 @@ } function updateDashboardGitState(dashboardId, nextGit) { - dashboards = dashboards.map((dashboard) => - dashboard.id === dashboardId - ? { ...dashboard, git: { ...dashboard.git, ...nextGit } } - : dashboard, - ); + const mergeGitState = (collection) => + collection.map((dashboard) => + dashboard.id === dashboardId + ? { ...dashboard, git: { ...dashboard.git, ...nextGit } } + : dashboard, + ); + + allDashboards = mergeGitState(allDashboards); + filteredDashboards = mergeGitState(filteredDashboards); + dashboards = mergeGitState(dashboards); } - async function refreshDashboardGitState(dashboardId) { + async function refreshDashboardGitState(dashboardId, force = false) { + if (gitLoadingIds.has(dashboardId)) { + return; + } + if (force && gitResolvedIds.has(dashboardId)) { + gitResolvedIds.delete(dashboardId); + gitResolvedIds = new Set(gitResolvedIds); + } + if (!force && gitResolvedIds.has(dashboardId)) return; + + gitLoadingIds.add(dashboardId); + gitLoadingIds = new Set(gitLoadingIds); try { const status = await gitService.getStatus(dashboardId); updateDashboardGitState(dashboardId, { @@ -1010,9 +1119,30 @@ hasRepo: false, hasChangesForCommit: false, }); + } finally { + gitLoadingIds.delete(dashboardId); + gitLoadingIds = new Set(gitLoadingIds); + gitResolvedIds.add(dashboardId); + gitResolvedIds = new Set(gitResolvedIds); } } + async function hydrateVisibleGitStatuses() { + const pendingIds = dashboards + .filter( + (dashboard) => + dashboard.git?.hasRepo === null && + !gitResolvedIds.has(dashboard.id) && + !gitLoadingIds.has(dashboard.id), + ) + .map((dashboard) => dashboard.id); + if (pendingIds.length === 0) return; + + await Promise.all( + pendingIds.map((dashboardId) => refreshDashboardGitState(dashboardId)), + ); + } + async function handleGitInit(dashboard) { setGitBusy(dashboard.id, true); try { @@ -1041,7 +1171,7 @@ remoteUrl.trim(), ); addToast($t.git?.init_success || "Repository initialized", "success"); - await refreshDashboardGitState(dashboard.id); + await refreshDashboardGitState(dashboard.id, true); } catch (err) { addToast(err?.message || "Git init failed", "error"); } finally { @@ -1054,7 +1184,7 @@ try { await gitService.sync(dashboard.id, selectedEnv || null); addToast($t.git?.sync_success || "Synced", "success"); - await refreshDashboardGitState(dashboard.id); + await refreshDashboardGitState(dashboard.id, true); } catch (err) { addToast(err?.message || "Git sync failed", "error"); } finally { @@ -1082,7 +1212,7 @@ try { await gitService.commit(dashboard.id, message.trim()); addToast($t.git?.commit_success || "Committed", "success"); - await refreshDashboardGitState(dashboard.id); + await refreshDashboardGitState(dashboard.id, true); } catch (err) { addToast(err?.message || "Git commit failed", "error"); } finally { @@ -1096,7 +1226,7 @@ try { await gitService.pull(dashboard.id); addToast($t.git?.pull_success || "Pulled", "success"); - await refreshDashboardGitState(dashboard.id); + await refreshDashboardGitState(dashboard.id, true); } catch (err) { addToast(err?.message || "Git pull failed", "error"); } finally { @@ -1110,7 +1240,7 @@ try { await gitService.push(dashboard.id); addToast($t.git?.push_success || "Pushed", "success"); - await refreshDashboardGitState(dashboard.id); + await refreshDashboardGitState(dashboard.id, true); } catch (err) { addToast(err?.message || "Git push failed", "error"); } finally { @@ -1181,19 +1311,21 @@ return range; } - $: environments = $environmentContextStore?.environments || []; - $: if ( - $environmentContextStore?.selectedEnvId && - selectedEnv !== $environmentContextStore.selectedEnvId - ) { - selectedEnv = $environmentContextStore.selectedEnvId; - currentPage = 1; - selectedIds.clear(); - loadDashboards(); - } + $effect(() => { + if ( + $environmentContextStore?.selectedEnvId && + selectedEnv !== $environmentContextStore.selectedEnvId + ) { + selectedEnv = $environmentContextStore.selectedEnvId; + currentPage = 1; + selectedIds.clear(); + selectedIds = selectedIds; + loadDashboards(); + } + }); -