From 00be51d9255cedc1907af7e6494ccdb1de1dadf4 Mon Sep 17 00:00:00 2001 From: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:14:06 +0000 Subject: [PATCH] fix(dashboards): keep the user's edit when a dashboard save conflicts (#15272) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 97 +++ .../DashboardSaveConflictDialog.spec.ts | 322 ++++++++ .../DashboardSaveConflictDialog.vue | 694 +++++++++++++++++ .../components/dashboards/PanelContainer.vue | 19 +- .../settings/AddSettingVariable.spec.ts | 19 + .../settings/AddSettingVariable.vue | 37 +- .../dashboards/settings/GeneralSettings.vue | 26 +- .../dashboards/settings/TabsSettings.spec.ts | 59 +- .../dashboards/settings/TabsSettings.vue | 83 +- .../settings/VariableSettings.spec.ts | 24 +- .../dashboards/settings/VariableSettings.vue | 48 +- .../components/dashboards/tabs/AddTab.spec.ts | 22 +- web/src/components/dashboards/tabs/AddTab.vue | 35 +- .../dashboards/tabs/TabList.spec.ts | 52 +- .../components/dashboards/tabs/TabList.vue | 45 +- .../dashboard/dashboardSaveConflictState.ts | 65 ++ .../useDashboardSaveConflict.spec.ts | 716 ++++++++++++++++++ .../dashboard/useDashboardSaveConflict.ts | 416 ++++++++++ .../traces/useTraceProcessing.spec.ts | 3 +- web/src/layouts/MainLayout.vue | 10 + web/src/lib/overlay/Dialog/ODialog.vue | 5 +- .../lib/overlay/Dialog/dialogStacking.spec.ts | 53 ++ web/src/lib/overlay/Dialog/dialogStacking.ts | 33 + web/src/locales/languages/ar-SA.json | 97 +++ web/src/locales/languages/de-DE.json | 97 +++ web/src/locales/languages/en-US.json | 97 +++ web/src/locales/languages/es-ES.json | 97 +++ web/src/locales/languages/fr-FR.json | 97 +++ web/src/locales/languages/it-IT.json | 97 +++ web/src/locales/languages/ja-JP.json | 97 +++ web/src/locales/languages/ko-KR.json | 97 +++ web/src/locales/languages/nl-NL.json | 97 +++ web/src/locales/languages/pl-PL.json | 97 +++ web/src/locales/languages/pt-PT.json | 97 +++ web/src/locales/languages/ru-RU.json | 97 +++ web/src/locales/languages/tr-TR.json | 97 +++ web/src/locales/languages/vi-VN.json | 97 +++ web/src/locales/languages/zh-CN.json | 97 +++ web/src/locales/languages/zh-TW.json | 97 +++ web/src/utils/commons.ts | 155 +++- web/src/utils/dashboard/saveConflict.spec.ts | 355 +++++++++ web/src/utils/dashboard/saveConflict.ts | 435 +++++++++++ .../Dashboards/RenderDashboardCharts.spec.ts | 40 +- .../Dashboards/RenderDashboardCharts.vue | 42 +- web/src/views/Dashboards/ViewDashboard.vue | 115 ++- .../Dashboards/addPanel/AddPanel.spec.ts | 78 +- .../views/Dashboards/addPanel/AddPanel.vue | 112 +-- .../views/Dashboards/viewDashboard.spec.ts | 145 ++++ 48 files changed, 5665 insertions(+), 247 deletions(-) create mode 100644 web/src/components/dashboards/DashboardSaveConflictDialog.spec.ts create mode 100644 web/src/components/dashboards/DashboardSaveConflictDialog.vue create mode 100644 web/src/composables/dashboard/dashboardSaveConflictState.ts create mode 100644 web/src/composables/dashboard/useDashboardSaveConflict.spec.ts create mode 100644 web/src/composables/dashboard/useDashboardSaveConflict.ts create mode 100644 web/src/lib/overlay/Dialog/dialogStacking.spec.ts create mode 100644 web/src/lib/overlay/Dialog/dialogStacking.ts create mode 100644 web/src/utils/dashboard/saveConflict.spec.ts create mode 100644 web/src/utils/dashboard/saveConflict.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 7a79960f87..6c8d680988 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -8715,6 +8715,103 @@ "rowsPerPage": "7051b8bbc62daa11", "rowsPerPageTooltip": "d42813bc4029d08c", "save": "1509f561f2416598", + "saveConflict": { + "added": "6b02e0d363a4af1c", + "back": "76900f1bfd16c8d4", + "baseUnknown": "480eac3e6d29656a", + "cannotUndo": "60dddbb0d7da619b", + "change": { + "addedPanel": "7098801c586ed504", + "addedTab": "cb7fad087532ea61", + "addedVariable": "350dca9cf38274ae", + "changedSettings": "26127bbdf241622a", + "deletedPanel": "a50304674b885cc6", + "deletedTab": "c78719d42bac493b", + "deletedVariable": "fad1535ad646f772", + "duplicatedPanel": "fb655f96a306503c", + "editedJson": "632803d315a57f07", + "editedPanel": "4f766fd42b7f8cd5", + "editedVariable": "8ec9fb3a37c2447a", + "movedPanel": "24bd9df76a579cd7", + "movedPanels": "0df6c34650203df2", + "renamedTab": "bc28269c39f535b6", + "reorderedTabs": "95117e97b0254bfc", + "reorderedVariables": "d5ddaaeea52bdfbd" + }, + "changed": "2a6141e43be0c212", + "checking": "b185ea5c5812bae4", + "chipAdded": "114b07dc4f0098c6", + "chipChanged": "872f328c67d0e691", + "chipRemoved": "ddb33a0af03980a3", + "compareDifferent": "e9cdbd4dd56c72ee", + "compareField": "513b6492ecabdf1b", + "compareOther": "6e1332d2beadc5e5", + "compareRemoved": "14d8c0464d8ace89", + "compareSame": "81696990fe98a4e6", + "compareTheirs": "5a8edce3d938a735", + "compareTitle": "7e8cd2056da73a7f", + "compareYours": "f06cf159ad184289", + "discard": "eb1a70e39274bb76", + "impactDeletesEdited": "7540f8a22fa5a591", + "impactNothing": "475b333880f1ccf5", + "impactRemoves": "83fd0fd4a70b16e9", + "impactRestores": "d553afe4e038e667", + "impactReverts": "ff270b04233ccc13", + "impactSettings": "8b34014903265465", + "impactUnknown": "31f9bb1938e08712", + "kindPanel": "d0d91173008b6f83", + "kindSettings": "74a883a037bc227f", + "kindTab": "90ddf1963abb36c9", + "kindVariable": "e57e998726030b84", + "loadFailed": "9e9a4f3fef3ffcaa", + "merge": "8851aaa7d08ca1ae", + "mergeHint": "41ab12a53d84f6a9", + "mergeUnavailable": "f1291f97f5626bcb", + "notSaved": "790b38476d4b0216", + "notSavedChange": "062694f1d1e3b14f", + "nothingChanged": "31ef1ec561f045f7", + "overwrite": "b24963ea2cbc74b9", + "overwriteHint": "5aedc235b36f4cf3", + "overwriteRemoves": "03a71af16a95d5a3", + "overwriteReplaces": "a9e53ada6f12492d", + "panelCount": "31bbca679806b213", + "panelTouched": "a55dccd5fe284ca9", + "panelUntouched": "5cd59d9f7b6b8782", + "reload": "bdc090ec61e3fcfc", + "reloadHint": "f6c7666119f2d326", + "removeAndOverwrite": "5bc78f15a6f5e7cb", + "removed": "4118fb4fed0ecec9", + "renamedFrom": "4e0dcf51229a8632", + "resolve": "c8f193b315c86f3a", + "retry": "942087cc2d41e013", + "reviewHeading": "a13adc0e28fa4e42", + "reviewIntro": "3c26880bc574d061", + "saveFailed": "6b6f39aaececd86c", + "saveMerged": "b0176cce401c4ef4", + "savedAgain": "46ea08fa62731e0b", + "savedBy": "819451b3c7070d7b", + "savedByUnknown": "2c7c2326901bfe39", + "settingDescription": "c9046f7a37ad0ea7", + "settingDynamicFilters": "245d0fe3e84c969f", + "settingOther": "252d9e8941fc5d7a", + "settingTimeRange": "c7eab79536307e8c", + "settingTitle": "82a3537ff0dbce7e", + "stillUnresolved": "2c973039944aba05", + "tabCount": "61293d125134f7ec", + "tagTheirsEdited": "3350a34d59d03201", + "tagTheirsNew": "dd342050994d94f6", + "tagYours": "54b26f7d3d0ddc66", + "title": "8a8e2856aad142a8", + "toastMergeDiscarded": "9a2291b3fbe47831", + "toastMerged": "1bb862312db686fe", + "toastOverwritten": "c10a190bbb1f494a", + "toastOverwrittenNoTime": "330e479ef39af32d", + "toastReloaded": "33c3dc8525b71ef3", + "toastReloadedChange": "1bb408f1b60f0134", + "variableCount": "ebdcd13cca777a5f", + "whatChanged": "b76bcc96e0656c0d", + "yourChange": "95a544905acdb687" + }, "scatter": "4720c0c3df2ea08e", "scatterLabel": "4720c0c3df2ea08e", "scheduledDashboards": "ce101109fb9453cb", diff --git a/web/src/components/dashboards/DashboardSaveConflictDialog.spec.ts b/web/src/components/dashboards/DashboardSaveConflictDialog.spec.ts new file mode 100644 index 0000000000..85e763b110 --- /dev/null +++ b/web/src/components/dashboards/DashboardSaveConflictDialog.spec.ts @@ -0,0 +1,322 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import i18n from "@/locales"; +import { raw, type TranslateFn } from "@/types/i18n"; + +vi.mock("reka-ui", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DialogPortal: actual.DialogContent }; +}); + +const service = vi.hoisted(() => ({ + get_Dashboard: vi.fn(), + save: vi.fn(), + list: vi.fn(), +})); +vi.mock("@/services/dashboards", () => ({ default: service })); +vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn() })); +vi.mock("@/composables/dashboard/usePanelCache", () => ({ + dropPanelCache: vi.fn(), + dropDashboardPanelCache: vi.fn(), +})); + +import DashboardSaveConflictDialog from "./DashboardSaveConflictDialog.vue"; +import { getDashboard } from "@/utils/commons"; +import { openDashboardConflict } from "@/composables/dashboard/useDashboardSaveConflict"; +import { dashboardSaveConflictState as state } from "@/composables/dashboard/dashboardSaveConflictState"; + +const panel = (id: string, title: string, y = 0) => ({ + id, + title, + type: "line", + queries: [], + layout: { x: 0, y, w: 96, h: 18, i: y + 1 }, +}); + +const doc = (panels: any[], extra: Record = {}) => ({ + version: 8, + dashboardId: "d1", + title: "Ops overview", + description: "", + tabs: [{ tabId: "default", name: "Overview", panels }], + variables: { showDynamicFilters: false, list: [] }, + ...extra, +}); + +const response = (dashboard: any, hash: string) => ({ + data: { version: 8, hash, updatedAt: Date.now() * 1000 - 240_000_000, v8: dashboard }, +}); + +const makeStore = () => { + const store: any = { + state: { + selectedOrganization: { identifier: "acme" }, + userInfo: { name: "me" }, + organizationData: { allDashboardData: {}, allDashboardListHash: {}, allDashboardList: {} }, + }, + dispatch: vi.fn((action: string, payload: any) => { + const od = store.state.organizationData; + if (action === "setDashboardData") od.allDashboardData = payload; + if (action === "setAllDashboardListHash") od.allDashboardListHash = payload; + }), + }; + return store; +}; + +const byTest = (wrapper: VueWrapper, id: string) => wrapper.find(`[data-test="${id}"]`); + +const BASE = () => doc([panel("p1", "Error rate"), panel("p2", "Latency", 18)]); +const LATEST = () => doc([panel("p1", "Error rate"), panel("p3", "Disk usage", 18)]); + +const openConflict = async ( + panelId?: string, + opts: { latest?: any; mine?: any; change?: string; wait?: boolean } = {}, +) => { + const store = makeStore(); + service.get_Dashboard.mockResolvedValueOnce(response(BASE(), "h1")); + await getDashboard(store, "d1", "default"); + service.get_Dashboard.mockResolvedValueOnce(response(opts.latest ?? LATEST(), "h2")); + const opened = openDashboardConflict( + { + dashboardId: "d1", + folderId: "default", + panelId, + mine: opts.mine, + change: opts.change ? raw(opts.change) : undefined, + }, + { store, t: i18n.global.t as unknown as TranslateFn, locale: "en-US" }, + ); + if (opts.wait !== false) await opened; + return opened; +}; + +describe("DashboardSaveConflictDialog", () => { + let wrapper: VueWrapper; + + beforeEach(() => { + vi.clearAllMocks(); + service.list.mockResolvedValue({ data: { dashboards: [] } }); + }); + + afterEach(() => { + wrapper?.unmount(); + state.open = false; + }); + + const mountDialog = async () => { + wrapper = mount(DashboardSaveConflictDialog, { global: { plugins: [i18n] } }); + await flushPromises(); + }; + + it("stacks above a settings drawer or dialog that is already open", async () => { + const drawer = document.createElement("div"); + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("data-state", "open"); + document.body.appendChild(drawer); + try { + await openConflict(); + await mountDialog(); + const panel = wrapper.find("[data-o2-dialog]").element as HTMLElement; + expect(Number(panel.style.zIndex)).toBe(7000); + } finally { + drawer.remove(); + } + }); + + it("keeps the relative time of their save current while the dialog stays open", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] }); + try { + await openConflict(); + await mountDialog(); + const savedBy = () => byTest(wrapper, "dashboard-save-conflict-saved-by").text(); + expect(savedBy()).toContain("4 minutes ago"); + + vi.advanceTimersByTime(60_000); + await flushPromises(); + expect(savedBy()).toContain("5 minutes ago"); + } finally { + vi.useRealTimers(); + } + }); + + it("shows when the other save happened and what it changed", async () => { + await openConflict(); + await mountDialog(); + + expect(byTest(wrapper, "dashboard-save-conflict-saved-by").text()).toMatch( + /^Saved by someone else 4 minutes ago \(.+\)$/, + ); + expect(byTest(wrapper, "dashboard-save-conflict-change-panel-added").text()).toContain( + "Disk usage", + ); + expect(byTest(wrapper, "dashboard-save-conflict-change-panel-removed").text()).toContain( + "Latency", + ); + expect(byTest(wrapper, "dashboard-save-conflict-chip-added").text()).toBe("1 panel added"); + }); + + it("defaults to Reload outside the panel editor and has no Merge", async () => { + await openConflict(); + await mountDialog(); + + expect(byTest(wrapper, "dashboard-save-conflict-merge").exists()).toBe(false); + const buttons = wrapper.findAll("button[data-test^='dashboard-save-conflict-']"); + expect(buttons.at(-1)?.attributes("data-test")).toBe("dashboard-save-conflict-reload"); + }); + + it("offers only Reload and Overwrite when they also changed my panel, with Reload highlighted", async () => { + const latest = doc([panel("p1", "Errors (theirs)"), panel("p2", "Latency", 18)]); + const mine = doc([panel("p1", "Errors (mine)"), panel("p2", "Latency", 18)]); + await openConflict("p1", { latest, mine }); + await mountDialog(); + + expect(byTest(wrapper, "dashboard-save-conflict-panel-touched").exists()).toBe(true); + expect(byTest(wrapper, "dashboard-save-conflict-panel-compare").exists()).toBe(true); + expect(byTest(wrapper, "dashboard-save-conflict-merge").exists()).toBe(false); + const buttons = wrapper + .findAll("button[data-test^='dashboard-save-conflict-']") + .map((b) => b.attributes("data-test")); + expect(buttons).toEqual([ + "dashboard-save-conflict-overwrite", + "dashboard-save-conflict-reload", + ]); + expect(wrapper.text()).not.toContain("Save as copy"); + }); + + it("defaults to Merge in the panel editor when my panel was untouched", async () => { + await openConflict("p1"); + await mountDialog(); + + expect(byTest(wrapper, "dashboard-save-conflict-panel-untouched").exists()).toBe(true); + const buttons = wrapper.findAll("button[data-test^='dashboard-save-conflict-']"); + expect(buttons.at(-1)?.attributes("data-test")).toBe("dashboard-save-conflict-merge"); + }); + + it("asks for a second click before Overwrite, listing what it removes", async () => { + await openConflict(); + await mountDialog(); + + await byTest(wrapper, "dashboard-save-conflict-overwrite").trigger("click"); + const confirm = byTest(wrapper, "dashboard-save-conflict-confirm"); + expect(confirm.text()).toContain("Overwrite removes 1 panel"); + expect(confirm.text()).toContain("Removes 1 panel that someone else added: Disk usage"); + expect(confirm.text()).toContain("Restores 1 panel they removed: Latency"); + expect(byTest(wrapper, "dashboard-save-conflict-confirm-overwrite").text()).toBe( + "Remove and overwrite", + ); + expect(service.save).not.toHaveBeenCalled(); + }); + it("shows my pending change under the time of their save", async () => { + await openConflict(undefined, { change: 'Deleted panel "Latency"' }); + await mountDialog(); + expect(byTest(wrapper, "dashboard-save-conflict-my-change").text()).toBe( + 'Your unsaved change: Deleted panel "Latency"', + ); + }); + + it("keeps the time and the warning as separate sentences on the Overwrite confirm", async () => { + await openConflict(); + await mountDialog(); + await byTest(wrapper, "dashboard-save-conflict-overwrite").trigger("click"); + expect(byTest(wrapper, "dashboard-save-conflict-confirm-saved-by").text()).toMatch( + /^Saved by someone else 4 minutes ago \(.+\)$/, + ); + expect(byTest(wrapper, "dashboard-save-conflict-confirm").text()).toContain( + "This cannot be undone from here.", + ); + }); + + it("names the settings they changed and shows a tab rename as old to new", async () => { + const latest = doc([panel("p1", "Error rate"), panel("p2", "Latency", 18)], { + description: "Now with SLOs", + }); + latest.tabs[0].name = "Overview (prod)"; + await openConflict(undefined, { latest }); + await mountDialog(); + expect(byTest(wrapper, "dashboard-save-conflict-change-settings-changed").text()).toContain( + "description", + ); + expect(byTest(wrapper, "dashboard-save-conflict-change-tab-changed").text()).toContain( + "Overview (prod) (renamed from Overview)", + ); + }); + + it("shows their title against mine when they also edited my panel", async () => { + const latest = doc([panel("p1", "Errors (theirs)"), panel("p2", "Latency", 18)]); + const mine = doc([panel("p1", "Errors (mine)"), panel("p2", "Latency", 18)]); + await openConflict("p1", { latest, mine }); + await mountDialog(); + expect(byTest(wrapper, "dashboard-save-conflict-compare-title-theirs").text()).toBe( + "Errors (theirs)", + ); + expect(byTest(wrapper, "dashboard-save-conflict-compare-title-yours").text()).toBe( + "Errors (mine)", + ); + }); + + it("opens at once with a loading summary while the latest version is fetched", async () => { + const store = makeStore(); + let resolveLatest!: (value: unknown) => void; + service.get_Dashboard.mockReturnValueOnce(new Promise((r) => (resolveLatest = r))); + const opened = openDashboardConflict( + { dashboardId: "d1", folderId: "default" }, + { store, t: i18n.global.t as unknown as TranslateFn, locale: "en-US" }, + ); + await mountDialog(); + expect(byTest(wrapper, "dashboard-save-conflict-loading").exists()).toBe(true); + resolveLatest(response(LATEST(), "h2")); + await opened; + await flushPromises(); + expect(byTest(wrapper, "dashboard-save-conflict-loading").exists()).toBe(false); + }); + + it("lists every pending change and stops offering Merge once more than one is pending", async () => { + const store = makeStore(); + const ctx = { store, t: i18n.global.t as unknown as TranslateFn, locale: "en-US" }; + service.get_Dashboard.mockResolvedValueOnce(response(BASE(), "h1")); + await getDashboard(store, "d1", "default"); + service.get_Dashboard.mockResolvedValue(response(LATEST(), "h2")); + await openDashboardConflict( + { dashboardId: "d1", folderId: "default", change: raw('Deleted panel "Latency"') }, + ctx, + ); + state.open = false; + await openDashboardConflict( + { + dashboardId: "d1", + folderId: "default", + panelId: "p1", + change: raw('Edited panel "Error rate"'), + }, + ctx, + ); + await mountDialog(); + + expect(byTest(wrapper, "dashboard-save-conflict-my-change").text()).toBe( + 'Your unsaved changes: Deleted panel "Latency" and Edited panel "Error rate"', + ); + expect(byTest(wrapper, "dashboard-save-conflict-merge").exists()).toBe(false); + }); + + it("renders the notice that a save after closing was not applied", async () => { + await openConflict(); + state.notice = "stillUnresolved"; + await mountDialog(); + expect(byTest(wrapper, "dashboard-save-conflict-still-unresolved").exists()).toBe(true); + }); +}); diff --git a/web/src/components/dashboards/DashboardSaveConflictDialog.vue b/web/src/components/dashboards/DashboardSaveConflictDialog.vue new file mode 100644 index 0000000000..a6bc4981b1 --- /dev/null +++ b/web/src/components/dashboards/DashboardSaveConflictDialog.vue @@ -0,0 +1,694 @@ + + + + + diff --git a/web/src/components/dashboards/PanelContainer.vue b/web/src/components/dashboards/PanelContainer.vue index 07fbd9f849..039f17d7b6 100644 --- a/web/src/components/dashboards/PanelContainer.vue +++ b/web/src/components/dashboards/PanelContainer.vue @@ -523,6 +523,7 @@ import PanelBar from "@/components/common/PanelBar.vue"; import SinglePanelMove from "@/components/dashboards/settings/SinglePanelMove.vue"; import { getUUID, b64EncodeUnicode } from "@/utils/zincutils"; import useNotifications from "@/composables/useNotifications"; +import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict"; import OButton from "@/lib/core/Button/OButton.vue"; import OTag from "@/lib/core/Badge/OTag.vue"; import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue"; @@ -738,11 +739,8 @@ export default defineComponent({ const showLegendsDialog = ref(false); const confirmDeletePanelDialog = ref(false); const confirmMovePanelDialog: any = ref(false); - const { - showPositiveNotification, - showErrorNotification, - showConfictErrorNotificationWithRefreshBtn, - } = useNotifications(); + const { showPositiveNotification, showErrorNotification } = useNotifications(); + const saveConflict = useDashboardSaveConflict(); const metaDataValue = (metadata: any) => { metaData.value = metadata; }; @@ -1004,11 +1002,12 @@ export default defineComponent({ } catch (error: any) { // Show an error notification. - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? error?.message ?? t("panel.panelDuplicationFailed"), - t, - ); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: String(route.query.dashboard), + folderId: (route.query.folder as string) ?? "default", + change: t("dashboard.saveConflict.change.duplicatedPanel", { name: data.title }), + }); } else { showErrorNotification(error?.message ?? t("panel.panelDuplicationFailed")); } diff --git a/web/src/components/dashboards/settings/AddSettingVariable.spec.ts b/web/src/components/dashboards/settings/AddSettingVariable.spec.ts index 3a6273e7bd..f04628e24d 100644 --- a/web/src/components/dashboards/settings/AddSettingVariable.spec.ts +++ b/web/src/components/dashboards/settings/AddSettingVariable.spec.ts @@ -60,6 +60,14 @@ vi.mock("vue-i18n", () => ({ }), })); +const mockOpenConflict = vi.fn(); +vi.mock("@/composables/dashboard/useDashboardSaveConflict", () => ({ + useDashboardSaveConflict: () => ({ + isConflict: (error: any) => error?.response?.status === 409, + openConflict: mockOpenConflict, + }), +})); + vi.mock("vuex", () => ({ useStore: () => ({ state: { @@ -940,6 +948,17 @@ describe("AddSettingVariable", () => { expect(analytics.track).not.toHaveBeenCalled(); }); + it("closes the form on a save conflict so Save cannot add the pending variable twice", async () => { + vi.mocked(addVariable).mockRejectedValueOnce({ response: { status: 409 } }); + + await wrapper.vm.saveData({ name: "v", type: "constant" }); + + expect(mockOpenConflict).toHaveBeenCalledWith( + expect.objectContaining({ folderId: expect.any(String) }), + ); + expect(wrapper.emitted("save")).toHaveLength(1); + }); + it("tracks dashboard_variable_saved as not new once updateVariable resolves", async () => { const editWrapper = mount(AddSettingVariable, { props: { variableName: "existingVar", dashboardVariablesList: [] }, diff --git a/web/src/components/dashboards/settings/AddSettingVariable.vue b/web/src/components/dashboards/settings/AddSettingVariable.vue index c7b53fa167..ea9306bca2 100644 --- a/web/src/components/dashboards/settings/AddSettingVariable.vue +++ b/web/src/components/dashboards/settings/AddSettingVariable.vue @@ -540,6 +540,7 @@ import { import { getScopeType } from "@/utils/dashboard/variables/variablesScopeUtils"; import OFormCombobox from "@/lib/forms/Combobox/OFormCombobox.vue"; import useNotifications from "@/composables/useNotifications"; +import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict"; import analytics from "@/services/product_analytics"; export default defineComponent({ @@ -763,8 +764,8 @@ export default defineComponent({ const route = useRoute(); const title = ref(t("dashboard.newVariable")); const { getStreams, getStream } = useStreams(t); - const { showErrorNotification, showConfictErrorNotificationWithRefreshBtn } = - useNotifications(); + const { showErrorNotification } = useNotifications(); + const saveConflict = useDashboardSaveConflict(); // const model = ref(null) // const filteredStreams = ref([]); const variableTypes = ref([ @@ -1157,13 +1158,15 @@ export default defineComponent({ analytics.track("dashboard_variable_saved", { type: payload.type, is_new: false }); emit("save"); } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.addSettingVariable.variableUpdateFailed"), - t, - ); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: dashId, + folderId: (route.query.folder as string) ?? "default", + change: t("dashboard.saveConflict.change.editedVariable", { name: payload.name }), + onResolved: () => emit("save"), + }); + // The variable is already in the working copy; leaving the form open would let Save add it again. + emit("save"); } else { showErrorNotification( error.message ?? t("dashboard.addSettingVariable.variableUpdateFailed"), @@ -1179,13 +1182,15 @@ export default defineComponent({ analytics.track("dashboard_variable_saved", { type: payload.type, is_new: true }); emit("save"); } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.addSettingVariable.variableCreationFailed"), - t, - ); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: dashId, + folderId: (route.query.folder as string) ?? "default", + change: t("dashboard.saveConflict.change.addedVariable", { name: payload.name }), + onResolved: () => emit("save"), + }); + // The variable is already in the working copy; leaving the form open would let Save add it again. + emit("save"); } else { showErrorNotification( error.message ?? t("dashboard.addSettingVariable.variableCreationFailed"), diff --git a/web/src/components/dashboards/settings/GeneralSettings.vue b/web/src/components/dashboards/settings/GeneralSettings.vue index 57df979f7b..d223c2de21 100644 --- a/web/src/components/dashboards/settings/GeneralSettings.vue +++ b/web/src/components/dashboards/settings/GeneralSettings.vue @@ -112,6 +112,7 @@ import { useRoute } from "vue-router"; import DashboardHeader from "./common/DashboardHeader.vue"; import DateTimePickerDashboard from "@/components/DateTimePickerDashboard.vue"; import useNotifications from "@/composables/useNotifications"; +import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict"; import OButton from "@/lib/core/Button/OButton.vue"; import OForm from "@/lib/forms/Form/OForm.vue"; import OFormInput from "@/lib/forms/Input/OFormInput.vue"; @@ -140,11 +141,8 @@ export default defineComponent({ const { t } = useI18nTyped(); const generalSettingsSchema = makeGeneralSettingsSchema(t); const route = useRoute(); - const { - showPositiveNotification, - showErrorNotification, - showConfictErrorNotificationWithRefreshBtn, - } = useNotifications(); + const { showPositiveNotification, showErrorNotification } = useNotifications(); + const saveConflict = useDashboardSaveConflict(); const formRef = ref(null); const closeBtn: Ref = ref(null); @@ -183,9 +181,10 @@ export default defineComponent({ }); const saveDashboard = async (value: GeneralSettingsForm) => { + let data: any; try { // get the latest dashboard data and update the title and description - const data = JSON.parse( + data = JSON.parse( JSON.stringify( await getDashboard(store, route.query.dashboard, route.query.folder ?? "default"), ), @@ -225,13 +224,14 @@ export default defineComponent({ emit("save"); } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.generalSettingsPage.updationFailed"), - t, - ); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: route.query.dashboard as string, + folderId: (route?.query?.folder as string) ?? "default", + mine: data, + change: t("dashboard.saveConflict.change.changedSettings"), + onResolved: getDashboardData, + }); } else { showErrorNotification( error?.message ?? t("dashboard.generalSettingsPage.updationFailed"), diff --git a/web/src/components/dashboards/settings/TabsSettings.spec.ts b/web/src/components/dashboards/settings/TabsSettings.spec.ts index e6ee06466e..8b86742d50 100644 --- a/web/src/components/dashboards/settings/TabsSettings.spec.ts +++ b/web/src/components/dashboards/settings/TabsSettings.spec.ts @@ -25,9 +25,18 @@ vi.mock("../../../utils/commons", () => ({ updateDashboard: vi.fn(), })); +const mockT = vi.hoisted(() => vi.fn((key: string) => key)); vi.mock("vue-i18n", () => ({ useI18n: () => ({ - t: (key: string) => key, + t: mockT, + }), +})); + +const mockOpenConflict = vi.fn(); +vi.mock("@/composables/dashboard/useDashboardSaveConflict", () => ({ + useDashboardSaveConflict: () => ({ + isConflict: (error: any) => error?.response?.status === 409, + openConflict: mockOpenConflict, }), })); @@ -129,6 +138,54 @@ describe("TabsSettings", () => { } }); + describe("save conflict descriptions", () => { + const conflict = { response: { status: 409 } }; + + const mountWithFreshDashboard = async () => { + const dashboard = JSON.parse(JSON.stringify(mockDashboardData)); + const { getDashboard } = await import("../../../utils/commons"); + vi.mocked(getDashboard).mockResolvedValue(dashboard); + wrapper = createWrapper(); + await waitForComponent(wrapper); + return dashboard; + }; + + it("names the tab's old name in a rename, read before editTab changes it", async () => { + const dashboard = await mountWithFreshDashboard(); + const { editTab } = await import("../../../utils/commons"); + vi.mocked(editTab).mockImplementationOnce(async (...args: any[]) => { + dashboard.tabs[0].name = args[4].name; + throw conflict; + }); + wrapper.vm.editTabId = "tab1"; + wrapper.vm.editTabObj.data = { tabId: "tab1", name: "Renamed" }; + + await wrapper.vm.saveEdit(); + + expect(mockT).toHaveBeenCalledWith("dashboard.saveConflict.change.renamedTab", { + from: "Tab 1", + to: "Renamed", + }); + expect(mockOpenConflict).toHaveBeenCalled(); + }); + + it("names the deleted tab, read before deleteTab removes it", async () => { + const dashboard = await mountWithFreshDashboard(); + const { deleteTab } = await import("../../../utils/commons"); + vi.mocked(deleteTab).mockImplementationOnce(async () => { + dashboard.tabs.splice(1, 1); + throw conflict; + }); + wrapper.vm.tabIdToBeDeleted = "tab2"; + + await wrapper.vm.confirmDelete(); + + expect(mockT).toHaveBeenCalledWith("dashboard.saveConflict.change.deletedTab", { + name: "Tab 2", + }); + }); + }); + describe("Component Initialization", () => { it("should render correctly with default props", async () => { wrapper = createWrapper(); diff --git a/web/src/components/dashboards/settings/TabsSettings.vue b/web/src/components/dashboards/settings/TabsSettings.vue index 273f993de0..c3838f8d8d 100644 --- a/web/src/components/dashboards/settings/TabsSettings.vue +++ b/web/src/components/dashboards/settings/TabsSettings.vue @@ -194,6 +194,7 @@ import { COL } from "@/lib/core/Table/OTable.types"; import AddTab from "@/components/dashboards/tabs/AddTab.vue"; import TabsDeletePopUp from "./TabsDeletePopUp.vue"; import useNotifications from "@/composables/useNotifications"; +import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict"; export default defineComponent({ name: "TabsSettings", @@ -229,11 +230,8 @@ export default defineComponent({ route.query.folder ?? "default", ); }; - const { - showPositiveNotification, - showErrorNotification, - showConfictErrorNotificationWithRefreshBtn, - } = useNotifications(); + const { showPositiveNotification, showErrorNotification } = useNotifications(); + const saveConflict = useDashboardSaveConflict(); // Attach SortableJS to OTable's rendered so rows can be dragged // to reorder. Re-runnable: tears down any prior instance first. const initSortable = async () => { @@ -324,6 +322,10 @@ export default defineComponent({ data: {}, }); + const tabNameOf = (tabId: any) => + currentDashboardData.data?.tabs?.find((tab: any) => tab.tabId === tabId)?.name ?? + String(tabId ?? ""); + const handleDragEnd = async () => { try { await updateDashboard( @@ -338,16 +340,17 @@ export default defineComponent({ showPositiveNotification(t("dashboard.tabsSettings.dashboardUpdated")); } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.tabsSettings.tabReorderFailed"), - t, - ); - } else { - showErrorNotification(error?.message ?? t("dashboard.tabsSettings.tabReorderFailed")); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: currentDashboardData.data.dashboardId, + folderId: (route.query.folder as string) ?? "default", + mine: currentDashboardData.data, + change: t("dashboard.saveConflict.change.reorderedTabs"), + onResolved: getDashboardData, + }); + return; } + showErrorNotification(error?.message ?? t("dashboard.tabsSettings.tabReorderFailed")); emit("refresh"); await getDashboardData(); } @@ -361,6 +364,8 @@ export default defineComponent({ }; const saveEdit = async () => { + // editTab renames the cached tab in place, so read the old name before it runs. + const previousName = tabNameOf(editTabObj.data.tabId); try { if (editTabId.value) { // only allowed to edit name @@ -381,16 +386,26 @@ export default defineComponent({ editTabObj.data = {}; } } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.tabsSettings.tabUpdationFailed"), - t, - ); - } else { - showErrorNotification(error?.message ?? t("dashboard.tabsSettings.tabUpdationFailed")); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: currentDashboardData.data.dashboardId, + folderId: (route.query.folder as string) ?? "default", + change: t("dashboard.saveConflict.change.renamedTab", { + from: previousName, + to: editTabObj.data.name, + }), + onResolved: async () => { + cancelEdit(); + await getDashboardData(); + }, + }); + // The rename is already in the working copy, so show it as unsaved instead of keeping the editor open. + cancelEdit(); + await getDashboardData(); + emit("refresh"); + return; } + showErrorNotification(error?.message ?? t("dashboard.tabsSettings.tabUpdationFailed")); emit("refresh"); await getDashboardData(); @@ -415,6 +430,8 @@ export default defineComponent({ }; const confirmDelete = async (moveTabId: any) => { + // deleteTab removes the tab from the cached dashboard, so read its name first. + const deletedName = tabNameOf(tabIdToBeDeleted.value); try { await deleteTab( store, @@ -432,13 +449,19 @@ export default defineComponent({ showPositiveNotification(t("dashboard.tabsSettings.tabDeleted")); } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.tabsSettings.tabDeletionFailed"), - t, - ); + if (saveConflict.isConflict(error)) { + deletePopupVisible.value = false; + void saveConflict.openConflict({ + dashboardId: currentDashboardData.data.dashboardId, + folderId: (route.query.folder as string) ?? "default", + change: t("dashboard.saveConflict.change.deletedTab", { + name: deletedName, + }), + onResolved: async () => { + tabIdToBeDeleted.value = null; + await getDashboardData(); + }, + }); } else { showErrorNotification(error?.message ?? t("dashboard.tabsSettings.tabDeletionFailed"), { timeout: 2000, diff --git a/web/src/components/dashboards/settings/VariableSettings.spec.ts b/web/src/components/dashboards/settings/VariableSettings.spec.ts index cf973b97ab..4bacf470ce 100644 --- a/web/src/components/dashboards/settings/VariableSettings.spec.ts +++ b/web/src/components/dashboards/settings/VariableSettings.spec.ts @@ -50,12 +50,18 @@ vi.mock("../../../utils/zincutils", () => ({ const mockNotifications = { showPositiveNotification: vi.fn(), showErrorNotification: vi.fn(), - showConfictErrorNotificationWithRefreshBtn: vi.fn(), }; vi.mock("@/composables/useNotifications", () => ({ default: vi.fn(() => mockNotifications), })); +const mockOpenConflict = vi.fn(); +vi.mock("@/composables/dashboard/useDashboardSaveConflict", () => ({ + useDashboardSaveConflict: () => ({ + isConflict: (error: any) => error?.response?.status === 409, + openConflict: mockOpenConflict, + }), +})); vi.mock("../../../utils/commons", () => ({ getDashboard: vi.fn(), @@ -156,7 +162,7 @@ describe("VariableSettings", () => { // Reset notification mocks mockNotifications.showPositiveNotification.mockClear(); mockNotifications.showErrorNotification.mockClear(); - mockNotifications.showConfictErrorNotificationWithRefreshBtn.mockClear(); + mockOpenConflict.mockClear(); mockGetDashboard = vi.fn().mockResolvedValue(mockDashboardData); mockDeleteVariable = vi.fn().mockResolvedValue({}); @@ -416,9 +422,11 @@ describe("VariableSettings", () => { await vm.deleteVariableFn(); - expect(mockNotifications.showConfictErrorNotificationWithRefreshBtn).toHaveBeenCalledWith( - "Conflict error", - expect.any(Function), + expect(mockOpenConflict).toHaveBeenCalledWith( + expect.objectContaining({ + dashboardId: "test-dashboard", + onResolved: expect.any(Function), + }), ); // No positive notification should be called when error occurs expect(mockNotifications.showPositiveNotification).not.toHaveBeenCalled(); @@ -493,10 +501,10 @@ describe("VariableSettings", () => { await vm.handleDragEnd(); - expect(mockNotifications.showConfictErrorNotificationWithRefreshBtn).toHaveBeenCalledWith( - "Reorder conflict", - expect.any(Function), + expect(mockOpenConflict).toHaveBeenCalledWith( + expect.objectContaining({ mine: vm.dashboardVariableData.data }), ); + expect(mockNotifications.showErrorNotification).not.toHaveBeenCalled(); }); it("should handle drag end error with non-409 status", async () => { diff --git a/web/src/components/dashboards/settings/VariableSettings.vue b/web/src/components/dashboards/settings/VariableSettings.vue index 6406a04d7b..d3c7be89e7 100644 --- a/web/src/components/dashboards/settings/VariableSettings.vue +++ b/web/src/components/dashboards/settings/VariableSettings.vue @@ -265,6 +265,7 @@ import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue"; import ConfirmDialog from "../../ConfirmDialog.vue"; import VariablesDependenciesGraph from "./VariablesDependenciesGraph.vue"; import useNotifications from "@/composables/useNotifications"; +import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict"; import Sortable from "sortablejs"; import OButton from "@/lib/core/Button/OButton.vue"; import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue"; @@ -366,11 +367,9 @@ export default defineComponent({ return index < 9 ? `0${index + 1}` : `${index + 1}`; }; - const { - showPositiveNotification, - showErrorNotification, - showConfictErrorNotificationWithRefreshBtn, - } = useNotifications(); + const { showPositiveNotification, showErrorNotification } = useNotifications(); + + const saveConflict = useDashboardSaveConflict(); // list of all variables, which will be same as the dashboard variables list const dashboardVariablesList: any = ref([]); @@ -460,18 +459,19 @@ export default defineComponent({ emit("save"); } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.variableSettingsPage.variableReorderFailed"), - t, - ); - } else { - showErrorNotification( - error?.message ?? t("dashboard.variableSettingsPage.variableReorderFailed"), - ); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: dashboardVariableData.data.dashboardId, + folderId: (route.query.folder as string) ?? "default", + mine: dashboardVariableData.data, + change: t("dashboard.saveConflict.change.reorderedVariables"), + onResolved: getDashboardData, + }); + return; } + showErrorNotification( + error?.message ?? t("dashboard.variableSettingsPage.variableReorderFailed"), + ); await getDashboardData(); } }; @@ -569,13 +569,15 @@ export default defineComponent({ timeout: 2000, }); } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - t("dashboard.variableSettingsPage.variableDeletionFailed"), - t, - ); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: route.query.dashboard as string, + folderId: (route.query.folder as string) ?? "default", + change: t("dashboard.saveConflict.change.deletedVariable", { + name: selectedDelete.value?.name ?? "", + }), + onResolved: getDashboardData, + }); } else { showErrorNotification( error?.message ?? t("dashboard.variableSettingsPage.variableDeletionFailed"), diff --git a/web/src/components/dashboards/tabs/AddTab.spec.ts b/web/src/components/dashboards/tabs/AddTab.spec.ts index b93a22f89d..22087e605a 100644 --- a/web/src/components/dashboards/tabs/AddTab.spec.ts +++ b/web/src/components/dashboards/tabs/AddTab.spec.ts @@ -102,13 +102,18 @@ global.console = { // Mock composables const mockShowPositiveNotification = vi.fn(); const mockShowErrorNotification = vi.fn(); -const mockShowConflictErrorNotification = vi.fn(); vi.mock("@/composables/useNotifications", () => ({ default: () => ({ showPositiveNotification: mockShowPositiveNotification, showErrorNotification: mockShowErrorNotification, - showConfictErrorNotificationWithRefreshBtn: mockShowConflictErrorNotification, + }), +})); +const mockOpenConflict = vi.fn(); +vi.mock("@/composables/dashboard/useDashboardSaveConflict", () => ({ + useDashboardSaveConflict: () => ({ + isConflict: (error: any) => error?.response?.status === 409, + openConflict: mockOpenConflict, }), })); @@ -216,7 +221,7 @@ describe("AddTab", () => { mockShowPositiveNotification.mockClear(); mockShowErrorNotification.mockClear(); - mockShowConflictErrorNotification.mockClear(); + mockOpenConflict.mockClear(); const { addTab, getDashboard } = await import("@/utils/commons"); const { editTab } = await import("../../../utils/commons"); @@ -489,7 +494,7 @@ describe("AddTab", () => { expect(mockAddTab).not.toHaveBeenCalled(); }); - it("should handle 409 conflict errors", async () => { + it("opens the save-conflict dialog on a 409 instead of an error toast", async () => { wrapper = createWrapper(); mockAddTab.mockRejectedValue({ response: { status: 409, data: { message: "Tab already exists" } }, @@ -497,10 +502,13 @@ describe("AddTab", () => { await wrapper.vm.onSubmit({ name: "Test Tab" }); - expect(mockShowConflictErrorNotification).toHaveBeenCalledWith( - "Tab already exists", - expect.any(Function), + expect(mockOpenConflict).toHaveBeenCalledWith( + expect.objectContaining({ dashboardId: wrapper.props("dashboardId") }), ); + expect(mockShowErrorNotification).not.toHaveBeenCalled(); + // The tab is already pending on screen, so the dialog closes and Save cannot add it twice. + expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]); + expect(wrapper.emitted("refresh")?.at(-1)?.[0]).toMatchObject({ name: "Test Tab" }); }); it("should handle general errors", async () => { diff --git a/web/src/components/dashboards/tabs/AddTab.vue b/web/src/components/dashboards/tabs/AddTab.vue index d326d13136..a466f620e6 100644 --- a/web/src/components/dashboards/tabs/AddTab.vue +++ b/web/src/components/dashboards/tabs/AddTab.vue @@ -53,6 +53,7 @@ import { addTab, getDashboard } from "@/utils/commons"; import { useRoute } from "vue-router"; import { editTab } from "../../../utils/commons"; import useNotifications from "@/composables/useNotifications"; +import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict"; import ODialog from "@/lib/overlay/Dialog/ODialog.vue"; import OForm from "@/lib/forms/Form/OForm.vue"; import OFormInput from "@/lib/forms/Input/OFormInput.vue"; @@ -96,11 +97,8 @@ export default defineComponent({ const store: any = useStore(); const addTabForm: any = ref(null); const addTabSchema = makeAddTabSchema(t); - const { - showPositiveNotification, - showErrorNotification, - showConfictErrorNotificationWithRefreshBtn, - } = useNotifications(); + const { showPositiveNotification, showErrorNotification } = useNotifications(); + const saveConflict = useDashboardSaveConflict(); // The tab record being edited. This is EXTERNAL data fetched from the // store/API (it carries the non-form fields tabId/panels) — NOT a mirror of @@ -142,6 +140,7 @@ export default defineComponent({ const onSubmit = async (value: AddTabForm) => { // The @submit payload is the source of truth for `name`; the loaded // `editingTab` carries the rest (panels/tabId) in edit mode. + const newTabData: any = { name: value.name, panels: [] }; try { //if edit mode if (props.editMode) { @@ -166,7 +165,7 @@ export default defineComponent({ store, props.dashboardId, props.folderId ?? route.query.folder ?? "default", - { name: value.name, panels: [] }, + newTabData, ); // emit refresh to rerender @@ -176,15 +175,21 @@ export default defineComponent({ showPositiveNotification(t("dashboard.addTab.tabAddedSuccessfully")); } } catch (error: any) { - if (error?.response?.status === 409) { - showConfictErrorNotificationWithRefreshBtn( - error?.response?.data?.message ?? - error?.message ?? - (props.editMode - ? t("dashboard.addTab.failedToUpdateTab") - : t("dashboard.addTab.failedToAddTab")), - t, - ); + if (saveConflict.isConflict(error)) { + void saveConflict.openConflict({ + dashboardId: props.dashboardId, + folderId: (props.folderId ?? route.query.folder ?? "default") as string, + change: props.editMode + ? t("dashboard.saveConflict.change.renamedTab", { + from: editingTab.value?.name ?? "", + to: value.name, + }) + : t("dashboard.saveConflict.change.addedTab", { name: value.name }), + onResolved: () => emit("update:open", false), + }); + // The tab is already in the working copy and on screen as unsaved; Save here again would add it twice. + emit("refresh", props.editMode ? { ...editingTab.value, name: value.name } : newTabData); + emit("update:open", false); } else { showErrorNotification( error?.message ?? diff --git a/web/src/components/dashboards/tabs/TabList.spec.ts b/web/src/components/dashboards/tabs/TabList.spec.ts index 728b65e9a8..792b8ca298 100644 --- a/web/src/components/dashboards/tabs/TabList.spec.ts +++ b/web/src/components/dashboards/tabs/TabList.spec.ts @@ -41,8 +41,9 @@ vi.mock("./AddTab.vue", () => ({ // Inline rename + reorder pull in i18n, the store, notifications and the tab // persistence helpers — mock them so the component mounts in isolation (mirrors // AddTab.spec). +const mockT = vi.hoisted(() => vi.fn((key: string) => key)); vi.mock("vue-i18n", () => ({ - useI18n: () => ({ t: (key: string) => key }), + useI18n: () => ({ t: mockT }), })); const mockStore = { @@ -54,12 +55,17 @@ vi.mock("vuex", () => ({ const mockShowPositiveNotification = vi.fn(); const mockShowErrorNotification = vi.fn(); -const mockShowConflictErrorNotification = vi.fn(); vi.mock("@/composables/useNotifications", () => ({ default: () => ({ showPositiveNotification: mockShowPositiveNotification, showErrorNotification: mockShowErrorNotification, - showConfictErrorNotificationWithRefreshBtn: mockShowConflictErrorNotification, + }), +})); +const mockOpenConflict = vi.fn(); +vi.mock("@/composables/dashboard/useDashboardSaveConflict", () => ({ + useDashboardSaveConflict: () => ({ + isConflict: (error: any) => error?.response?.status === 409, + openConflict: mockOpenConflict, }), })); @@ -716,6 +722,20 @@ describe("TabList", () => { expect(wrapper.emitted("refresh")).toBeTruthy(); }); + it("opens the save-conflict dialog on a 409 and keeps the new order on screen", async () => { + mockUpdateDashboard.mockRejectedValueOnce({ response: { status: 409 } }); + wrapper = createWrapper(); + + await wrapper.vm.onReorder({ from: "tab3", to: "tab1", before: true }); + await flushPromises(); + + expect(mockOpenConflict).toHaveBeenCalledWith( + expect.objectContaining({ mine: wrapper.props("dashboardData") }), + ); + expect(wrapper.emitted("refresh")).toBeFalsy(); + expect(wrapper.vm.tabs.map((tab: any) => tab.tabId)).toEqual(["tab3", "tab1", "tab2"]); + }); + it("should ignore a reorder referencing an unknown tab id", async () => { wrapper = createWrapper(); @@ -813,6 +833,32 @@ describe("TabList", () => { expect(wrapper.vm.editingTabId).toBe(null); }); + it("describes the rename as old to new in the conflict dialog on a 409", async () => { + const tab = { tabId: "tab1", name: "First Tab" }; + // editTab renames the cached tab (the one on screen) before the save fails. + mockEditTab.mockImplementationOnce(async (...args: any[]) => { + tab.name = args[4].name; + throw { response: { status: 409 } }; + }); + wrapper = createWrapper(); + + wrapper.vm.startRename(tab); + await flushPromises(); + wrapper.vm.editingName = "Renamed Tab"; + await wrapper.vm.commitRename(tab); + + expect(mockT).toHaveBeenCalledWith("dashboard.saveConflict.change.renamedTab", { + from: "First Tab", + to: "Renamed Tab", + }); + + // t() is mocked as identity, so the change carries the i18n key. + expect(mockOpenConflict.mock.calls.at(-1)![0].change).toBe( + "dashboard.saveConflict.change.renamedTab", + ); + expect(wrapper.emitted("refresh")).toBeFalsy(); + }); + it("should not call editTab when the name is unchanged", async () => { wrapper = createWrapper(); diff --git a/web/src/components/dashboards/tabs/TabList.vue b/web/src/components/dashboards/tabs/TabList.vue index 7da6391009..cade4b7900 100644 --- a/web/src/components/dashboards/tabs/TabList.vue +++ b/web/src/components/dashboards/tabs/TabList.vue @@ -160,9 +160,10 @@ import { computed, inject, nextTick, ref, defineComponent, type PropType } from import { useStore } from "vuex"; import AddTab from "@/components/dashboards/tabs/AddTab.vue"; import { useRoute } from "vue-router"; -import { raw, useI18nTyped, type I18nKey } from "@/types/i18n"; +import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n"; import { editTab, updateDashboard } from "@/utils/commons"; import useNotifications from "@/composables/useNotifications"; +import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict"; import type { LayoutMode } from "@/components/dashboards/layoutMode"; export default defineComponent({ @@ -195,11 +196,8 @@ export default defineComponent({ const { t } = useI18nTyped(); const route = useRoute(); const store = useStore(); - const { - showPositiveNotification, - showErrorNotification, - showConfictErrorNotificationWithRefreshBtn, - } = useNotifications(); + const { showPositiveNotification, showErrorNotification } = useNotifications(); + const saveConflict = useDashboardSaveConflict(); const showAddTabDialog = ref(false); @@ -230,16 +228,18 @@ export default defineComponent({ // Shared failure handling for both tab operations: surface a 409 with the // refresh CTA, everything else as a plain error, then reload canonical data. - const notifyTabFailure = (error: any, failKey: I18nKey) => { - if (error?.response?.status === 409) { - // Server/network text arrives already-resolved; the fallback is a key. - showConfictErrorNotificationWithRefreshBtn( - raw(error?.response?.data?.message ?? error?.message) || t(failKey), - t, - ); - } else { - showErrorNotification(raw(error?.message) || t(failKey)); + const notifyTabFailure = (error: any, failKey: I18nKey, change: I18nText, mine?: any) => { + if (saveConflict.isConflict(error)) { + // No refresh: the reorder or rename stays on screen while the dialog is open. + void saveConflict.openConflict({ + dashboardId: props.dashboardData?.dashboardId, + folderId: folderId.value, + mine, + change, + }); + return; } + showErrorNotification(raw(error?.message) || t(failKey)); emit("refresh"); }; @@ -286,7 +286,12 @@ export default defineComponent({ ); showPositiveNotification(t("dashboard.tabsSettings.dashboardUpdated")); } catch (error: any) { - notifyTabFailure(error, "dashboard.tabsSettings.tabReorderFailed"); + notifyTabFailure( + error, + "dashboard.tabsSettings.tabReorderFailed", + t("dashboard.saveConflict.change.reorderedTabs"), + props.dashboardData, + ); } }; @@ -327,12 +332,18 @@ export default defineComponent({ return; } + // editTab renames the cached tab in place, and that is the object on screen, so read the old name first. + const previousName = tab?.name ?? ""; try { await editTab(store, props.dashboardData?.dashboardId, folderId.value, tab.tabId, { name }); emit("refresh"); showPositiveNotification(t("dashboard.tabsSettings.tabUpdated")); } catch (error: any) { - notifyTabFailure(error, "dashboard.tabsSettings.tabUpdationFailed"); + notifyTabFailure( + error, + "dashboard.tabsSettings.tabUpdationFailed", + t("dashboard.saveConflict.change.renamedTab", { from: previousName, to: name }), + ); } finally { editingName.value = ""; } diff --git a/web/src/composables/dashboard/dashboardSaveConflictState.ts b/web/src/composables/dashboard/dashboardSaveConflictState.ts new file mode 100644 index 0000000000..7a365971fa --- /dev/null +++ b/web/src/composables/dashboard/dashboardSaveConflictState.ts @@ -0,0 +1,65 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { reactive } from "vue"; +import type { + ConflictChange, + DashboardSettingKey, + OverwriteImpact, + PanelComparison, +} from "@/utils/dashboard/saveConflict"; + +export type ConflictStep = "loading" | "main" | "confirm" | "review" | "error"; +export type ConflictAction = "reload" | "overwrite" | "merge" | "saveMerged"; +export type ConflictNotice = "savedAgain" | "stillUnresolved" | "saveFailed" | null; +export type ConflictOutcome = "reloaded" | "overwritten" | "merged"; + +export interface MergedPanelRow { + key: string; + name: string; + tag: "yours" | "theirsNew" | "theirsEdited" | null; +} + +// Kept free of service imports so the app shell can mount the dialog lazily. +export const dashboardSaveConflictState = reactive({ + mounted: false, + /** Bumped on every open so the dialog remounts and stacks above whatever is open at that moment. */ + openSeq: 0, + open: false, + step: "loading" as ConflictStep, + busy: null as ConflictAction | null, + notice: null as ConflictNotice, + errorMessage: "", + dashboardId: "", + isPanelEditor: false, + panelTouched: false, + hasBase: false, + hasMine: false, + latestUpdatedAt: null as number | null, + changes: [] as ConflictChange[], + changedSettings: [] as DashboardSettingKey[], + panelComparison: null as PanelComparison | null, + /** My pending changes on this dashboard, joined for display. */ + myChange: "", + myChangeCount: 0, + impact: null as OverwriteImpact | null, + mergedRows: [] as MergedPanelRow[], + mergeUnavailable: false, + /** The dashboard whose last save lost a conflict and is still not saved. */ + unresolvedDashboardId: "", + resolvedSeq: 0, + resolvedDashboardId: "", + resolvedOutcome: null as ConflictOutcome | null, +}); diff --git a/web/src/composables/dashboard/useDashboardSaveConflict.spec.ts b/web/src/composables/dashboard/useDashboardSaveConflict.spec.ts new file mode 100644 index 0000000000..9a793d20f4 --- /dev/null +++ b/web/src/composables/dashboard/useDashboardSaveConflict.spec.ts @@ -0,0 +1,716 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, effectScope, h, nextTick } from "vue"; +import { flushPromises, mount } from "@vue/test-utils"; +import i18n from "@/locales"; +import { raw, type TranslateFn } from "@/types/i18n"; + +const service = vi.hoisted(() => ({ + get_Dashboard: vi.fn(), + save: vi.fn(), + list: vi.fn(), +})); +vi.mock("@/services/dashboards", () => ({ default: service })); +const toast = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast })); +const dialogModuleLoaded = vi.hoisted(() => vi.fn()); +vi.mock("@/components/dashboards/DashboardSaveConflictDialog.vue", () => { + dialogModuleLoaded(); + return { default: {} }; +}); +vi.mock("@/composables/dashboard/usePanelCache", () => ({ + dropPanelCache: vi.fn(), + dropDashboardPanelCache: vi.fn(), +})); + +import { + addTab, + deletePanel, + getDashboard, + setUnresolvedDashboardConflict, + updateDashboard, + updatePanel, +} from "@/utils/commons"; +import { + onDashboardConflictResolved, + openDashboardConflict, + useDashboardSaveConflict, + useDashboardSaveConflictDialog, +} from "./useDashboardSaveConflict"; +import { dashboardSaveConflictState as state } from "./dashboardSaveConflictState"; +import * as commons from "@/utils/commons"; + +const DASH = "d1"; +const FOLDER = "default"; + +const panel = (id: string, title: string, y = 0) => ({ + id, + title, + type: "line", + queries: [], + layout: { x: 0, y, w: 96, h: 18, i: y + 1 }, +}); + +const doc = (panels: any[], extraVars: any[] = []) => ({ + version: 8, + dashboardId: DASH, + title: "Ops overview", + description: "", + tabs: [{ tabId: "default", name: "Overview", panels }], + variables: { showDynamicFilters: false, list: [{ name: "env" }, ...extraVars] }, +}); + +const response = (dashboard: any, hash: string, updatedAt = 1_760_000_000_000_000) => ({ + data: { version: 8, hash, updatedAt, v8: JSON.parse(JSON.stringify(dashboard)) }, +}); + +const conflict = () => Object.assign(new Error("conflict"), { response: { status: 409 } }); + +const makeStore = () => { + const store: any = { + state: { + selectedOrganization: { identifier: "acme" }, + userInfo: { name: "me" }, + zoConfig: { timestamp_column: "_timestamp" }, + organizationData: { allDashboardData: {}, allDashboardListHash: {}, allDashboardList: {} }, + }, + dispatch: vi.fn((action: string, payload: any) => { + const od = store.state.organizationData; + if (action === "setDashboardData") od.allDashboardData = payload; + if (action === "setAllDashboardListHash") od.allDashboardListHash = payload; + if (action === "setAllDashboardList") od.allDashboardList = payload; + }), + }; + return store; +}; + +const BASE = doc([panel("p1", "Error rate"), panel("p2", "Latency", 18)]); +const LATEST = doc( + [panel("p1", "Error rate"), { ...panel("p2", "Latency p99", 18) }, panel("p3", "Disk usage", 36)], + [{ name: "region" }], +); + +const MY_CHANGE = raw('Edited panel "Error rate"'); + +let store: any; +const t = i18n.global.t as unknown as TranslateFn; +const dialog = useDashboardSaveConflictDialog(); + +const savedBodies = () => service.save.mock.calls.map((c) => ({ body: c[2], hash: c[4] })); + +// Loads BASE, edits panel p1 through the real helper, and lets the save fail with 409. +const editPanelAndConflict = async (onResolved = vi.fn()) => { + service.get_Dashboard.mockResolvedValueOnce(response(BASE, "h1")); + await getDashboard(store, DASH, FOLDER); + service.save.mockRejectedValueOnce(conflict()); + await expect( + updatePanel( + store, + DASH, + { ...panel("p1", "Error rate"), queries: [{ q: "mine" }] }, + FOLDER, + "default", + ), + ).rejects.toBeTruthy(); + const staleObject = store.state.organizationData.allDashboardData[DASH]; + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER, panelId: "p1", change: MY_CHANGE, onResolved }, + { store, t, locale: "en-US" }, + ); + return { staleObject, onResolved }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + service.list.mockResolvedValue({ data: { dashboards: [] } }); + service.save.mockResolvedValue({ data: {} }); + store = makeStore(); + state.open = false; + setUnresolvedDashboardConflict(DASH, null); +}); + +describe("openDashboardConflict", () => { + it("re-fetches once, keeps my working copy in the cache and lists what they changed", async () => { + await editPanelAndConflict(); + + expect(service.get_Dashboard).toHaveBeenCalledTimes(2); + const cached = store.state.organizationData.allDashboardData[DASH]; + expect(cached.tabs[0].panels.map((p: any) => p.id)).toEqual(["p1", "p2"]); + expect(cached.tabs[0].panels[0].queries).toEqual([{ q: "mine" }]); + expect(store.state.organizationData.allDashboardListHash[DASH]).toBe("h1"); + expect(state).toMatchObject({ open: true, step: "main", panelTouched: false, hasMine: true }); + expect(state.latestUpdatedAt).toBe(1_760_000_000_000); + expect(state.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "panel", change: "added", key: "p3" }), + expect.objectContaining({ kind: "panel", change: "changed", key: "p2" }), + expect.objectContaining({ kind: "variable", change: "added", key: "region" }), + ]), + ); + }); + + it("flags when the other save also edited my panel", async () => { + service.get_Dashboard.mockResolvedValueOnce(response(BASE, "h1")); + await getDashboard(store, DASH, FOLDER); + service.get_Dashboard.mockResolvedValueOnce( + response(doc([panel("p1", "Errors (theirs)"), panel("p2", "Latency", 18)]), "h2"), + ); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER, panelId: "p1" }, + { store, t, locale: "en-US" }, + ); + expect(state.panelTouched).toBe(true); + }); + + it("keeps a stale screen object on its own base hash, while fresh loads save on the latest", async () => { + const { staleObject } = await editPanelAndConflict(); + service.save.mockClear(); + service.get_Dashboard.mockResolvedValue(response(LATEST, "h3")); + + await expect(updateDashboard(store, "acme", DASH, staleObject, FOLDER)).resolves.toBeTruthy(); + const fresh = await getDashboard(store, DASH, FOLDER); + await updateDashboard(store, "acme", DASH, fresh, FOLDER); + + expect(savedBodies().map((s) => s.hash)).toEqual(["h1", "h3"]); + }); + + it("shows the load error and retries on request", async () => { + service.get_Dashboard.mockRejectedValueOnce(new Error("offline")); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER }, + { store, t, locale: "en-US" }, + ); + expect(state.step).toBe("error"); + + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await dialog.retry(); + expect(state.step).toBe("main"); + }); +}); + +describe("o2-enterprise#2875: closing the dialog and editing again", () => { + const ctx = () => ({ store, t, locale: "en-US" }); + const open = (change: string) => + openDashboardConflict({ dashboardId: DASH, folderId: FOLDER, change: raw(change) }, ctx()); + const tabNames = (d: any) => d.tabs.map((tab: any) => tab.name); + const panelTitles = (d: any) => d.tabs[0].panels.map((p: any) => p.title); + + it("deleting a second panel stacks on the first; Overwrite saves exactly {Disk} and claims no restore", async () => { + const start = doc([panel("e", "Errors"), panel("l", "Latency", 18), panel("d", "Disk", 36)]); + const afterB = doc([panel("e", "Errors"), panel("d", "Disk", 36)]); + service.get_Dashboard.mockResolvedValueOnce(response(start, "h1")); + await getDashboard(store, DASH, FOLDER); + + service.save.mockRejectedValueOnce(conflict()); + await expect(deletePanel(store, DASH, "e", FOLDER, "default")).rejects.toBeTruthy(); + service.get_Dashboard.mockResolvedValueOnce(response(afterB, "h2")); + await open('Deleted panel "Errors"'); + + const cached = () => store.state.organizationData.allDashboardData[DASH]; + expect(panelTitles(cached())).toEqual(["Latency", "Disk"]); + expect(state.changes).toEqual([ + expect.objectContaining({ kind: "panel", change: "removed", name: "Latency" }), + ]); + dialog.close(); + + service.save.mockRejectedValueOnce(conflict()); + await expect(deletePanel(store, DASH, "l", FOLDER, "default")).rejects.toBeTruthy(); + expect(savedBodies().at(-1)!.hash).toBe("h1"); + expect(panelTitles(cached())).toEqual(["Disk"]); + service.get_Dashboard.mockResolvedValueOnce(response(afterB, "h2")); + await open('Deleted panel "Latency"'); + + expect(state).toMatchObject({ notice: "stillUnresolved", myChangeCount: 2 }); + expect(state.myChange).toBe('Deleted panel "Errors" and Deleted panel "Latency"'); + + dialog.startOverwrite(); + expect(state.impact).toEqual({ + removes: [], + deletesEdited: [], + reverts: [], + restores: [], + settings: [], + }); + + service.save.mockClear(); + service.get_Dashboard.mockResolvedValue(response(doc([panel("d", "Disk", 36)]), "h3")); + await dialog.confirmOverwrite(); + const [{ body, hash }] = savedBodies(); + expect(hash).toBe("h2"); + expect(panelTitles(body)).toEqual(["Disk"]); + }); + + it("adding a second tab stacks on the first; Overwrite saves both and says it replaces their description", async () => { + const start = { ...doc([panel("p1", "Errors")]), description: "original" }; + start.tabs[0].name = "Overview"; + const afterB = { ...start, description: "changed by B" }; + service.get_Dashboard.mockResolvedValueOnce(response(start, "h1")); + await getDashboard(store, DASH, FOLDER); + + service.save.mockRejectedValueOnce(conflict()); + await expect(addTab(store, DASH, FOLDER, { name: "TabA", panels: [] })).rejects.toBeTruthy(); + service.get_Dashboard.mockResolvedValueOnce(response(afterB, "h2")); + await open("Added tab 'TabA'"); + expect(state.changedSettings).toEqual(["description"]); + dialog.close(); + + service.save.mockRejectedValueOnce(conflict()); + await expect(addTab(store, DASH, FOLDER, { name: "TabB", panels: [] })).rejects.toBeTruthy(); + service.get_Dashboard.mockResolvedValueOnce(response(afterB, "h2")); + await open("Added tab 'TabB'"); + + expect(state.myChange).toBe("Added tab 'TabA' and Added tab 'TabB'"); + + dialog.startOverwrite(); + expect(state.impact).toMatchObject({ removes: [], restores: [], settings: ["description"] }); + + service.save.mockClear(); + service.get_Dashboard.mockResolvedValue(response(afterB, "h3")); + await dialog.confirmOverwrite(); + const [{ body, hash }] = savedBodies(); + expect(hash).toBe("h2"); + expect(tabNames(body)).toEqual(["Overview", "TabA", "TabB"]); + expect(body.description).toBe("original"); + }); + + it("Reload replaces the working copy with the latest and clears every pending change", async () => { + await editPanelAndConflict(); + dialog.close(); + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await open("second change"); + expect(state.myChangeCount).toBe(2); + + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await dialog.reload(); + + const cached = store.state.organizationData.allDashboardData[DASH]; + expect(cached.tabs[0].panels.map((p: any) => p.id)).toEqual(["p1", "p2", "p3"]); + expect(store.state.organizationData.allDashboardListHash[DASH]).toBe("h2"); + expect(state.unresolvedDashboardId).toBe(""); + + service.save.mockRejectedValueOnce(conflict()); + await expect(updateDashboard(store, "acme", DASH, cached, FOLDER)).rejects.toBeTruthy(); + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h3")); + await open("fresh change"); + expect(state).toMatchObject({ notice: null, myChangeCount: 1, myChange: "fresh change" }); + }); + + it("a save from its own copy (settings forms) becomes the working copy shown in place", async () => { + service.get_Dashboard.mockResolvedValueOnce(response(BASE, "h1")); + const cached = await getDashboard(store, DASH, FOLDER); + const form = JSON.parse(JSON.stringify(cached)); + form.title = "Renamed by me"; + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + + await openDashboardConflict({ dashboardId: DASH, folderId: FOLDER, mine: form }, ctx()); + + expect(store.state.organizationData.allDashboardData[DASH]).toBe(cached); + expect(cached.title).toBe("Renamed by me"); + }); +}); + +describe("regression pass on 093855bec9", () => { + it("Resolve reopens without the 'not applied' notice, which belongs to a real save attempt", async () => { + await editPanelAndConflict(); + dialog.close(); + service.save.mockRejectedValueOnce(conflict()); + await expect( + updateDashboard( + store, + "acme", + DASH, + store.state.organizationData.allDashboardData[DASH], + FOLDER, + ), + ).rejects.toBeTruthy(); + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER }, + { store, t, locale: "en-US" }, + ); + expect(state.notice).toBe("stillUnresolved"); + dialog.close(); + + const seq = state.openSeq; + dialog.reopen(); + + expect(state).toMatchObject({ open: true, step: "main", notice: null }); + expect(state.openSeq).toBe(seq + 1); + }); + + it("Reload shows its toast before the site reacts, so a page that navigates away still shows it", async () => { + const { onResolved } = await editPanelAndConflict(); + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + + await dialog.reload(); + + expect(toast).toHaveBeenCalledWith({ + variant: "success", + timeout: 15_000, + message: `Loaded the latest version. Discarded your change: ${MY_CHANGE}`, + }); + expect(toast.mock.invocationCallOrder[0]).toBeLessThan(onResolved.mock.invocationCallOrder[0]); + }); +}); + +describe("adoptDashboardWorkingCopy", () => { + it("never copies a __proto__ key from parsed JSON onto the cached dashboard", async () => { + service.get_Dashboard.mockResolvedValueOnce(response(BASE, "h1")); + const target = await getDashboard(store, DASH, FOLDER); + const prototypeBefore = Object.getPrototypeOf(target); + + commons.adoptDashboardWorkingCopy( + store, + DASH, + JSON.parse('{"__proto__":{"polluted":1},"title":"x"}'), + ); + + expect(Object.getPrototypeOf(target)).toBe(prototypeBefore); + expect(({} as any).polluted).toBeUndefined(); + expect((target as any).polluted).toBeUndefined(); + expect(target.title).toBe("x"); + }); +}); + +describe("save helpers do nothing when the id is not found", () => { + beforeEach(async () => { + service.get_Dashboard.mockResolvedValueOnce(response(BASE, "h1")); + await getDashboard(store, DASH, FOLDER); + service.save.mockClear(); + }); + + const titles = () => + store.state.organizationData.allDashboardData[DASH].tabs[0].panels.map((p: any) => p.id); + + it.each([ + ["deletePanel, unknown panel", () => deletePanel(store, DASH, "nope", FOLDER, "default")], + ["deletePanel, unknown tab", () => deletePanel(store, DASH, "p1", FOLDER, "nope")], + [ + "updatePanel, unknown panel", + () => updatePanel(store, DASH, panel("nope", "x"), FOLDER, "default"), + ], + ["deleteTab, unknown tab", () => commons.deleteTab(store, DASH, FOLDER, "nope")], + [ + "deleteTab, unknown target tab", + () => commons.deleteTab(store, DASH, FOLDER, "default", "nope"), + ], + ["editTab, unknown tab", () => commons.editTab(store, DASH, FOLDER, "nope", { name: "x" })], + [ + "movePanelToAnotherTab, unknown panel", + () => commons.movePanelToAnotherTab(store, DASH, "nope", FOLDER, "default", "default"), + ], + [ + "movePanelToAnotherTab, unknown target tab", + () => commons.movePanelToAnotherTab(store, DASH, "p1", FOLDER, "default", "nope"), + ], + ["deleteVariable, unknown name", () => commons.deleteVariable(store, DASH, "nope", FOLDER)], + [ + "updateVariable, unknown name", + () => commons.updateVariable(t, store, DASH, "nope", { name: "nope" }, FOLDER), + ], + ["addPanel, unknown tab", () => commons.addPanel(store, DASH, panel("n", "N"), FOLDER, "nope")], + ])("%s", async (_label, call) => { + const before = JSON.stringify(store.state.organizationData.allDashboardData[DASH]); + await call(); + expect(service.save).not.toHaveBeenCalled(); + expect(JSON.stringify(store.state.organizationData.allDashboardData[DASH])).toBe(before); + expect(titles()).toEqual(["p1", "p2"]); + }); + + it("addPanel replaces a panel that is already pending instead of adding a twin", async () => { + await commons.addPanel( + store, + DASH, + { ...panel("p2", "Latency v2"), layout: undefined }, + FOLDER, + "default", + ); + const [{ body }] = savedBodies(); + expect(body.tabs[0].panels.map((p: any) => [p.id, p.title])).toEqual([ + ["p1", "Error rate"], + ["p2", "Latency v2"], + ]); + expect(body.tabs[0].panels[1].layout).toEqual(panel("p2", "Latency", 18).layout); + }); +}); + +describe("fix round 2", () => { + it("stamps every save with the time, since the server stores updatedAt as the client sends it", async () => { + service.get_Dashboard.mockResolvedValue(response(BASE, "h1")); + const cached = await getDashboard(store, DASH, FOLDER); + const before = Date.now() * 1000; + + await updateDashboard(store, "acme", DASH, cached, FOLDER); + + const [{ body }] = savedBodies(); + expect(body.updatedAt).toBeGreaterThanOrEqual(before); + expect(body.updatedAt).toBeLessThanOrEqual(Date.now() * 1000); + expect(body.tabs).toEqual(BASE.tabs); + expect(cached).not.toHaveProperty("updatedAt"); + }); + + it("shows the time a stamped save carried back in the real GET shape", async () => { + await editPanelAndConflict(); + expect(state.latestUpdatedAt).toBe(1_760_000_000_000); + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2", 0)); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER }, + { store, t, locale: "en-US" }, + ); + expect(state.latestUpdatedAt).toBeNull(); + }); + + it("marks the dashboard not saved until the conflict is resolved, and can reopen the dialog", async () => { + await editPanelAndConflict(); + expect(state.unresolvedDashboardId).toBe(DASH); + + dialog.close(); + expect(state.unresolvedDashboardId).toBe(DASH); + dialog.reopen(); + expect(state).toMatchObject({ open: true, step: "main" }); + + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await dialog.reload(); + expect(state.unresolvedDashboardId).toBe(""); + }); + + it("drops the not-saved mark when the dashboard is loaded fresh", async () => { + await editPanelAndConflict(); + store.state.organizationData.allDashboardData = {}; + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await getDashboard(store, DASH, FOLDER); + expect(state.unresolvedDashboardId).toBe(""); + }); +}); + +describe("fix round 1", () => { + it("fetches the dialog as soon as a save site mounts, so a conflict opens it at once", async () => { + const Site = defineComponent({ + setup() { + useDashboardSaveConflict(); + return () => h("div"); + }, + }); + mount(Site, { global: { plugins: [i18n], provide: { store: makeStore() } } }); + await flushPromises(); + expect(dialogModuleLoaded).toHaveBeenCalledTimes(1); + }); + + it("reads the other save's time from the camelCase updatedAt the server sends", async () => { + await editPanelAndConflict(); + expect(state.latestUpdatedAt).toBe(1_760_000_000_000); + }); + + it("still reads a snake_case updated_at", async () => { + service.get_Dashboard.mockResolvedValueOnce({ + data: { version: 8, hash: "h2", updated_at: 1_760_000_000_000_000, v8: LATEST }, + }); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER }, + { store, t, locale: "en-US" }, + ); + expect(state.latestUpdatedAt).toBe(1_760_000_000_000); + }); + + it("shows my pending change and names it when Reload discards it", async () => { + await editPanelAndConflict(); + expect(state.myChange).toBe(MY_CHANGE); + + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await dialog.reload(); + expect(toast).toHaveBeenCalledWith({ + variant: "success", + timeout: 15_000, + message: `Loaded the latest version. Discarded your change: ${MY_CHANGE}`, + }); + }); + + it("a save after closing the dialog is not silent: it reopens the dialog against my original base", async () => { + await editPanelAndConflict(); + dialog.close(); + service.save.mockClear(); + service.save.mockRejectedValueOnce(conflict()); + + await expect( + updatePanel(store, DASH, { ...panel("p1", "Error rate v2") }, FOLDER, "default"), + ).rejects.toBeTruthy(); + expect(savedBodies()[0].hash).toBe("h1"); + + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER, panelId: "p1" }, + { store, t, locale: "en-US" }, + ); + expect(state).toMatchObject({ open: true, notice: "stillUnresolved" }); + expect(state.changes).toEqual( + expect.arrayContaining([expect.objectContaining({ key: "p3", change: "added" })]), + ); + }); + + it("resolving clears the unresolved conflict so the next save uses the latest hash", async () => { + await editPanelAndConflict(); + service.get_Dashboard.mockResolvedValue(response(LATEST, "h3")); + dialog.startOverwrite(); + await dialog.confirmOverwrite(); + service.save.mockClear(); + + await updateDashboard(store, "acme", DASH, await getDashboard(store, DASH, FOLDER), FOLDER); + expect(savedBodies()[0].hash).toBe("h3"); + }); + + it("compares their version of my panel with mine when they also edited it", async () => { + service.get_Dashboard.mockResolvedValueOnce(response(BASE, "h1")); + await getDashboard(store, DASH, FOLDER); + service.get_Dashboard.mockResolvedValueOnce( + response(doc([panel("p1", "Errors (theirs)"), panel("p2", "Latency", 18)]), "h2"), + ); + await openDashboardConflict( + { + dashboardId: DASH, + folderId: FOLDER, + panelId: "p1", + mine: doc([panel("p1", "Errors (mine)"), panel("p2", "Latency", 18)]), + }, + { store, t, locale: "en-US" }, + ); + expect(state.panelComparison).toMatchObject({ + theirsTitle: "Errors (theirs)", + mineTitle: "Errors (mine)", + sameTitle: false, + }); + }); +}); + +describe("dialog actions", () => { + it("Reload re-fetches in place, reports it and never reloads the page", async () => { + const reloadSpy = vi.fn(); + vi.stubGlobal("location", { ...window.location, reload: reloadSpy }); + const scope = effectScope(); + const resolved = vi.fn(); + scope.run(() => onDashboardConflictResolved(resolved)); + const { onResolved } = await editPanelAndConflict(); + service.get_Dashboard.mockResolvedValueOnce(response(LATEST, "h2")); + + await dialog.reload(); + await nextTick(); + + expect(service.get_Dashboard).toHaveBeenCalledTimes(3); + expect(onResolved).toHaveBeenCalledWith("reloaded"); + expect(resolved).toHaveBeenCalledWith(DASH, "reloaded"); + expect(state.open).toBe(false); + expect(reloadSpy).not.toHaveBeenCalled(); + scope.stop(); + vi.unstubAllGlobals(); + }); + + it("Overwrite needs a confirm step, then saves my version against the latest hash", async () => { + const { onResolved } = await editPanelAndConflict(); + service.save.mockClear(); + service.get_Dashboard.mockResolvedValue(response(LATEST, "h3")); + + dialog.startOverwrite(); + expect(state.step).toBe("confirm"); + await dialog.confirmOverwrite(); + + const [{ body, hash }] = savedBodies(); + expect(hash).toBe("h2"); + expect(body.tabs[0].panels.map((p: any) => p.id)).toEqual(["p1", "p2"]); + expect(body.tabs[0].panels[0].queries).toEqual([{ q: "mine" }]); + expect(onResolved).toHaveBeenCalledWith("overwritten"); + expect(state.open).toBe(false); + }); + + it("a second 409 during an action reopens the dialog on the newer version", async () => { + const { onResolved } = await editPanelAndConflict(); + service.save.mockRejectedValueOnce(conflict()); + service.get_Dashboard.mockResolvedValueOnce( + response(doc([panel("p1", "Error rate"), panel("p4", "Queue", 18)]), "h3"), + ); + + dialog.startOverwrite(); + await dialog.confirmOverwrite(); + + expect(onResolved).not.toHaveBeenCalled(); + expect(state).toMatchObject({ open: true, step: "main", notice: "savedAgain" }); + expect(state.changes).toEqual( + expect.arrayContaining([expect.objectContaining({ key: "p4", change: "added" })]), + ); + + service.save.mockClear(); + service.get_Dashboard.mockResolvedValue(response(LATEST, "h4")); + dialog.startOverwrite(); + await dialog.confirmOverwrite(); + expect(savedBodies()[0].hash).toBe("h3"); + }); + + it("Merge puts my panel on the latest version for review, then saves it", async () => { + const { onResolved } = await editPanelAndConflict(); + service.save.mockClear(); + service.get_Dashboard.mockResolvedValue(response(LATEST, "h3")); + + dialog.merge(); + expect(state.step).toBe("review"); + expect(state.mergedRows).toEqual([ + { key: "p1", name: "Error rate", tag: "yours" }, + { key: "p2", name: "Latency p99", tag: "theirsEdited" }, + { key: "p3", name: "Disk usage", tag: "theirsNew" }, + ]); + + await dialog.saveMerged(); + const [{ body, hash }] = savedBodies(); + expect(hash).toBe("h2"); + expect(body.tabs[0].panels.map((p: any) => p.id)).toEqual(["p1", "p2", "p3"]); + expect(body.tabs[0].panels[0].queries).toEqual([{ q: "mine" }]); + expect(body.variables.list.map((v: any) => v.name)).toEqual(["env", "region"]); + expect(onResolved).toHaveBeenCalledWith("merged"); + }); + + it("Discarding a merge closes the dialog and keeps my edit unsaved", async () => { + const { onResolved } = await editPanelAndConflict(); + dialog.merge(); + dialog.discardMerge(); + expect(state.open).toBe(false); + expect(onResolved).not.toHaveBeenCalled(); + expect(service.save).toHaveBeenCalledTimes(1); + }); + + it("does not merge when their save also changed my panel", async () => { + service.get_Dashboard.mockResolvedValueOnce(response(BASE, "h1")); + await getDashboard(store, DASH, FOLDER); + service.get_Dashboard.mockResolvedValueOnce( + response(doc([panel("p1", "Errors (theirs)"), panel("p2", "Latency", 18)]), "h2"), + ); + await openDashboardConflict( + { dashboardId: DASH, folderId: FOLDER, panelId: "p1" }, + { store, t, locale: "en-US" }, + ); + expect(state.panelTouched).toBe(true); + + dialog.merge(); + + expect(state.step).toBe("main"); + expect(state.mergedRows).toEqual([]); + }); + + it("closing keeps my edit and resolves nothing", async () => { + const { onResolved } = await editPanelAndConflict(); + dialog.close(); + expect(state.open).toBe(false); + expect(onResolved).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/composables/dashboard/useDashboardSaveConflict.ts b/web/src/composables/dashboard/useDashboardSaveConflict.ts new file mode 100644 index 0000000000..9cf7012429 --- /dev/null +++ b/web/src/composables/dashboard/useDashboardSaveConflict.ts @@ -0,0 +1,416 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { toRaw, unref, watch, type Ref } from "vue"; +import { useStore } from "vuex"; +import { raw, useI18nTyped, type I18nText, type TranslateFn } from "@/types/i18n"; +import { toast } from "@/lib/feedback/Toast/useToast"; +import { + adoptDashboardWorkingCopy, + fetchLatestDashboard, + fetchServerDashboard, + getLoadedDashboardVersion, + getUnresolvedDashboardConflict, + markDashboardStale, + setUnresolvedDashboardConflict, + updateDashboard, + type LoadedDashboardVersion, +} from "@/utils/commons"; +import { + comparePanel, + diffDashboards, + formatClockTime, + isPanelTouched, + mergePanelOntoLatest, + overwriteImpact, + toEpochMs, +} from "@/utils/dashboard/saveConflict"; +import { + dashboardSaveConflictState as state, + type ConflictAction, + type ConflictOutcome, + type MergedPanelRow, +} from "./dashboardSaveConflictState"; + +export interface DashboardConflictRequest { + dashboardId: string; + folderId: string; + /** The dashboard I tried to save; defaults to the cached copy the save helpers edit in place. */ + mine?: any; + /** Panel editor only: the edited panel, which enables Merge. */ + panelId?: string; + /** What I tried to save, shown in the dialog and named when Reload discards it. */ + change?: I18nText; + onResolved?: (outcome: ConflictOutcome) => void | Promise; +} + +interface ConflictContext { + store: any; + t: TranslateFn; + locale: Ref | string; +} + +interface ConflictSession { + request: DashboardConflictRequest; + ctx: ConflictContext; + /** Every change that lost a conflict since the dashboard was loaded, oldest first. */ + changes: I18nText[]; + onResolved: NonNullable[]; + mine: any; + base: LoadedDashboardVersion | null; + /** The server's version and hash; the cache keeps the user's working copy until Reload. */ + latest: { data: any; hash: string; updatedAt: number | null } | null; + merged: any | null; +} + +const RELOAD_TOAST_MS = 15_000; + +let session: ConflictSession | null = null; + +const clone = (value: T): T => JSON.parse(JSON.stringify(toRaw(value) ?? null)); + +const localeOf = (ctx: ConflictContext) => unref(ctx.locale) || "en-US"; + +const orgOf = (ctx: ConflictContext) => ctx.store.state.selectedOrganization.identifier; + +const conflictMessage = (error: any) => raw(error?.response?.data?.message ?? error?.message ?? ""); + +export const isDashboardConflictError = (error: any) => error?.response?.status === 409; + +// The cached dashboard is the working copy: my loaded version plus every pending edit, and what Overwrite saves. +const workingCopy = (s: ConflictSession) => + s.ctx.store.state.organizationData.allDashboardData?.[s.request.dashboardId] ?? s.mine; + +const changesText = (s: ConflictSession) => + new Intl.ListFormat(localeOf(s.ctx), { style: "long", type: "conjunction" }).format(s.changes); + +const recompute = (s: ConflictSession) => { + const latest = s.latest?.data; + const diff = s.base ? diffDashboards(s.base.snapshot, latest) : null; + state.hasBase = !!diff; + state.changes = diff?.changes ?? []; + state.changedSettings = diff?.changedSettings ?? []; + state.latestUpdatedAt = toEpochMs(s.latest?.updatedAt); + state.panelTouched = isPanelTouched(state.changes, s.request.panelId); + state.panelComparison = state.panelTouched + ? comparePanel(latest, workingCopy(s), s.request.panelId) + : null; +}; + +const loadLatest = async (s: ConflictSession) => { + try { + const latest = await fetchServerDashboard( + s.ctx.store, + s.request.dashboardId, + s.request.folderId, + ); + if (session !== s) return; + s.latest = { data: clone(latest.data), hash: latest.hash, updatedAt: latest.updatedAt }; + recompute(s); + state.step = "main"; + } catch (error: any) { + if (session !== s) return; + state.errorMessage = conflictMessage(error); + state.step = "error"; + } +}; + +const signalResolved = (dashboardId: string, outcome: ConflictOutcome) => { + state.resolvedDashboardId = dashboardId; + state.resolvedOutcome = outcome; + state.resolvedSeq += 1; +}; + +const finish = async (s: ConflictSession, outcome: ConflictOutcome) => { + setUnresolvedDashboardConflict(s.request.dashboardId, null); + for (const callback of s.onResolved) await callback(outcome); + signalResolved(s.request.dashboardId, outcome); + state.open = false; + session = null; +}; + +const runAction = async (action: ConflictAction, fn: (s: ConflictSession) => Promise) => { + const s = session; + if (!s || state.busy) return; + state.busy = action; + state.notice = null; + try { + await fn(s); + } finally { + state.busy = null; + } +}; + +/** Opens the shared conflict dialog for a dashboard save that failed with 409. */ +export const openDashboardConflict = async ( + request: DashboardConflictRequest, + ctx: ConflictContext, +) => { + const id = request.dashboardId; + // A save made from its own copy (settings forms, the JSON editor) becomes the working copy, so later edits stack on it. + if (request.mine) adoptDashboardWorkingCopy(ctx.store, id, request.mine); + const cached = ctx.store.state.organizationData.allDashboardData?.[id]; + const mineSource = cached ?? request.mine; + const unresolved = getUnresolvedDashboardConflict(id); + const base = unresolved ?? getLoadedDashboardVersion(id); + setUnresolvedDashboardConflict(id, base); + state.unresolvedDashboardId = String(id); + markDashboardStale(request.mine, base?.hash); + markDashboardStale(cached, base?.hash); + + // A further conflict before the first is resolved adds to the same session instead of dropping its pending edits. + const extend = !!unresolved && session?.request.dashboardId === id ? session : null; + const s: ConflictSession = extend ?? { + request, + ctx, + changes: [], + onResolved: [], + mine: null, + base, + latest: null, + merged: null, + }; + s.request = request; + s.ctx = ctx; + s.mine = clone(mineSource); + s.merged = null; + if (request.change && !s.changes.includes(request.change)) s.changes.push(request.change); + if (request.onResolved) s.onResolved.push(request.onResolved); + session = s; + Object.assign(state, { + mounted: true, + openSeq: state.openSeq + 1, + open: true, + step: "loading", + busy: null, + notice: unresolved ? "stillUnresolved" : null, + errorMessage: "", + myChange: changesText(s), + myChangeCount: s.changes.length, + dashboardId: String(id), + isPanelEditor: !!request.panelId, + panelTouched: false, + hasBase: !!base, + hasMine: !!s.mine, + latestUpdatedAt: null, + changes: [], + changedSettings: [], + panelComparison: null, + impact: null, + mergedRows: [], + mergeUnavailable: false, + }); + await loadLatest(s); +}; + +const saveAgainstLatest = async (s: ConflictSession, doc: any, outcome: ConflictOutcome) => { + const { store, t } = s.ctx; + const savedAt = state.latestUpdatedAt; + try { + await updateDashboard( + store, + orgOf(s.ctx), + s.request.dashboardId, + doc, + s.request.folderId, + s.latest?.hash, + ); + } catch (error: any) { + if (isDashboardConflictError(error)) { + state.notice = "savedAgain"; + state.step = "loading"; + await loadLatest(s); + return; + } + state.errorMessage = conflictMessage(error); + state.notice = "saveFailed"; + return; + } + await finish(s, outcome); + if (outcome === "merged") + toast({ variant: "success", message: t("dashboard.saveConflict.toastMerged") }); + else if (savedAt) + toast({ + variant: "success", + message: t("dashboard.saveConflict.toastOverwritten", { + time: formatClockTime(savedAt, localeOf(s.ctx)), + }), + }); + else toast({ variant: "success", message: t("dashboard.saveConflict.toastOverwrittenNoTime") }); +}; + +const reload = () => + runAction("reload", async (s) => { + try { + await fetchLatestDashboard(s.ctx.store, s.request.dashboardId, s.request.folderId); + } catch (error: any) { + state.errorMessage = conflictMessage(error); + state.notice = "saveFailed"; + return; + } + const discarded = changesText(s); + // Shown before the sites react (the panel editor navigates away), and kept longer than a routine success: it reports lost work. + toast({ + variant: "success", + timeout: RELOAD_TOAST_MS, + message: discarded + ? s.ctx.t("dashboard.saveConflict.toastReloadedChange", { change: discarded }) + : s.ctx.t("dashboard.saveConflict.toastReloaded"), + }); + await finish(s, "reloaded"); + }); + +const confirmOverwrite = () => + runAction("overwrite", async (s) => { + const mine = workingCopy(s); + if (mine) await saveAgainstLatest(s, clone(mine), "overwritten"); + }); + +// The confirm lists what saving the working copy undoes of the server's version, as it stands right now. +const startOverwrite = () => { + const s = session; + setStep("confirm"); + if (!s?.latest) return; + state.impact = overwriteImpact(s.base?.snapshot ?? null, s.latest.data, clone(workingCopy(s))); +}; + +const mergedRowsFor = (s: ConflictSession, merged: any): MergedPanelRow[] => { + const added = new Set( + state.changes.filter((c) => c.kind === "panel" && c.change === "added").map((c) => c.key), + ); + const edited = new Set( + state.changes.filter((c) => c.kind === "panel" && c.change === "changed").map((c) => c.key), + ); + const rows: MergedPanelRow[] = []; + for (const tab of merged?.tabs ?? []) { + for (const panel of tab?.panels ?? []) { + const key = String(panel?.id); + const tag = + key === String(s.request.panelId) + ? "yours" + : added.has(key) + ? "theirsNew" + : edited.has(key) + ? "theirsEdited" + : null; + rows.push({ key, name: panel?.title || key, tag }); + } + } + return rows; +}; + +const merge = () => { + const s = session; + // Merging would silently replace their edit of the same panel, so that case only offers Reload or Overwrite. + if (!s?.latest || !s.request.panelId || state.panelTouched) return; + const merged = mergePanelOntoLatest( + s.latest.data, + clone(workingCopy(s)), + s.request.panelId, + s.base?.snapshot ?? s.latest.data, + ); + state.mergeUnavailable = !merged; + if (!merged) return; + s.merged = merged; + state.mergedRows = mergedRowsFor(s, merged); + state.step = "review"; +}; + +const saveMerged = () => + runAction("saveMerged", async (s) => { + if (s.merged) await saveAgainstLatest(s, s.merged, "merged"); + }); + +const discardMerge = () => { + const s = session; + state.open = false; + if (s) { + s.merged = null; + toast({ variant: "info", message: s.ctx.t("dashboard.saveConflict.toastMergeDiscarded") }); + } +}; + +const retry = async () => { + const s = session; + if (!s) return; + state.step = "loading"; + await loadLatest(s); +}; + +const setStep = (step: "main" | "confirm") => { + state.notice = null; + state.mergeUnavailable = false; + state.step = step; +}; + +const close = () => { + if (state.busy) return; + state.open = false; +}; + +// Reopening is not a save attempt, so it never carries a save notice; it re-reads the working copy as it is now. +const reopen = () => { + const s = session; + if (!s) return; + if (state.step !== "loading" && state.step !== "error") setStep("main"); + state.notice = null; + s.mine = clone(workingCopy(s)); + if (s.latest) recompute(s); + state.openSeq += 1; + state.open = true; +}; + +/** Actions for the shared dialog; state lives in `dashboardSaveConflictState`. */ +export const useDashboardSaveConflictDialog = () => ({ + state, + reload, + startOverwrite, + confirmOverwrite, + merge, + saveMerged, + discardMerge, + back: () => setStep("main"), + retry, + close, + reopen, +}); + +let dialogPreload: Promise | null = null; + +/** For dashboard save sites: replaces the old "Refresh" toast on a 409. */ +export const useDashboardSaveConflict = () => { + // Fetch the lazily mounted dialog ahead of time so it opens the moment a save conflicts. + dialogPreload ??= import("@/components/dashboards/DashboardSaveConflictDialog.vue").catch( + () => (dialogPreload = null), + ); + const store = useStore(); + const { t, locale } = useI18nTyped(); + return { + isConflict: isDashboardConflictError, + openConflict: (request: DashboardConflictRequest) => + openDashboardConflict(request, { store, t, locale }), + }; +}; + +/** Re-renders a view after the conflict dialog reloaded, overwrote or merged its dashboard. */ +export const onDashboardConflictResolved = ( + callback: (dashboardId: string, outcome: ConflictOutcome) => void, +) => + watch( + () => state.resolvedSeq, + () => { + if (state.resolvedOutcome) callback(state.resolvedDashboardId, state.resolvedOutcome); + }, + ); diff --git a/web/src/composables/traces/useTraceProcessing.spec.ts b/web/src/composables/traces/useTraceProcessing.spec.ts index ae5198e882..25b12133cb 100644 --- a/web/src/composables/traces/useTraceProcessing.spec.ts +++ b/web/src/composables/traces/useTraceProcessing.spec.ts @@ -396,7 +396,8 @@ describe("useTraceProcessing", () => { }); }); -describe("deep span chains", () => { +// 20,000-level chains are slow on loaded CI runners, so the default 5 s timeout flakes. +describe("deep span chains", { timeout: 30_000 }, () => { const DEPTH = 20_000; const oldNode = (id: string, children: any[] = []) => ({ diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index cf6eae4e10..03c71466a9 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -227,6 +227,10 @@ along with this program. If not, see . + @@ -264,6 +268,7 @@ import { nextTick, onBeforeMount, provide, + defineAsyncComponent, } from "vue"; import { useStore } from "vuex"; import { useTheme } from "@/composables/useTheme"; @@ -291,6 +296,7 @@ import ManagementIcon from "@/components/icons/ManagementIcon.vue"; import useStreams from "@/composables/useStreams"; import { userDataStreams } from "@/utils/internalStreams"; import FirstDataNotice from "@/components/ingestion/FirstDataNotice.vue"; +import { dashboardSaveConflictState } from "@/composables/dashboard/dashboardSaveConflictState"; import { FIRST_DATA_NOTICE, useFirstDataNotice, @@ -344,6 +350,9 @@ export default defineComponent({ ODrawer, OButton, FirstDataNotice, + DashboardSaveConflictDialog: defineAsyncComponent( + () => import("@/components/dashboards/DashboardSaveConflictDialog.vue"), + ), }, methods: { navigateToDocs() { @@ -1653,6 +1662,7 @@ export default defineComponent({ isDark, t, raw, + dashboardSaveConflict: dashboardSaveConflictState, firstDataNotice, layoutMixin, router, diff --git a/web/src/lib/overlay/Dialog/ODialog.vue b/web/src/lib/overlay/Dialog/ODialog.vue index 203a183e2c..5dac8abb36 100644 --- a/web/src/lib/overlay/Dialog/ODialog.vue +++ b/web/src/lib/overlay/Dialog/ODialog.vue @@ -1,4 +1,5 @@