From 935dfbad613cfdf9e3905cd59f139b1007fb6f15 Mon Sep 17 00:00:00 2001 From: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:11:43 +0530 Subject: [PATCH] fix: dashboard conflict issue (#7558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### **PR Type** Bug fix, Enhancement ___ ### **Description** - Validate dashboard JSON before import - Handle duplicate panel IDs in retrieval - Refine GridStack layout update logic - Improve error and import messages ___ ### **Changes diagram** ```mermaid flowchart LR node1["getDashboard called"] node2["fixDuplicatePanelIds"] node3["duplicates?"] node4["updateDashboard & re-fetch"] node5["return dashboard"] node1 --> node2 node2 --> node3 node3 -- "true" --> node4 node3 -- "false" --> node5 node4 --> node5 ``` ___ ### **Changes walkthrough** 📝
Relevant files
Error handling
ImportDashboard.vue
Add dashboard JSON validation before import                           

web/src/views/Dashboards/ImportDashboard.vue
  • Imported validateDashboardJson utility
  • Added schema validation before each import step
  • Extended error for dashboard_validation
  • Reformatted promise and notification code
  • +85/-25 
    Enhancement
    RenderDashboardCharts.vue
    Improve GridStack layout update logic                                       

    web/src/views/Dashboards/RenderDashboardCharts.vue
  • Included selectedTabId in panel :key
  • Added gridStackUpdateInProgress guard
  • Optimized refreshGridStack clearing logic
  • Added console error logs on save and widget add
  • +51/-42 
    Bug fix
    commons.ts
    Handle duplicate panel IDs on retrieval                                   

    web/src/utils/commons.ts
  • Added fixDuplicatePanelIds to dedupe panel IDs
  • Called dedupe in getDashboard flow
  • Saved and re-fetched dashboard when duplicates found
  • Implemented generateUniquePanelId helper
  • +81/-14 
    ___ >
    Need help?
  • Type /help how to ... in the comments thread for any questions about PR-Agent usage.
  • Check out the documentation for more information.
  • --------- Co-authored-by: Kirtan Thakkar --- web/src/utils/commons.ts | 95 ++++++++++++--- web/src/views/Dashboards/ImportDashboard.vue | 108 ++++++++++++++---- .../Dashboards/RenderDashboardCharts.vue | 88 +++++++------- 3 files changed, 211 insertions(+), 80 deletions(-) diff --git a/web/src/utils/commons.ts b/web/src/utils/commons.ts index d2868a8efe..1a654f3e7e 100644 --- a/web/src/utils/commons.ts +++ b/web/src/utils/commons.ts @@ -557,26 +557,93 @@ export const getDashboard = async ( folderId: any, ) => { // check if dashboard data is present in store - if (store.state.organizationData.allDashboardData[dashboardId]) { - return store.state.organizationData.allDashboardData[dashboardId]; + let dashboardJson = + store.state.organizationData.allDashboardData[dashboardId]; + + if (!dashboardJson && dashboardId) { + const apiResponse = await dashboardService.get_Dashboard( + store.state.selectedOrganization.identifier, + dashboardId, + folderId, + ); + + dashboardJson = await retrieveAndStoreDashboardData( + store, + dashboardId, + folderId, + apiResponse, + ); } - if (!dashboardId) { + if (!dashboardJson) { return {}; } - const apiResponse = await dashboardService.get_Dashboard( - store.state.selectedOrganization.identifier, - dashboardId, - folderId, - ); + // Fix duplicate panel IDs and check if any were found + const hasDuplicates = fixDuplicatePanelIds(dashboardJson); - return await retrieveAndStoreDashboardData( - store, - dashboardId, - folderId, - apiResponse, - ); + // If duplicates were found, save the dashboard and retrieve it with new IDs + if (hasDuplicates) { + // Save the dashboard with fixed IDs + await updateDashboard( + store, + store.state.selectedOrganization.identifier, + dashboardId, + dashboardJson, + folderId ?? "default", + ); + + // Retrieve the dashboard again to get the updated version with new IDs + const apiResponse = await dashboardService.get_Dashboard( + store.state.selectedOrganization.identifier, + dashboardId, + folderId, + ); + + dashboardJson = await retrieveAndStoreDashboardData( + store, + dashboardId, + folderId, + apiResponse, + ); + } + + return dashboardJson; +}; + +// Fix duplicate panel.id values by assigning unique ones +const PANEL_ID_PREFIX = "Panel_ID"; + +const fixDuplicatePanelIds = (dashboardJson: any): boolean => { + const panelIdSet = new Set(); + let hasDuplicates = false; + + for (const tab of dashboardJson?.tabs || []) { + if (!Array.isArray(tab?.panels)) continue; + + for (const panel of tab.panels) { + const originalId = panel?.id; + if (!originalId) continue; + + if (panelIdSet.has(originalId)) { + const newId = generateUniquePanelId(panelIdSet); + panel.id = newId; + hasDuplicates = true; + } + + panelIdSet.add(panel.id); + } + } + + return hasDuplicates; +}; + +const generateUniquePanelId = (existingIds: Set): string => { + let id; + do { + id = `${PANEL_ID_PREFIX}${Math.floor(1000000 + Math.random() * 9000000)}`; + } while (existingIds.has(id)); + return id; }; export const deleteDashboardById = async ( diff --git a/web/src/views/Dashboards/ImportDashboard.vue b/web/src/views/Dashboards/ImportDashboard.vue index 0483d92d45..f2c91ac966 100644 --- a/web/src/views/Dashboards/ImportDashboard.vue +++ b/web/src/views/Dashboards/ImportDashboard.vue @@ -303,7 +303,16 @@ along with this program. If not, see . - {{ errorMessage }} + + {{ errorMessage.message }} + + + {{ + errorMessage.message || errorMessage + }} @@ -326,6 +335,7 @@ import { useRouter, useRoute } from "vue-router"; import dashboardService from "../../services/dashboards.js"; import axios from "axios"; import { convertDashboardSchemaVersion } from "@/utils/dashboard/convertDashboardSchemaVersion"; +import { validateDashboardJson } from "@/utils/dashboard/convertDataIntoUnitValue"; import SelectFolderDropdown from "@/components/dashboards/SelectFolderDropdown.vue"; import useNotifications from "@/composables/useNotifications"; import AppTabs from "@/components/common/AppTabs.vue"; @@ -529,10 +539,10 @@ export default defineComponent({ return; } - const data = jsonStr.value.map((parsedContent, fileIndex) => { return new Promise(async (resolve, reject) => { - const fileName = jsonFiles.value[fileIndex]?.name || `File ${fileIndex + 1}`; + const fileName = + jsonFiles.value[fileIndex]?.name || `File ${fileIndex + 1}`; try { //this is done because if the user uploads a single dashboard, it will be an object and if the user uploads multiple dashboards, it will be an array of objects @@ -550,8 +560,24 @@ export default defineComponent({ //it will convert the dashboard schema version to the latest version try { - const convertedSchema = convertDashboardSchemaVersion(dashboard); - const res = await importDashboardFromJSON(convertedSchema, selectedFolder.value); + const convertedSchema = + convertDashboardSchemaVersion(dashboard); + + // Validate the converted schema before importing + const validationErrors = validateDashboardJson(convertedSchema); + if (validationErrors.length > 0) { + const errorMessage = validationErrors.join("; "); + results.push({ + index: i + 1, + error: new Error(errorMessage), + }); + continue; + } + + const res = await importDashboardFromJSON( + convertedSchema, + selectedFolder.value, + ); results.push({ index: i + 1, result: res }); } catch (e) { results.push({ index: i + 1, error: e }); @@ -559,17 +585,17 @@ export default defineComponent({ } const failedMessages = results - .filter(r => r.error) - .map(r => `${r.error?.message || r.error}`); + .filter((r) => r.error) + .map((r) => `${r.error?.message || r.error}`); - if (failedMessages.length) { - reject({ - file: `JSON ${fileIndex + 1}`, - error: failedMessages.join("; "), - }); - } else { - resolve({ file: fileName, results }); - } + if (failedMessages.length) { + reject({ + file: `JSON ${fileIndex + 1}`, + error: failedMessages.join("; "), + }); + } else { + resolve({ file: fileName, results }); + } } catch (e) { reject({ file: fileName, error: "Error processing file" }); } @@ -579,14 +605,18 @@ export default defineComponent({ Promise.allSettled(data).then(async (results) => { filesImportResults.value = results; - const successfulImports = results.filter(r => r.status === "fulfilled").length; + const successfulImports = results.filter( + (r) => r.status === "fulfilled", + ).length; if (results.length === successfulImports) { await resetAndRefresh(ImportType.FILES, selectedFolder.value); } if (successfulImports) { - showPositiveNotification(`${successfulImports} File(s) Imported Successfully`); + showPositiveNotification( + `${successfulImports} File(s) Imported Successfully`, + ); } const failedImports = results.length - successfulImports; @@ -598,7 +628,6 @@ export default defineComponent({ }); }; - // reset and refresh the value based on selected type const resetAndRefresh = async (type, selectedFolder) => { switch (type) { @@ -644,11 +673,18 @@ export default defineComponent({ //Example: if user uploads a single object file it will be converted to an array and if user uploads a array of objects it is already an array so we dont do anything const rawJson = JSON.parse(jsonStr.value); const dashboards = Array.isArray(rawJson) ? rawJson : [rawJson]; - const importPromises = dashboards.map((dashboard, index) => { try { const converted = convertDashboardSchemaVersion(dashboard); + + // Validate the converted schema before importing + const validationErrors = validateDashboardJson(converted); + if (validationErrors.length > 0) { + const errorMessage = validationErrors.join("; "); + return Promise.reject({ index, error: new Error(errorMessage) }); + } + return importDashboardFromJSON(converted, selectedFolder.value); } catch (e) { return Promise.reject({ index, error: e }); @@ -657,12 +693,16 @@ export default defineComponent({ const results = await Promise.allSettled(importPromises); - const successCount = results.filter(r => r.status === 'fulfilled').length; + const successCount = results.filter( + (r) => r.status === "fulfilled", + ).length; const failedCount = results.length - successCount; if (successCount > 0) { await resetAndRefresh(ImportType.URL, selectedFolder.value); - showPositiveNotification(`${successCount} Dashboard(s) Imported Successfully`); + showPositiveNotification( + `${successCount} Dashboard(s) Imported Successfully`, + ); } if (failedCount > 0) { @@ -674,13 +714,12 @@ export default defineComponent({ showErrorNotification("Failed to Import Dashboard"); } finally { if (jsonStr.value && typeof jsonStr.value !== "string") { - jsonStr.value = ""; - } + jsonStr.value = ""; + } isLoading.value = false; } }; - // import dashboard from json string const importFromJsonStr = async () => { isLoading.value = ImportType.JSON_STRING; @@ -691,6 +730,14 @@ export default defineComponent({ const convertedSchema = convertDashboardSchemaVersion(oldImportedSchema); + // Validate the converted schema before importing + const validationErrors = validateDashboardJson(convertedSchema); + if (validationErrors.length > 0) { + const errorMessage = validationErrors.join("; "); + showErrorNotification(`Validation failed: ${errorMessage}`); + return; + } + await importDashboardFromJSON( convertedSchema, selectedFolder.value, @@ -759,6 +806,7 @@ export default defineComponent({ } }; const validateBasicInputs = (input, index = 0) => { + // Basic title validation if (input.title === "" || typeof input.title !== "string") { dashboardErrorsToDisplay.value.push({ message: `Title is required for dashboard - ${index ? index + 1 : 1} and should be a string`, @@ -766,6 +814,18 @@ export default defineComponent({ dashboardIndex: index, }); } + + // Comprehensive dashboard validation using validateDashboardJson + const validationErrors = validateDashboardJson(input); + if (validationErrors.length > 0) { + validationErrors.forEach((error) => { + dashboardErrorsToDisplay.value.push({ + message: `Dashboard ${index ? index + 1 : 1}: ${error}`, + field: "dashboard_validation", + dashboardIndex: index, + }); + }); + } }; const goToCommunityDashboards = () => { diff --git a/web/src/views/Dashboards/RenderDashboardCharts.vue b/web/src/views/Dashboards/RenderDashboardCharts.vue index f032a1783a..221c9618d3 100644 --- a/web/src/views/Dashboards/RenderDashboardCharts.vue +++ b/web/src/views/Dashboards/RenderDashboardCharts.vue @@ -86,7 +86,7 @@ along with this program. If not, see . >
    { + if (!gridStackContainer.value || gridStackInstance) return; // Initialize GridStack with optimal configuration @@ -508,9 +513,14 @@ export default defineComponent({ ); // Event listeners for GridStack interactions - - // Handle layout changes (drag/resize) - gridStackInstance.on("change", (event, items) => { + + // Handle layout changes (drag/resize) - only update layout data, don't save during operations + gridStackInstance.on("change", async (event, items) => { + + if(gridStackUpdateInProgress) { + return; + } + if (items && items.length > 0) { updatePanelLayouts(items); // Update panel layout data saveDashboardData.execute(); // Save changes to backend @@ -521,7 +531,7 @@ export default defineComponent({ gridStackInstance.on("resizestop", (event, element) => { window.dispatchEvent(new Event("resize")); }); - }; // Update panel layout data from GridStack items + }; // Update panel layout data from GridStack items const updatePanelLayouts = (items) => { items.forEach((item) => { const panelId = item.id; @@ -534,9 +544,13 @@ export default defineComponent({ panel.layout.h = item.h; } }); - }; // Optimized GridStack refresh function + }; // Optimized GridStack refresh function const refreshGridStack = async () => { - if (!gridStackInstance || !gridStackContainer.value) return; + if (!gridStackInstance || !gridStackContainer.value) { + return; + } + + gridStackUpdateInProgress = true; // Wait for Vue to finish DOM updates await nextTick(); @@ -546,35 +560,24 @@ export default defineComponent({ // IMPORTANT: Disable animation and floating during reconstruction for better performance grid.float(false); - grid.setAnimation(false); // Clear all existing widgets completely to prevent stale references + grid.setAnimation(false); + + // Clear all existing widgets completely to prevent stale references const existingElements = grid.getGridItems(); - - existingElements.forEach((element) => { - grid.removeWidget(element, false); - }); - + // Force clear any remaining grid state grid.removeAll(false); - // Wait for DOM cleanup to complete await nextTick(); // Ensure DOM is ready - + if (panels.value.length === 0) { return; } - // Sort panels by their Y position first, then X position for optimal layout - const sortedPanels = [...panels.value].sort((a, b) => { - const aY = getPanelLayout(a, "y"); - const bY = getPanelLayout(b, "y"); - if (aY !== bY) return aY - bY; - return getPanelLayout(a, "x") - getPanelLayout(b, "x"); - }); - // Add panels in sorted order to maintain proper layout - for (const panel of sortedPanels) { + for (const panel of panels.value) { // Wait for the element to be available in DOM - await nextTick(); + await nextTick(); const element = gridStackContainer.value.querySelector( `[gs-id="${panel.id}"]`, ); @@ -590,7 +593,7 @@ export default defineComponent({ minH: getMinimumHeight(panel.type), id: panel.id, noMove: props.viewOnly, - noResize: props.viewOnly, + noResize: props.viewOnly, }; // Make widget with explicit layout @@ -604,10 +607,12 @@ export default defineComponent({ // Wait for all widgets to be added await nextTick(); // Ensure DOM is updated + // Trigger window resize to ensure charts render correctly + gridStackUpdateInProgress = false; window.dispatchEvent(new Event("resize")); - }; - + }; + // Add a method to reset grid layout const resetGridLayout = async () => { if (!gridStackInstance) return; @@ -635,8 +640,8 @@ export default defineComponent({ return panelData?.layout?.i || panelData.id; } return 0; - }; - + }; + // Get minimum height based on panel type for optimal display const getMinimumHeight = (type) => { switch (type) { @@ -654,7 +659,7 @@ export default defineComponent({ } }; - // Get minimum width based on panel type for optimal display + // Get minimum width based on panel type for optimal display const getMinimumWidth = (type) => { switch (type) { case "area": @@ -685,13 +690,14 @@ export default defineComponent({ watch( () => [selectedTabId.value], - async (newPanels, oldPanels) => { + async (newPanels, oldPanels) => { // Only refresh if the number of tab changes + await nextTick(); await refreshGridStack(); }, { deep: true }, // Deep watch to catch layout changes within panels ); - + // Initialize GridStack when component is mounted onMounted(async () => { await nextTick(); // Wait for DOM to be ready @@ -701,7 +707,6 @@ export default defineComponent({ // Clean up GridStack instance before component unmounts to prevent memory leaks onBeforeUnmount(() => { - // Clean up GridStack instance if (gridStackInstance) { gridStackInstance.off("change"); @@ -783,7 +788,7 @@ export default defineComponent({ isDashboardVariablesAndPanelsDataLoadedDebouncedValue, currentQueryTraceIds, openEditLayout, - saveDashboardData, + saveDashboardData, currentVariablesDataRef, resetGridLayout, refreshGridStack, @@ -829,7 +834,7 @@ export default defineComponent({ .grid-stack { background: transparent; margin: 2px; - + /* When grid is static (disabled), hide resize handles */ &.grid-stack-static { .ui-resizable-handle { @@ -846,7 +851,7 @@ export default defineComponent({ } .grid-stack-item-content { border: 1px solid #c2c2c27a; - border-radius: 4px; + border-radius: 4px; overflow: visible; border-radius: inherit; } @@ -855,7 +860,6 @@ export default defineComponent({ /* GridStack theme overrides */ :deep(.grid-stack) { .grid-stack-item { - .drag-allow { cursor: move; } @@ -869,10 +873,10 @@ export default defineComponent({ &.ui-resizable-resizing { opacity: 0.9; } - + > .ui-resizable-handle { - background: none; - + background: none; + &.ui-resizable-se { background: url("data:image/svg+xml;utf8,") no-repeat center;