fix: dashboard conflict issue (#7558)

### **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** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Error
handling</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>ImportDashboard.vue</strong><dd><code>Add dashboard
JSON validation before import</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></summary>
<hr>

web/src/views/Dashboards/ImportDashboard.vue

<li>Imported <code>validateDashboardJson</code> utility<br> <li> Added
schema validation before each import step<br> <li> Extended error
<code><span></code> for <code>dashboard_validation</code><br> <li>
Reformatted promise and notification code


</details>


  </td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7558/files#diff-660ac197e10735b4f08fc4b5a39744187d16c14156ced00eefde09dbaec323f0">+85/-25</a>&nbsp;
</td>

</tr>
</table></td></tr><tr><td><strong>Enhancement</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>RenderDashboardCharts.vue</strong><dd><code>Improve
GridStack layout update logic</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

web/src/views/Dashboards/RenderDashboardCharts.vue

<li>Included <code>selectedTabId</code> in panel <code>:key</code><br>
<li> Added <code>gridStackUpdateInProgress</code> guard<br> <li>
Optimized <code>refreshGridStack</code> clearing logic<br> <li> Added
console error logs on save and widget add


</details>


  </td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7558/files#diff-313f7c69102161242c893c863c50f1f1cd1fc19b68b5c99bf840b4b5c72f0f2e">+51/-42</a>&nbsp;
</td>

</tr>
</table></td></tr><tr><td><strong>Bug fix</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>commons.ts</strong><dd><code>Handle duplicate panel IDs
on retrieval</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></summary>
<hr>

web/src/utils/commons.ts

<li>Added <code>fixDuplicatePanelIds</code> to dedupe panel IDs<br> <li>
Called dedupe in <code>getDashboard</code> flow<br> <li> Saved and
re-fetched dashboard when duplicates found<br> <li> Implemented
<code>generateUniquePanelId</code> helper


</details>


  </td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7558/files#diff-17cb74f8142984fc46bbfecdeaacccc118fd5d4ab7f040a55695caaa309d104f">+81/-14</a>&nbsp;
</td>

</tr>
</table></td></tr></tr></tbody></table>

___

> <details> <summary> Need help?</summary><li>Type <code>/help how to
...</code> in the comments thread for any questions about PR-Agent
usage.</li><li>Check out the <a
href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a>
for more information.</li></details>

---------

Co-authored-by: Kirtan Thakkar <kirtan@kiara.tech>
This commit is contained in:
ktx-vaidehi
2025-07-15 12:11:43 +05:30
committed by GitHub
co-authored by Kirtan Thakkar
parent b057aeb4d0
commit 935dfbad61
3 changed files with 211 additions and 80 deletions
+81 -14
View File
@@ -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<string>();
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>): string => {
let id;
do {
id = `${PANEL_ID_PREFIX}${Math.floor(1000000 + Math.random() * 9000000)}`;
} while (existingIds.has(id));
return id;
};
export const deleteDashboardById = async (
+84 -24
View File
@@ -303,7 +303,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</span>
<span v-else>{{ errorMessage }}</span>
<span
v-else-if="errorMessage.field == 'dashboard_validation'"
class="text-red"
>
{{ errorMessage.message }}
</span>
<span v-else>{{
errorMessage.message || errorMessage
}}</span>
</div>
</div>
</div>
@@ -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 = () => {
@@ -86,7 +86,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div
v-for="item in panels"
:key="item.id"
:key="item.id + selectedTabId"
:gs-id="item.id"
:gs-x="getPanelLayout(item, 'x')"
:gs-y="getPanelLayout(item, 'y')"
@@ -465,6 +465,7 @@ export default defineComponent({
// refresh dashboard
refreshDashboard();
} finally {
}
});
@@ -479,8 +480,12 @@ export default defineComponent({
tab: route.query.tab ?? props.dashboardData.panels[0]?.tabId,
},
});
}; // GridStack initialization and methods
};
// GridStack initialization and methods
let gridStackUpdateInProgress = false;
const initGridStack = () => {
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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'><path d='M8 2 L8 8 L2 8' stroke='%23999999' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>")
no-repeat center;