mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
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>
This commit is contained in:
co-authored by
github-actions[bot]
parent
7366654175
commit
00be51d925
@@ -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",
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<typeof import("reka-ui")>();
|
||||
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<string, any> = {}) => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
<!--
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<ODialog
|
||||
:open="state.open"
|
||||
size="lg"
|
||||
:title="t('dashboard.saveConflict.title')"
|
||||
:persistent="!!state.busy"
|
||||
data-test="dashboard-save-conflict-dialog"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<OBanner
|
||||
v-if="state.notice === 'savedAgain'"
|
||||
variant="warning"
|
||||
icon="warning"
|
||||
:content="t('dashboard.saveConflict.savedAgain')"
|
||||
data-test="dashboard-save-conflict-saved-again"
|
||||
/>
|
||||
<OBanner
|
||||
v-if="state.notice === 'stillUnresolved'"
|
||||
variant="warning"
|
||||
icon="warning"
|
||||
:content="t('dashboard.saveConflict.stillUnresolved')"
|
||||
data-test="dashboard-save-conflict-still-unresolved"
|
||||
/>
|
||||
<OBanner
|
||||
v-if="state.notice === 'saveFailed'"
|
||||
variant="error"
|
||||
icon="error"
|
||||
:content="t('dashboard.saveConflict.saveFailed', { message: state.errorMessage })"
|
||||
data-test="dashboard-save-conflict-save-failed"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="state.step === 'loading'"
|
||||
class="flex flex-col gap-3"
|
||||
data-test="dashboard-save-conflict-loading"
|
||||
>
|
||||
<div class="text-text-secondary flex items-center gap-2 text-sm">
|
||||
<OSpinner size="xs" />
|
||||
{{ t("dashboard.saveConflict.checking") }}
|
||||
</div>
|
||||
<OSkeleton class="h-4 w-3/4" />
|
||||
<OSkeleton class="h-4 w-11/12" />
|
||||
<OSkeleton class="h-4 w-1/2" />
|
||||
</div>
|
||||
|
||||
<OBanner
|
||||
v-else-if="state.step === 'error'"
|
||||
variant="error"
|
||||
icon="error"
|
||||
:content="t('dashboard.saveConflict.loadFailed')"
|
||||
data-test="dashboard-save-conflict-load-failed"
|
||||
/>
|
||||
|
||||
<template v-else-if="state.step === 'main'">
|
||||
<div
|
||||
class="text-text-body flex items-center gap-2 text-sm"
|
||||
data-test="dashboard-save-conflict-saved-by"
|
||||
>
|
||||
<OIcon name="history" size="sm" class="text-text-secondary" />
|
||||
<span>{{ savedByText }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="state.myChange"
|
||||
class="text-text-body flex items-center gap-2 text-sm"
|
||||
data-test="dashboard-save-conflict-my-change"
|
||||
>
|
||||
<OIcon name="edit-note" size="sm" class="text-text-secondary" />
|
||||
<span>{{
|
||||
t(
|
||||
"dashboard.saveConflict.yourChange",
|
||||
{ change: state.myChange },
|
||||
Math.max(state.myChangeCount, 1),
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="state.isPanelEditor">
|
||||
<OBadge
|
||||
v-if="state.panelTouched"
|
||||
variant="warning-soft"
|
||||
icon="warning"
|
||||
data-test="dashboard-save-conflict-panel-touched"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.panelTouched") }}
|
||||
</OBadge>
|
||||
<OBadge
|
||||
v-else
|
||||
variant="success-soft"
|
||||
icon="check-circle"
|
||||
data-test="dashboard-save-conflict-panel-untouched"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.panelUntouched") }}
|
||||
</OBadge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="comparison"
|
||||
class="border-border-default rounded-default grid grid-cols-[auto_1fr_1fr] gap-x-4 gap-y-1.5 border px-3 py-2 text-sm"
|
||||
data-test="dashboard-save-conflict-panel-compare"
|
||||
>
|
||||
<span class="text-text-secondary text-xs">{{
|
||||
t("dashboard.saveConflict.compareField")
|
||||
}}</span>
|
||||
<span class="text-text-secondary text-xs">{{
|
||||
t("dashboard.saveConflict.compareTheirs")
|
||||
}}</span>
|
||||
<span class="text-text-secondary text-xs">{{
|
||||
t("dashboard.saveConflict.compareYours")
|
||||
}}</span>
|
||||
<template v-for="row in comparison" :key="row.key">
|
||||
<span class="text-text-heading font-medium">{{ row.label }}</span>
|
||||
<OTruncatedText
|
||||
class="text-text-body min-w-0"
|
||||
:data-test="`dashboard-save-conflict-compare-${row.key}-theirs`"
|
||||
>{{ row.theirs }}</OTruncatedText
|
||||
>
|
||||
<OTruncatedText
|
||||
class="text-text-body min-w-0"
|
||||
:data-test="`dashboard-save-conflict-compare-${row.key}-yours`"
|
||||
>{{ row.yours }}</OTruncatedText
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-border-default rounded-default border">
|
||||
<OCollapsible v-model="changesOpen" data-test="dashboard-save-conflict-changes">
|
||||
<template #trigger="{ open }">
|
||||
<div class="flex flex-wrap items-center gap-2 px-3 py-2 text-sm">
|
||||
<OIcon :name="open ? 'expand-more' : 'chevron-right'" size="sm" />
|
||||
<span class="text-text-heading font-medium">
|
||||
{{ t("dashboard.saveConflict.whatChanged") }}
|
||||
</span>
|
||||
<OBadge
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
:variant="chip.variant"
|
||||
size="xs"
|
||||
:data-test="`dashboard-save-conflict-chip-${chip.key}`"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</OBadge>
|
||||
</div>
|
||||
</template>
|
||||
<div class="border-border-default border-t px-3 py-2">
|
||||
<ul v-if="changeRows.length" class="flex flex-col gap-1.5">
|
||||
<li
|
||||
v-for="row in changeRows"
|
||||
:key="row.key"
|
||||
class="flex min-w-0 items-center gap-2 text-sm"
|
||||
:data-test="row.dataTest"
|
||||
>
|
||||
<span class="text-text-secondary w-16 shrink-0 text-xs">
|
||||
{{ row.kind }}
|
||||
</span>
|
||||
<OBadge
|
||||
:variant="CHANGE_BADGE[row.change].variant"
|
||||
:icon="CHANGE_BADGE[row.change].icon"
|
||||
size="xs"
|
||||
>
|
||||
{{ changeLabel(row.change) }}
|
||||
</OBadge>
|
||||
<OTruncatedText class="text-text-heading min-w-0 flex-1 font-medium">
|
||||
{{ row.name }}
|
||||
</OTruncatedText>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="text-text-secondary text-sm"
|
||||
data-test="dashboard-save-conflict-no-changes"
|
||||
>
|
||||
{{ emptyChangesText }}
|
||||
</p>
|
||||
</div>
|
||||
</OCollapsible>
|
||||
</div>
|
||||
|
||||
<OBanner
|
||||
v-if="state.mergeUnavailable"
|
||||
variant="warning"
|
||||
icon="warning"
|
||||
:content="t('dashboard.saveConflict.mergeUnavailable')"
|
||||
data-test="dashboard-save-conflict-merge-unavailable"
|
||||
/>
|
||||
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<template v-for="item in legend" :key="item.key">
|
||||
<dt class="text-text-heading font-medium">{{ item.label }}</dt>
|
||||
<dd class="text-text-secondary">{{ item.hint }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<OBanner
|
||||
v-else-if="state.step === 'confirm'"
|
||||
variant="error-soft"
|
||||
icon="warning"
|
||||
data-test="dashboard-save-conflict-confirm"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-medium">{{ impact.heading }}</span>
|
||||
<ul class="flex list-disc flex-col gap-1 ps-5">
|
||||
<li v-for="line in impact.lines" :key="line">{{ line }}</li>
|
||||
</ul>
|
||||
<span data-test="dashboard-save-conflict-confirm-saved-by">{{ savedByText }}</span>
|
||||
<span>{{ t("dashboard.saveConflict.cannotUndo") }}</span>
|
||||
</div>
|
||||
</OBanner>
|
||||
|
||||
<div
|
||||
v-else-if="state.step === 'review'"
|
||||
class="flex flex-col gap-3"
|
||||
data-test="dashboard-save-conflict-review"
|
||||
>
|
||||
<OBanner
|
||||
variant="warning"
|
||||
icon="call-merge"
|
||||
:content="t('dashboard.saveConflict.reviewIntro')"
|
||||
/>
|
||||
<span class="text-text-heading text-sm font-medium">
|
||||
{{ t("dashboard.saveConflict.reviewHeading") }}
|
||||
</span>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li
|
||||
v-for="row in state.mergedRows"
|
||||
:key="row.key"
|
||||
class="flex min-w-0 items-center gap-2 text-sm"
|
||||
:data-test="`dashboard-save-conflict-merged-${row.key}`"
|
||||
>
|
||||
<OTruncatedText class="text-text-body min-w-0 flex-1">{{ row.name }}</OTruncatedText>
|
||||
<OBadge v-if="row.tag" :variant="MERGE_TAG[row.tag]" size="xs">
|
||||
{{ tagLabel(row.tag) }}
|
||||
</OBadge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<OButton
|
||||
v-if="state.step === 'main' || state.step === 'loading'"
|
||||
variant="outline-destructive"
|
||||
size="sm-action"
|
||||
:disabled="!actionsReady || !state.hasMine"
|
||||
data-test="dashboard-save-conflict-overwrite"
|
||||
@click="startOverwrite"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.overwrite") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
v-else-if="state.step !== 'error'"
|
||||
variant="ghost"
|
||||
size="sm-action"
|
||||
:disabled="!!state.busy"
|
||||
data-test="dashboard-save-conflict-back"
|
||||
@click="back"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.back") }}
|
||||
</OButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="state.step === 'main' || state.step === 'loading'">
|
||||
<OButton
|
||||
v-for="action in mainActions"
|
||||
:key="action.key"
|
||||
:variant="action.variant"
|
||||
size="sm-action"
|
||||
:disabled="!actionsReady || action.disabled"
|
||||
:loading="state.busy === action.key"
|
||||
:data-test="`dashboard-save-conflict-${action.key}`"
|
||||
@click="action.run"
|
||||
>
|
||||
{{ action.label }}
|
||||
</OButton>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state.step === 'error'">
|
||||
<OButton variant="outline" size="sm-action" @click="close">
|
||||
{{ t("common.close") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
data-test="dashboard-save-conflict-retry"
|
||||
@click="retry"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.retry") }}
|
||||
</OButton>
|
||||
</template>
|
||||
|
||||
<OButton
|
||||
v-else-if="state.step === 'confirm'"
|
||||
variant="destructive"
|
||||
size="sm-action"
|
||||
:loading="state.busy === 'overwrite'"
|
||||
data-test="dashboard-save-conflict-confirm-overwrite"
|
||||
@click="confirmOverwrite"
|
||||
>
|
||||
{{
|
||||
impact.removes
|
||||
? t("dashboard.saveConflict.removeAndOverwrite")
|
||||
: t("dashboard.saveConflict.overwrite")
|
||||
}}
|
||||
</OButton>
|
||||
|
||||
<template v-else-if="state.step === 'review'">
|
||||
<OButton
|
||||
variant="outline-destructive"
|
||||
size="sm-action"
|
||||
:disabled="!!state.busy"
|
||||
data-test="dashboard-save-conflict-discard-merge"
|
||||
@click="discardMerge"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.discard") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
:loading="state.busy === 'saveMerged'"
|
||||
data-test="dashboard-save-conflict-save-merged"
|
||||
@click="saveMerged"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.saveMerged") }}
|
||||
</OButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ODialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { provideDialogLayerAboveOpenOverlays } from "@/lib/overlay/Dialog/dialogStacking";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OBadge from "@/lib/core/Badge/OBadge.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
|
||||
import type { ButtonVariant } from "@/lib/core/Button/OButton.types";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.types";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import { useDashboardSaveConflictDialog } from "@/composables/dashboard/useDashboardSaveConflict";
|
||||
import type { MergedPanelRow } from "@/composables/dashboard/dashboardSaveConflictState";
|
||||
import {
|
||||
countChanges,
|
||||
formatClockTime,
|
||||
formatRelativeTime,
|
||||
type ConflictChange,
|
||||
type ConflictChangeType,
|
||||
type ConflictItemKind,
|
||||
type DashboardSettingKey,
|
||||
} from "@/utils/dashboard/saveConflict";
|
||||
|
||||
type MainActionKey = "reload" | "merge";
|
||||
|
||||
interface MainAction {
|
||||
key: MainActionKey;
|
||||
label: I18nText;
|
||||
variant: ButtonVariant;
|
||||
primary: boolean;
|
||||
disabled: boolean;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
const CHANGE_BADGE: Record<ConflictChangeType, { variant: BadgeVariant; icon: IconName }> = {
|
||||
added: { variant: "success-soft", icon: "add" },
|
||||
changed: { variant: "warning-soft", icon: "edit" },
|
||||
removed: { variant: "error-soft", icon: "remove" },
|
||||
};
|
||||
|
||||
const MERGE_TAG: Record<NonNullable<MergedPanelRow["tag"]>, BadgeVariant> = {
|
||||
yours: "primary-soft",
|
||||
theirsNew: "success-soft",
|
||||
theirsEdited: "warning-soft",
|
||||
};
|
||||
|
||||
const KINDS: ConflictItemKind[] = ["panel", "tab", "variable"];
|
||||
|
||||
const { t, locale } = useI18nTyped();
|
||||
const {
|
||||
state,
|
||||
reload,
|
||||
startOverwrite,
|
||||
confirmOverwrite,
|
||||
merge,
|
||||
saveMerged,
|
||||
discardMerge,
|
||||
back,
|
||||
retry,
|
||||
close,
|
||||
} = useDashboardSaveConflictDialog();
|
||||
|
||||
// Mounted at the app root, so it is outside any settings drawer or dialog that triggered the save; stack above them.
|
||||
provideDialogLayerAboveOpenOverlays();
|
||||
|
||||
const RELATIVE_TIME_TICK_MS = 15_000;
|
||||
const now = ref(Date.now());
|
||||
let clock: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const changesOpen = ref(true);
|
||||
watch(
|
||||
() => state.open,
|
||||
(open) => {
|
||||
clearInterval(clock);
|
||||
clock = undefined;
|
||||
if (!open) return;
|
||||
changesOpen.value = true;
|
||||
now.value = Date.now();
|
||||
// "Saved by someone else N seconds ago" keeps counting while the user decides.
|
||||
clock = setInterval(() => (now.value = Date.now()), RELATIVE_TIME_TICK_MS);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onUnmounted(() => clearInterval(clock));
|
||||
|
||||
const actionsReady = computed(() => state.step === "main" && !state.busy);
|
||||
|
||||
const listFormat = (items: string[]) =>
|
||||
new Intl.ListFormat(locale.value, { style: "long", type: "conjunction" }).format(items);
|
||||
|
||||
const kindLabel = (kind: ConflictItemKind) =>
|
||||
({
|
||||
panel: t("dashboard.saveConflict.kindPanel"),
|
||||
tab: t("dashboard.saveConflict.kindTab"),
|
||||
variable: t("dashboard.saveConflict.kindVariable"),
|
||||
})[kind];
|
||||
|
||||
const changeLabel = (change: ConflictChangeType) =>
|
||||
({
|
||||
added: t("dashboard.saveConflict.added"),
|
||||
changed: t("dashboard.saveConflict.changed"),
|
||||
removed: t("dashboard.saveConflict.removed"),
|
||||
})[change];
|
||||
|
||||
const tagLabel = (tag: NonNullable<MergedPanelRow["tag"]>) =>
|
||||
({
|
||||
yours: t("dashboard.saveConflict.tagYours"),
|
||||
theirsNew: t("dashboard.saveConflict.tagTheirsNew"),
|
||||
theirsEdited: t("dashboard.saveConflict.tagTheirsEdited"),
|
||||
})[tag];
|
||||
|
||||
const countPhrase = (kind: ConflictItemKind, count: number) =>
|
||||
({
|
||||
panel: t("dashboard.saveConflict.panelCount", { count }, count),
|
||||
tab: t("dashboard.saveConflict.tabCount", { count }, count),
|
||||
variable: t("dashboard.saveConflict.variableCount", { count }, count),
|
||||
})[kind];
|
||||
|
||||
const itemsPhrase = (changes: ConflictChange[], type: ConflictChangeType) => {
|
||||
const counts = countChanges(changes);
|
||||
return listFormat(
|
||||
KINDS.filter((kind) => counts[kind][type] > 0).map((kind) =>
|
||||
countPhrase(kind, counts[kind][type]),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const chips = computed(() => {
|
||||
const out: { key: string; label: I18nText; variant: BadgeVariant }[] = [];
|
||||
const typed = (type: ConflictChangeType) => state.changes.filter((c) => c.change === type);
|
||||
if (typed("added").length)
|
||||
out.push({
|
||||
key: "added",
|
||||
variant: "success-soft",
|
||||
label: t("dashboard.saveConflict.chipAdded", { items: itemsPhrase(state.changes, "added") }),
|
||||
});
|
||||
if (typed("changed").length)
|
||||
out.push({
|
||||
key: "changed",
|
||||
variant: "warning-soft",
|
||||
label: t("dashboard.saveConflict.chipChanged", {
|
||||
items: itemsPhrase(state.changes, "changed"),
|
||||
}),
|
||||
});
|
||||
if (typed("removed").length)
|
||||
out.push({
|
||||
key: "removed",
|
||||
variant: "error-soft",
|
||||
label: t("dashboard.saveConflict.chipRemoved", {
|
||||
items: itemsPhrase(state.changes, "removed"),
|
||||
}),
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
const emptyChangesText = computed(() =>
|
||||
state.hasBase
|
||||
? t("dashboard.saveConflict.nothingChanged")
|
||||
: t("dashboard.saveConflict.baseUnknown"),
|
||||
);
|
||||
|
||||
const settingLabel = (key: DashboardSettingKey) =>
|
||||
({
|
||||
title: t("dashboard.saveConflict.settingTitle"),
|
||||
description: t("dashboard.saveConflict.settingDescription"),
|
||||
defaultDatetimeDuration: t("dashboard.saveConflict.settingTimeRange"),
|
||||
showDynamicFilters: t("dashboard.saveConflict.settingDynamicFilters"),
|
||||
other: t("dashboard.saveConflict.settingOther"),
|
||||
})[key];
|
||||
|
||||
const settingsText = computed(() => listFormat(state.changedSettings.map(settingLabel)));
|
||||
|
||||
const changeRows = computed(() => {
|
||||
const rows = state.changes.map((change) => ({
|
||||
key: `${change.kind}-${change.key}`,
|
||||
kind: kindLabel(change.kind),
|
||||
change: change.change,
|
||||
name: change.previousName
|
||||
? t("dashboard.saveConflict.renamedFrom", { name: change.name, from: change.previousName })
|
||||
: raw(change.name),
|
||||
dataTest: `dashboard-save-conflict-change-${change.kind}-${change.change}`,
|
||||
}));
|
||||
if (state.changedSettings.length)
|
||||
rows.push({
|
||||
key: "settings",
|
||||
kind: t("dashboard.saveConflict.kindSettings"),
|
||||
change: "changed",
|
||||
name: raw(settingsText.value),
|
||||
dataTest: "dashboard-save-conflict-change-settings-changed",
|
||||
});
|
||||
return rows;
|
||||
});
|
||||
|
||||
const comparison = computed(() => {
|
||||
const c = state.panelComparison;
|
||||
if (!c) return null;
|
||||
const removed = t("dashboard.saveConflict.compareRemoved");
|
||||
const sameOrDifferent = c.sameOtherFields
|
||||
? t("dashboard.saveConflict.compareSame")
|
||||
: t("dashboard.saveConflict.compareDifferent");
|
||||
return [
|
||||
{
|
||||
key: "title",
|
||||
label: t("dashboard.saveConflict.compareTitle"),
|
||||
theirs: c.theirsRemoved ? removed : raw(c.theirsTitle),
|
||||
yours: raw(c.mineTitle),
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
label: t("dashboard.saveConflict.compareOther"),
|
||||
theirs: c.theirsRemoved ? removed : sameOrDifferent,
|
||||
yours: sameOrDifferent,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const savedByText = computed(() => {
|
||||
const ms = state.latestUpdatedAt;
|
||||
if (!ms) return t("dashboard.saveConflict.savedByUnknown");
|
||||
return t("dashboard.saveConflict.savedBy", {
|
||||
relative: formatRelativeTime(ms, locale.value, now.value),
|
||||
time: formatClockTime(ms, locale.value),
|
||||
});
|
||||
});
|
||||
|
||||
const legend = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
key: "reload",
|
||||
label: t("dashboard.saveConflict.reload"),
|
||||
hint: t("dashboard.saveConflict.reloadHint"),
|
||||
},
|
||||
{
|
||||
key: "overwrite",
|
||||
label: t("dashboard.saveConflict.overwrite"),
|
||||
hint: t("dashboard.saveConflict.overwriteHint"),
|
||||
},
|
||||
];
|
||||
if (canMerge.value)
|
||||
items.push({
|
||||
key: "merge",
|
||||
label: t("dashboard.saveConflict.merge"),
|
||||
hint: t("dashboard.saveConflict.mergeHint"),
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
// Merge only when their save left my panel alone; otherwise it would silently drop their edit of it.
|
||||
// Merge carries only this panel onto their version, so it is not offered once other edits are pending too.
|
||||
const canMerge = computed(
|
||||
() => state.isPanelEditor && !state.panelTouched && state.myChangeCount <= 1,
|
||||
);
|
||||
|
||||
// Safe default: Merge when it is offered, Reload otherwise.
|
||||
const mainActions = computed<MainAction[]>(() => {
|
||||
const actions: MainAction[] = [
|
||||
{
|
||||
key: "reload",
|
||||
label: t("dashboard.saveConflict.reload"),
|
||||
variant: canMerge.value ? "ghost" : "primary",
|
||||
primary: !canMerge.value,
|
||||
disabled: false,
|
||||
run: reload,
|
||||
},
|
||||
];
|
||||
if (canMerge.value)
|
||||
actions.push({
|
||||
key: "merge",
|
||||
label: t("dashboard.saveConflict.merge"),
|
||||
variant: "primary",
|
||||
primary: true,
|
||||
disabled: !state.hasMine,
|
||||
run: merge,
|
||||
});
|
||||
return actions.sort((a, b) => Number(a.primary) - Number(b.primary));
|
||||
});
|
||||
|
||||
// Computed from the server's version against the exact copy Overwrite will save.
|
||||
const impact = computed(() => {
|
||||
const i = state.impact;
|
||||
const names = (list: ConflictChange[]) => listFormat(list.map((c) => c.name));
|
||||
const items = (list: ConflictChange[]) => itemsPhrase(list, "changed");
|
||||
const lines: I18nText[] = [];
|
||||
if (i?.removes.length)
|
||||
lines.push(
|
||||
t("dashboard.saveConflict.impactRemoves", {
|
||||
items: items(i.removes),
|
||||
names: names(i.removes),
|
||||
}),
|
||||
);
|
||||
if (i?.deletesEdited.length)
|
||||
lines.push(
|
||||
t("dashboard.saveConflict.impactDeletesEdited", {
|
||||
items: items(i.deletesEdited),
|
||||
names: names(i.deletesEdited),
|
||||
}),
|
||||
);
|
||||
if (i?.reverts.length)
|
||||
lines.push(
|
||||
t("dashboard.saveConflict.impactReverts", {
|
||||
items: items(i.reverts),
|
||||
names: names(i.reverts),
|
||||
}),
|
||||
);
|
||||
if (i?.restores.length)
|
||||
lines.push(
|
||||
t("dashboard.saveConflict.impactRestores", {
|
||||
items: items(i.restores),
|
||||
names: names(i.restores),
|
||||
}),
|
||||
);
|
||||
if (i?.settings.length)
|
||||
lines.push(
|
||||
t("dashboard.saveConflict.impactSettings", {
|
||||
settings: listFormat(i.settings.map(settingLabel)),
|
||||
}),
|
||||
);
|
||||
if (!lines.length)
|
||||
lines.push(
|
||||
state.hasBase
|
||||
? t("dashboard.saveConflict.impactNothing")
|
||||
: t("dashboard.saveConflict.impactUnknown"),
|
||||
);
|
||||
const removed = [...(i?.removes ?? []), ...(i?.deletesEdited ?? [])];
|
||||
return {
|
||||
removes: removed.length > 0,
|
||||
heading: removed.length
|
||||
? t("dashboard.saveConflict.overwriteRemoves", { items: items(removed) })
|
||||
: t("dashboard.saveConflict.overwriteReplaces"),
|
||||
lines,
|
||||
};
|
||||
});
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) close();
|
||||
};
|
||||
</script>
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<any>(null);
|
||||
const closeBtn: Ref<any> = 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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <tbody> 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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
});
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
interface ConflictContext {
|
||||
store: any;
|
||||
t: TranslateFn;
|
||||
locale: Ref<string> | string;
|
||||
}
|
||||
|
||||
interface ConflictSession {
|
||||
request: DashboardConflictRequest;
|
||||
ctx: ConflictContext;
|
||||
/** Every change that lost a conflict since the dashboard was loaded, oldest first. */
|
||||
changes: I18nText[];
|
||||
onResolved: NonNullable<DashboardConflictRequest["onResolved"]>[];
|
||||
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 = <T>(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<void>) => {
|
||||
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<unknown> | 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);
|
||||
},
|
||||
);
|
||||
@@ -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[] = []) => ({
|
||||
|
||||
@@ -227,6 +227,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<PredefinedThemes />
|
||||
<ShortcutCheatsheet v-model:open="showShortcuts" />
|
||||
<PaidOverageConsentDialog v-if="config.isCloud == 'true'" />
|
||||
<DashboardSaveConflictDialog
|
||||
v-if="dashboardSaveConflict.mounted"
|
||||
:key="dashboardSaveConflict.openSeq"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { DIALOG_DEPTH_KEY } from "./dialogStacking";
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
import type { DialogProps, DialogEmits, DialogSlots } from "./ODialog.types";
|
||||
@@ -43,8 +44,8 @@ const $attrs = useAttrs();
|
||||
const parentDataTest = computed(() => $attrs["data-test"] as string | undefined);
|
||||
|
||||
// Stacking support: each nested ODialog gets a higher z-index layer
|
||||
const dialogDepth = inject<number>("o2DialogDepth", 0);
|
||||
provide("o2DialogDepth", dialogDepth + 1);
|
||||
const dialogDepth = inject<number>(DIALOG_DEPTH_KEY, 0);
|
||||
provide(DIALOG_DEPTH_KEY, dialogDepth + 1);
|
||||
|
||||
// Auto loading: an OForm nested in the body (linked via `form-id`) mirrors its
|
||||
// `isSubmitting` into this ref, so the footer Save button shows its spinner
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defineComponent, h, inject } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import {
|
||||
DIALOG_DEPTH_KEY,
|
||||
countOpenOverlays,
|
||||
provideDialogLayerAboveOpenOverlays,
|
||||
} from "./dialogStacking";
|
||||
|
||||
const overlay = (state: string) => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("role", "dialog");
|
||||
el.setAttribute("data-state", state);
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("dialogStacking", () => {
|
||||
it("counts only open dialog and drawer panels", () => {
|
||||
const root = document.createElement("div");
|
||||
root.append(overlay("open"), overlay("open"), overlay("closed"));
|
||||
expect(countOpenOverlays(root)).toBe(2);
|
||||
});
|
||||
|
||||
it("provides a dialog layer one above each open overlay", () => {
|
||||
const root = document.createElement("div");
|
||||
root.append(overlay("open"), overlay("open"));
|
||||
const Child = defineComponent({
|
||||
setup: () => () => h("span", String(inject(DIALOG_DEPTH_KEY))),
|
||||
});
|
||||
const Parent = defineComponent({
|
||||
setup() {
|
||||
provideDialogLayerAboveOpenOverlays(root);
|
||||
return () => h(Child);
|
||||
},
|
||||
});
|
||||
expect(mount(Parent).text()).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { provide } from "vue";
|
||||
|
||||
/** The inject key ODialog reads its stacking layer from (one layer per enclosing dialog). */
|
||||
export const DIALOG_DEPTH_KEY = "o2DialogDepth";
|
||||
|
||||
/** Open ODialog/ODrawer panels in the page; each holds at most one stacking layer. */
|
||||
export const countOpenOverlays = (root: ParentNode = document): number =>
|
||||
root.querySelectorAll('[role="dialog"][data-state="open"]').length;
|
||||
|
||||
/**
|
||||
* For a dialog opened from outside the component tree of whatever is already open (an app-level
|
||||
* dialog): call in the parent's setup, right before the ODialog mounts, to stack it above them.
|
||||
*/
|
||||
export const provideDialogLayerAboveOpenOverlays = (root?: ParentNode): number => {
|
||||
const depth = typeof document === "undefined" ? 0 : countOpenOverlays(root);
|
||||
provide(DIALOG_DEPTH_KEY, depth);
|
||||
return depth;
|
||||
};
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "مصدِّر GPU"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "لم يُحفظ: حفظ شخص آخر لوحة المعلومات هذه أولًا.",
|
||||
"notSavedChange": "لم يُحفظ: {change}. حفظ شخص آخر لوحة المعلومات هذه أولًا.",
|
||||
"resolve": "حل",
|
||||
"title": "تغيّرت لوحة المعلومات هذه منذ أن فتحتها",
|
||||
"savedBy": "حفظها شخص آخر {relative} ({time})",
|
||||
"savedByUnknown": "حفظها شخص آخر منذ أن فتحتها",
|
||||
"checking": "جارٍ التحقق مما تغيّر…",
|
||||
"loadFailed": "تعذّر تحميل أحدث إصدار. تعديلك لا يزال هنا. حاول مرة أخرى.",
|
||||
"savedAgain": "حفظ شخص ما مرة أخرى أثناء الحل. راجع أحدث التغييرات واختر من جديد.",
|
||||
"saveFailed": "تعذّر الحفظ: {message}. تعديلك لا يزال هنا.",
|
||||
"panelTouched": "غيّر شخص آخر لوحتك أيضًا",
|
||||
"panelUntouched": "لم يغيّر أحد لوحتك",
|
||||
"whatChanged": "ما الذي تغيّر منذ التحميل",
|
||||
"nothingChanged": "لا توجد اختلافات في اللوحات أو علامات التبويب أو المتغيرات أو الإعدادات.",
|
||||
"baseUnknown": "لم يعد الإصدار الذي حمّلته متاحًا، لذا لا يمكن عرض التغييرات.",
|
||||
"kindPanel": "لوحة",
|
||||
"kindTab": "علامة تبويب",
|
||||
"kindVariable": "متغير",
|
||||
"added": "مضاف",
|
||||
"changed": "معدّل",
|
||||
"removed": "محذوف",
|
||||
"panelCount": "اللوحات: {count}",
|
||||
"tabCount": "علامات التبويب: {count}",
|
||||
"variableCount": "المتغيرات: {count}",
|
||||
"chipAdded": "مضاف: {items}",
|
||||
"chipChanged": "معدّل: {items}",
|
||||
"chipRemoved": "محذوف: {items}",
|
||||
"mergeUnavailable": "تعذّر وضع لوحتك في أحدث إصدار. أعد التحميل أو استبدل بدلًا من ذلك.",
|
||||
"reload": "إعادة التحميل",
|
||||
"reloadHint": "يتجاهل تعديلك ويحمّل أحدث إصدار هنا.",
|
||||
"overwrite": "استبدال",
|
||||
"overwriteHint": "يستبدل حفظهم بحفظك.",
|
||||
"merge": "دمج",
|
||||
"mergeHint": "يضع لوحتك في أحدث إصدار ويعرض النتيجة قبل الحفظ.",
|
||||
"back": "رجوع",
|
||||
"retry": "إعادة المحاولة",
|
||||
"overwriteRemoves": "الاستبدال يحذف: {items}",
|
||||
"overwriteReplaces": "الاستبدال يحل محل حفظهم",
|
||||
"impactRemoves": "يحذف ما أضافه شخص آخر ({items}): {names}",
|
||||
"impactReverts": "يتراجع عن تعديلاتهم ({items}): {names}",
|
||||
"impactDeletesEdited": "يحذف ما عدّلوه ({items}): {names}",
|
||||
"impactRestores": "يستعيد ما حذفوه ({items}): {names}",
|
||||
"impactUnknown": "لا يمكن عرض تغييراتهم؛ ستُستبدل جميعها.",
|
||||
"cannotUndo": "لا يمكن التراجع عن ذلك من هنا.",
|
||||
"removeAndOverwrite": "حذف واستبدال",
|
||||
"reviewIntro": "طُبّقت لوحتك على أحدث إصدار؛ وتغييراتهم الأخرى مضمّنة. راجع النتيجة ثم احفظ أو تجاهل.",
|
||||
"reviewHeading": "لوحة المعلومات بعد الدمج",
|
||||
"tagYours": "لك",
|
||||
"tagTheirsNew": "لهم، جديد",
|
||||
"tagTheirsEdited": "لهم، معدّل",
|
||||
"discard": "تجاهل",
|
||||
"saveMerged": "حفظ الدمج",
|
||||
"toastReloaded": "تم تحميل أحدث إصدار. تم تجاهل تعديلك.",
|
||||
"toastOverwritten": "تم الاستبدال. استُبدلت التغييرات المحفوظة في {time} بتغييراتك.",
|
||||
"toastOverwrittenNoTime": "تم الاستبدال. استُبدلت تغييراتهم بتغييراتك.",
|
||||
"toastMerged": "تم حفظ الإصدار المدمج.",
|
||||
"toastMergeDiscarded": "تم تجاهل الدمج. تعديلك محفوظ هنا ولم يُحفظ بعد.",
|
||||
"yourChange": "تغييرك غير المحفوظ: {change} | تغييراتك غير المحفوظة: {change}",
|
||||
"stillUnresolved": "لم يُحل هذا التعارض بعد، لذا لم يتم الحفظ. اختر طريقة الحفظ.",
|
||||
"toastReloadedChange": "تم تحميل أحدث إصدار. تم تجاهل تغييرك: {change}",
|
||||
"renamedFrom": "{name} (الاسم السابق: {from})",
|
||||
"kindSettings": "الإعدادات",
|
||||
"settingTitle": "الاسم",
|
||||
"settingDescription": "الوصف",
|
||||
"settingTimeRange": "النطاق الزمني الافتراضي",
|
||||
"settingDynamicFilters": "عوامل التصفية الديناميكية",
|
||||
"settingOther": "إعدادات أخرى",
|
||||
"impactSettings": "يستبدل إعدادات لوحة المعلومات التي غيّروها: {settings}",
|
||||
"impactNothing": "يُستبدل حفظهم بحفظك.",
|
||||
"compareField": "لوحتك",
|
||||
"compareTheirs": "إصدارهم",
|
||||
"compareYours": "إصدارك",
|
||||
"compareTitle": "العنوان",
|
||||
"compareOther": "الاستعلام والإعدادات",
|
||||
"compareSame": "مطابق لإصدارهم",
|
||||
"compareDifferent": "مختلف",
|
||||
"compareRemoved": "حذفوها",
|
||||
"change": {
|
||||
"editedJson": "تعديل JSON للوحة المعلومات",
|
||||
"editedPanel": "تعديل اللوحة \"{name}\"",
|
||||
"addedPanel": "إضافة اللوحة \"{name}\"",
|
||||
"movedPanels": "نقل اللوحات أو تغيير حجمها",
|
||||
"deletedPanel": "حذف اللوحة \"{name}\"",
|
||||
"movedPanel": "نقل اللوحة \"{name}\" إلى علامة تبويب أخرى",
|
||||
"duplicatedPanel": "تكرار اللوحة \"{name}\"",
|
||||
"addedTab": "إضافة علامة التبويب \"{name}\"",
|
||||
"renamedTab": "إعادة تسمية علامة التبويب \"{from}\" إلى \"{to}\"",
|
||||
"reorderedTabs": "إعادة ترتيب علامات التبويب",
|
||||
"deletedTab": "حذف علامة التبويب \"{name}\"",
|
||||
"editedVariable": "تعديل المتغير \"{name}\"",
|
||||
"addedVariable": "إضافة المتغير \"{name}\"",
|
||||
"reorderedVariables": "إعادة ترتيب المتغيرات",
|
||||
"deletedVariable": "حذف المتغير \"{name}\"",
|
||||
"changedSettings": "تغيير إعدادات لوحة المعلومات"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "تعديل التخطيط",
|
||||
"edit": "تعديل",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU-Exporter"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Nicht gespeichert: Jemand anderes hat dieses Dashboard zuerst gespeichert.",
|
||||
"notSavedChange": "Nicht gespeichert: {change}. Jemand anderes hat dieses Dashboard zuerst gespeichert.",
|
||||
"resolve": "Lösen",
|
||||
"title": "Dieses Dashboard wurde geändert, seit Sie es geöffnet haben",
|
||||
"savedBy": "Von jemand anderem gespeichert {relative} ({time})",
|
||||
"savedByUnknown": "Seit dem Öffnen von jemand anderem gespeichert",
|
||||
"checking": "Änderungen werden geprüft…",
|
||||
"loadFailed": "Die neueste Version konnte nicht geladen werden. Ihre Bearbeitung ist noch vorhanden. Versuchen Sie es erneut.",
|
||||
"savedAgain": "Während Sie den Konflikt gelöst haben, hat jemand erneut gespeichert. Prüfen Sie die neuesten Änderungen und wählen Sie erneut.",
|
||||
"saveFailed": "Speichern fehlgeschlagen: {message}. Ihre Bearbeitung ist noch vorhanden.",
|
||||
"panelTouched": "Ihr Panel wurde ebenfalls geändert",
|
||||
"panelUntouched": "Ihr Panel wurde nicht geändert",
|
||||
"whatChanged": "Was sich seit dem Laden geändert hat",
|
||||
"nothingChanged": "Keine Unterschiede bei Panels, Tabs, Variablen oder Einstellungen.",
|
||||
"baseUnknown": "Die geladene Version ist nicht mehr verfügbar, daher können die Änderungen nicht aufgelistet werden.",
|
||||
"kindPanel": "Panel",
|
||||
"kindTab": "Tab",
|
||||
"kindVariable": "Variable",
|
||||
"added": "Hinzugefügt",
|
||||
"changed": "Geändert",
|
||||
"removed": "Entfernt",
|
||||
"panelCount": "{count} Panel | {count} Panels",
|
||||
"tabCount": "{count} Tab | {count} Tabs",
|
||||
"variableCount": "{count} Variable | {count} Variablen",
|
||||
"chipAdded": "{items} hinzugefügt",
|
||||
"chipChanged": "{items} geändert",
|
||||
"chipRemoved": "{items} entfernt",
|
||||
"mergeUnavailable": "Ihr Panel konnte nicht in die neueste Version eingefügt werden. Laden Sie neu oder überschreiben Sie stattdessen.",
|
||||
"reload": "Neu laden",
|
||||
"reloadHint": "Verwirft Ihre Bearbeitung und lädt hier die neueste Version.",
|
||||
"overwrite": "Überschreiben",
|
||||
"overwriteHint": "Ersetzt deren Speicherung durch Ihre.",
|
||||
"merge": "Zusammenführen",
|
||||
"mergeHint": "Setzt Ihr Panel in die neueste Version ein und zeigt das Ergebnis vor dem Speichern.",
|
||||
"back": "Zurück",
|
||||
"retry": "Erneut versuchen",
|
||||
"overwriteRemoves": "Überschreiben entfernt {items}",
|
||||
"overwriteReplaces": "Überschreiben ersetzt deren Speicherung",
|
||||
"impactRemoves": "Entfernt {items}, die jemand anderes hinzugefügt hat: {names}",
|
||||
"impactReverts": "Setzt {items} zurück, die bearbeitet wurden: {names}",
|
||||
"impactDeletesEdited": "Löscht {items}, die bearbeitet wurden: {names}",
|
||||
"impactRestores": "Stellt {items} wieder her, die entfernt wurden: {names}",
|
||||
"impactUnknown": "Deren Änderungen können nicht aufgelistet werden; alle werden ersetzt.",
|
||||
"cannotUndo": "Dies kann hier nicht rückgängig gemacht werden.",
|
||||
"removeAndOverwrite": "Entfernen und überschreiben",
|
||||
"reviewIntro": "Ihr Panel wurde auf die neueste Version angewendet; deren andere Änderungen sind enthalten. Prüfen Sie das Ergebnis und speichern oder verwerfen Sie es.",
|
||||
"reviewHeading": "Dashboard nach dem Zusammenführen",
|
||||
"tagYours": "Ihres",
|
||||
"tagTheirsNew": "Fremd, neu",
|
||||
"tagTheirsEdited": "Fremd, bearbeitet",
|
||||
"discard": "Verwerfen",
|
||||
"saveMerged": "Zusammengeführt speichern",
|
||||
"toastReloaded": "Neueste Version geladen. Ihre Bearbeitung wurde verworfen.",
|
||||
"toastOverwritten": "Überschrieben. Die um {time} gespeicherten Änderungen wurden durch Ihre ersetzt.",
|
||||
"toastOverwrittenNoTime": "Überschrieben. Deren Änderungen wurden durch Ihre ersetzt.",
|
||||
"toastMerged": "Zusammengeführte Version gespeichert.",
|
||||
"toastMergeDiscarded": "Zusammenführen verworfen. Ihre Bearbeitung bleibt erhalten und ist noch nicht gespeichert.",
|
||||
"yourChange": "Ihre nicht gespeicherte Änderung: {change} | Ihre nicht gespeicherten Änderungen: {change}",
|
||||
"stillUnresolved": "Dieser Konflikt ist noch ungelöst, daher wurde nicht gespeichert. Wählen Sie, wie gespeichert werden soll.",
|
||||
"toastReloadedChange": "Neueste Version geladen. Ihre Änderung wurde verworfen: {change}",
|
||||
"renamedFrom": "{name} (umbenannt von {from})",
|
||||
"kindSettings": "Einstellungen",
|
||||
"settingTitle": "Name",
|
||||
"settingDescription": "Beschreibung",
|
||||
"settingTimeRange": "Standardzeitraum",
|
||||
"settingDynamicFilters": "dynamische Filter",
|
||||
"settingOther": "weitere Einstellungen",
|
||||
"impactSettings": "Ersetzt die von ihnen geänderten Dashboard-Einstellungen: {settings}",
|
||||
"impactNothing": "Deren Speicherung wird durch Ihre ersetzt.",
|
||||
"compareField": "Ihr Panel",
|
||||
"compareTheirs": "Deren Version",
|
||||
"compareYours": "Ihre Version",
|
||||
"compareTitle": "Titel",
|
||||
"compareOther": "Abfrage und Einstellungen",
|
||||
"compareSame": "Wie deren Version",
|
||||
"compareDifferent": "Abweichend",
|
||||
"compareRemoved": "Von ihnen gelöscht",
|
||||
"change": {
|
||||
"editedJson": "Dashboard-JSON bearbeitet",
|
||||
"editedPanel": "Panel \"{name}\" bearbeitet",
|
||||
"addedPanel": "Panel \"{name}\" hinzugefügt",
|
||||
"movedPanels": "Panels verschoben oder in der Größe geändert",
|
||||
"deletedPanel": "Panel \"{name}\" gelöscht",
|
||||
"movedPanel": "Panel \"{name}\" in einen anderen Tab verschoben",
|
||||
"duplicatedPanel": "Panel \"{name}\" dupliziert",
|
||||
"addedTab": "Tab \"{name}\" hinzugefügt",
|
||||
"renamedTab": "Tab \"{from}\" in \"{to}\" umbenannt",
|
||||
"reorderedTabs": "Tabs neu angeordnet",
|
||||
"deletedTab": "Tab \"{name}\" gelöscht",
|
||||
"editedVariable": "Variable \"{name}\" bearbeitet",
|
||||
"addedVariable": "Variable \"{name}\" hinzugefügt",
|
||||
"reorderedVariables": "Variablen neu angeordnet",
|
||||
"deletedVariable": "Variable \"{name}\" gelöscht",
|
||||
"changedSettings": "Dashboard-Einstellungen geändert"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Layout bearbeiten",
|
||||
"edit": "Bearbeiten",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU exporter"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Not saved: someone else saved this dashboard first.",
|
||||
"notSavedChange": "Not saved: {change}. Someone else saved this dashboard first.",
|
||||
"resolve": "Resolve",
|
||||
"title": "This dashboard changed since you opened it",
|
||||
"savedBy": "Saved by someone else {relative} ({time})",
|
||||
"savedByUnknown": "Saved by someone else since you opened it",
|
||||
"checking": "Checking what changed…",
|
||||
"loadFailed": "Could not load the latest version. Your edit is still here. Try again.",
|
||||
"savedAgain": "Someone saved again while you were resolving. Review the latest changes and choose again.",
|
||||
"saveFailed": "Could not save: {message}. Your edit is still here.",
|
||||
"panelTouched": "Your panel was also changed by them",
|
||||
"panelUntouched": "Your panel was not changed by them",
|
||||
"whatChanged": "What changed since you loaded",
|
||||
"nothingChanged": "No differences found in panels, tabs, variables or settings.",
|
||||
"baseUnknown": "The version you loaded is no longer available, so the changes cannot be listed.",
|
||||
"kindPanel": "Panel",
|
||||
"kindTab": "Tab",
|
||||
"kindVariable": "Variable",
|
||||
"added": "Added",
|
||||
"changed": "Changed",
|
||||
"removed": "Removed",
|
||||
"panelCount": "{count} panel | {count} panels",
|
||||
"tabCount": "{count} tab | {count} tabs",
|
||||
"variableCount": "{count} variable | {count} variables",
|
||||
"chipAdded": "{items} added",
|
||||
"chipChanged": "{items} changed",
|
||||
"chipRemoved": "{items} removed",
|
||||
"mergeUnavailable": "Your panel could not be placed on the latest version. Reload or overwrite instead.",
|
||||
"reload": "Reload",
|
||||
"reloadHint": "Discards your edit and loads the latest version here.",
|
||||
"overwrite": "Overwrite",
|
||||
"overwriteHint": "Replaces their save with yours.",
|
||||
"merge": "Merge",
|
||||
"mergeHint": "Puts your panel onto the latest version and shows the result before you save.",
|
||||
"back": "Back",
|
||||
"retry": "Retry",
|
||||
"overwriteRemoves": "Overwrite removes {items}",
|
||||
"overwriteReplaces": "Overwrite replaces their save",
|
||||
"impactRemoves": "Removes {items} that someone else added: {names}",
|
||||
"impactReverts": "Reverts {items} they edited: {names}",
|
||||
"impactDeletesEdited": "Deletes {items} they edited: {names}",
|
||||
"impactRestores": "Restores {items} they removed: {names}",
|
||||
"impactUnknown": "Their changes cannot be listed; all of them are replaced.",
|
||||
"cannotUndo": "This cannot be undone from here.",
|
||||
"removeAndOverwrite": "Remove and overwrite",
|
||||
"reviewIntro": "Your panel was applied on top of the latest version; their other changes are included. Review it, then save or discard.",
|
||||
"reviewHeading": "Dashboard after merge",
|
||||
"tagYours": "Yours",
|
||||
"tagTheirsNew": "Theirs, new",
|
||||
"tagTheirsEdited": "Theirs, edited",
|
||||
"discard": "Discard",
|
||||
"saveMerged": "Save merged",
|
||||
"toastReloaded": "Loaded the latest version. Your edit was discarded.",
|
||||
"toastOverwritten": "Overwritten. The changes saved at {time} were replaced by yours.",
|
||||
"toastOverwrittenNoTime": "Overwritten. Their changes were replaced by yours.",
|
||||
"toastMerged": "Merged version saved.",
|
||||
"toastMergeDiscarded": "Merge discarded. Your edit is kept and still not saved.",
|
||||
"yourChange": "Your unsaved change: {change} | Your unsaved changes: {change}",
|
||||
"stillUnresolved": "This conflict is still unresolved, so your save was not applied. Choose how to save it.",
|
||||
"toastReloadedChange": "Loaded the latest version. Discarded your change: {change}",
|
||||
"renamedFrom": "{name} (renamed from {from})",
|
||||
"kindSettings": "Settings",
|
||||
"settingTitle": "name",
|
||||
"settingDescription": "description",
|
||||
"settingTimeRange": "default time range",
|
||||
"settingDynamicFilters": "dynamic filters",
|
||||
"settingOther": "other settings",
|
||||
"impactSettings": "Replaces the dashboard settings they changed: {settings}",
|
||||
"impactNothing": "Their save is replaced by yours.",
|
||||
"compareField": "Your panel",
|
||||
"compareTheirs": "Their version",
|
||||
"compareYours": "Your version",
|
||||
"compareTitle": "Title",
|
||||
"compareOther": "Query and settings",
|
||||
"compareSame": "Same as theirs",
|
||||
"compareDifferent": "Different",
|
||||
"compareRemoved": "Deleted by them",
|
||||
"change": {
|
||||
"editedJson": "Edited the dashboard JSON",
|
||||
"editedPanel": "Edited panel \"{name}\"",
|
||||
"addedPanel": "Added panel \"{name}\"",
|
||||
"movedPanels": "Moved or resized panels",
|
||||
"deletedPanel": "Deleted panel \"{name}\"",
|
||||
"movedPanel": "Moved panel \"{name}\" to another tab",
|
||||
"duplicatedPanel": "Duplicated panel \"{name}\"",
|
||||
"addedTab": "Added tab \"{name}\"",
|
||||
"renamedTab": "Renamed tab \"{from}\" to \"{to}\"",
|
||||
"reorderedTabs": "Reordered tabs",
|
||||
"deletedTab": "Deleted tab \"{name}\"",
|
||||
"editedVariable": "Edited variable \"{name}\"",
|
||||
"addedVariable": "Added variable \"{name}\"",
|
||||
"reorderedVariables": "Reordered variables",
|
||||
"deletedVariable": "Deleted variable \"{name}\"",
|
||||
"changedSettings": "Changed dashboard settings"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Edit layout",
|
||||
"edit": "Edit",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "Exportador de GPU"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Sin guardar: otra persona guardó este panel de control antes.",
|
||||
"notSavedChange": "Sin guardar: {change}. Otra persona guardó este panel de control antes.",
|
||||
"resolve": "Resolver",
|
||||
"title": "Este panel de control cambió desde que lo abriste",
|
||||
"savedBy": "Guardado por otra persona {relative} ({time})",
|
||||
"savedByUnknown": "Guardado por otra persona desde que lo abriste",
|
||||
"checking": "Comprobando qué cambió…",
|
||||
"loadFailed": "No se pudo cargar la versión más reciente. Tu edición sigue aquí. Inténtalo de nuevo.",
|
||||
"savedAgain": "Alguien guardó de nuevo mientras resolvías. Revisa los últimos cambios y vuelve a elegir.",
|
||||
"saveFailed": "No se pudo guardar: {message}. Tu edición sigue aquí.",
|
||||
"panelTouched": "Tu panel también fue modificado por otra persona",
|
||||
"panelUntouched": "Tu panel no fue modificado por otra persona",
|
||||
"whatChanged": "Qué cambió desde que lo cargaste",
|
||||
"nothingChanged": "No hay diferencias en paneles, pestañas, variables ni ajustes.",
|
||||
"baseUnknown": "La versión que cargaste ya no está disponible, así que no se pueden listar los cambios.",
|
||||
"kindPanel": "Panel",
|
||||
"kindTab": "Pestaña",
|
||||
"kindVariable": "Variable",
|
||||
"added": "Añadido",
|
||||
"changed": "Cambiado",
|
||||
"removed": "Eliminado",
|
||||
"panelCount": "{count} panel | {count} paneles",
|
||||
"tabCount": "{count} pestaña | {count} pestañas",
|
||||
"variableCount": "{count} variable | {count} variables",
|
||||
"chipAdded": "{items} añadidos",
|
||||
"chipChanged": "{items} cambiados",
|
||||
"chipRemoved": "{items} eliminados",
|
||||
"mergeUnavailable": "No se pudo colocar tu panel en la versión más reciente. Recarga o sobrescribe.",
|
||||
"reload": "Recargar",
|
||||
"reloadHint": "Descarta tu edición y carga aquí la versión más reciente.",
|
||||
"overwrite": "Sobrescribir",
|
||||
"overwriteHint": "Reemplaza su guardado por el tuyo.",
|
||||
"merge": "Combinar",
|
||||
"mergeHint": "Coloca tu panel en la versión más reciente y muestra el resultado antes de guardar.",
|
||||
"back": "Atrás",
|
||||
"retry": "Reintentar",
|
||||
"overwriteRemoves": "Sobrescribir elimina {items}",
|
||||
"overwriteReplaces": "Sobrescribir reemplaza su guardado",
|
||||
"impactRemoves": "Elimina {items} que añadió otra persona: {names}",
|
||||
"impactReverts": "Revierte {items} que editaron: {names}",
|
||||
"impactDeletesEdited": "Elimina {items} que editaron: {names}",
|
||||
"impactRestores": "Restaura {items} que eliminaron: {names}",
|
||||
"impactUnknown": "Sus cambios no se pueden listar; se reemplazan todos.",
|
||||
"cannotUndo": "Esto no se puede deshacer desde aquí.",
|
||||
"removeAndOverwrite": "Eliminar y sobrescribir",
|
||||
"reviewIntro": "Tu panel se aplicó sobre la versión más reciente; sus otros cambios están incluidos. Revísalo y luego guarda o descarta.",
|
||||
"reviewHeading": "Panel de control tras combinar",
|
||||
"tagYours": "Tuyo",
|
||||
"tagTheirsNew": "Suyo, nuevo",
|
||||
"tagTheirsEdited": "Suyo, editado",
|
||||
"discard": "Descartar",
|
||||
"saveMerged": "Guardar combinación",
|
||||
"toastReloaded": "Se cargó la versión más reciente. Tu edición se descartó.",
|
||||
"toastOverwritten": "Sobrescrito. Los cambios guardados a las {time} se reemplazaron por los tuyos.",
|
||||
"toastOverwrittenNoTime": "Sobrescrito. Sus cambios se reemplazaron por los tuyos.",
|
||||
"toastMerged": "Versión combinada guardada.",
|
||||
"toastMergeDiscarded": "Combinación descartada. Tu edición se conserva y aún no está guardada.",
|
||||
"yourChange": "Tu cambio sin guardar: {change} | Tus cambios sin guardar: {change}",
|
||||
"stillUnresolved": "Este conflicto sigue sin resolver, así que no se guardó. Elige cómo guardarlo.",
|
||||
"toastReloadedChange": "Se cargó la versión más reciente. Se descartó tu cambio: {change}",
|
||||
"renamedFrom": "{name} (antes {from})",
|
||||
"kindSettings": "Ajustes",
|
||||
"settingTitle": "nombre",
|
||||
"settingDescription": "descripción",
|
||||
"settingTimeRange": "intervalo de tiempo predeterminado",
|
||||
"settingDynamicFilters": "filtros dinámicos",
|
||||
"settingOther": "otros ajustes",
|
||||
"impactSettings": "Reemplaza los ajustes del panel de control que cambiaron: {settings}",
|
||||
"impactNothing": "Su guardado se reemplaza por el tuyo.",
|
||||
"compareField": "Tu panel",
|
||||
"compareTheirs": "Su versión",
|
||||
"compareYours": "Tu versión",
|
||||
"compareTitle": "Título",
|
||||
"compareOther": "Consulta y ajustes",
|
||||
"compareSame": "Igual que la suya",
|
||||
"compareDifferent": "Diferente",
|
||||
"compareRemoved": "Eliminado por otra persona",
|
||||
"change": {
|
||||
"editedJson": "Editaste el JSON del panel de control",
|
||||
"editedPanel": "Editaste el panel \"{name}\"",
|
||||
"addedPanel": "Añadiste el panel \"{name}\"",
|
||||
"movedPanels": "Moviste o cambiaste el tamaño de paneles",
|
||||
"deletedPanel": "Eliminaste el panel \"{name}\"",
|
||||
"movedPanel": "Moviste el panel \"{name}\" a otra pestaña",
|
||||
"duplicatedPanel": "Duplicaste el panel \"{name}\"",
|
||||
"addedTab": "Añadiste la pestaña \"{name}\"",
|
||||
"renamedTab": "Cambiaste el nombre de la pestaña \"{from}\" a \"{to}\"",
|
||||
"reorderedTabs": "Reordenaste las pestañas",
|
||||
"deletedTab": "Eliminaste la pestaña \"{name}\"",
|
||||
"editedVariable": "Editaste la variable \"{name}\"",
|
||||
"addedVariable": "Añadiste la variable \"{name}\"",
|
||||
"reorderedVariables": "Reordenaste las variables",
|
||||
"deletedVariable": "Eliminaste la variable \"{name}\"",
|
||||
"changedSettings": "Cambiaste los ajustes del panel de control"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Editar diseño",
|
||||
"edit": "Editar",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "Exportateur GPU"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Non enregistré : quelqu'un d'autre a enregistré ce tableau de bord avant vous.",
|
||||
"notSavedChange": "Non enregistré : {change}. Quelqu'un d'autre a enregistré ce tableau de bord avant vous.",
|
||||
"resolve": "Résoudre",
|
||||
"title": "Ce tableau de bord a changé depuis que vous l'avez ouvert",
|
||||
"savedBy": "Enregistré par quelqu'un d'autre {relative} ({time})",
|
||||
"savedByUnknown": "Enregistré par quelqu'un d'autre depuis que vous l'avez ouvert",
|
||||
"checking": "Vérification des changements…",
|
||||
"loadFailed": "Impossible de charger la dernière version. Votre modification est toujours là. Réessayez.",
|
||||
"savedAgain": "Quelqu'un a de nouveau enregistré pendant la résolution. Examinez les derniers changements et choisissez à nouveau.",
|
||||
"saveFailed": "Échec de l'enregistrement : {message}. Votre modification est toujours là.",
|
||||
"panelTouched": "Votre panneau a aussi été modifié par quelqu'un d'autre",
|
||||
"panelUntouched": "Votre panneau n'a pas été modifié par quelqu'un d'autre",
|
||||
"whatChanged": "Ce qui a changé depuis le chargement",
|
||||
"nothingChanged": "Aucune différence dans les panneaux, onglets, variables ou paramètres.",
|
||||
"baseUnknown": "La version chargée n'est plus disponible, les changements ne peuvent donc pas être listés.",
|
||||
"kindPanel": "Panneau",
|
||||
"kindTab": "Onglet",
|
||||
"kindVariable": "Variable",
|
||||
"added": "Ajouté",
|
||||
"changed": "Modifié",
|
||||
"removed": "Supprimé",
|
||||
"panelCount": "{count} panneau | {count} panneaux",
|
||||
"tabCount": "{count} onglet | {count} onglets",
|
||||
"variableCount": "{count} variable | {count} variables",
|
||||
"chipAdded": "{items} ajoutés",
|
||||
"chipChanged": "{items} modifiés",
|
||||
"chipRemoved": "{items} supprimés",
|
||||
"mergeUnavailable": "Votre panneau n'a pas pu être placé sur la dernière version. Rechargez ou écrasez à la place.",
|
||||
"reload": "Recharger",
|
||||
"reloadHint": "Abandonne votre modification et charge ici la dernière version.",
|
||||
"overwrite": "Écraser",
|
||||
"overwriteHint": "Remplace leur enregistrement par le vôtre.",
|
||||
"merge": "Fusionner",
|
||||
"mergeHint": "Place votre panneau sur la dernière version et affiche le résultat avant l'enregistrement.",
|
||||
"back": "Retour",
|
||||
"retry": "Réessayer",
|
||||
"overwriteRemoves": "Écraser supprime {items}",
|
||||
"overwriteReplaces": "Écraser remplace leur enregistrement",
|
||||
"impactRemoves": "Supprime {items} ajoutés par quelqu'un d'autre : {names}",
|
||||
"impactReverts": "Annule {items} qu'ils ont modifiés : {names}",
|
||||
"impactDeletesEdited": "Supprime {items} qu'ils ont modifiés : {names}",
|
||||
"impactRestores": "Restaure {items} qu'ils ont supprimés : {names}",
|
||||
"impactUnknown": "Leurs changements ne peuvent pas être listés ; ils sont tous remplacés.",
|
||||
"cannotUndo": "Cette action est irréversible ici.",
|
||||
"removeAndOverwrite": "Supprimer et écraser",
|
||||
"reviewIntro": "Votre panneau a été appliqué sur la dernière version ; leurs autres changements sont inclus. Vérifiez, puis enregistrez ou abandonnez.",
|
||||
"reviewHeading": "Tableau de bord après fusion",
|
||||
"tagYours": "Le vôtre",
|
||||
"tagTheirsNew": "Le leur, nouveau",
|
||||
"tagTheirsEdited": "Le leur, modifié",
|
||||
"discard": "Abandonner",
|
||||
"saveMerged": "Enregistrer la fusion",
|
||||
"toastReloaded": "Dernière version chargée. Votre modification a été abandonnée.",
|
||||
"toastOverwritten": "Écrasé. Les changements enregistrés à {time} ont été remplacés par les vôtres.",
|
||||
"toastOverwrittenNoTime": "Écrasé. Leurs changements ont été remplacés par les vôtres.",
|
||||
"toastMerged": "Version fusionnée enregistrée.",
|
||||
"toastMergeDiscarded": "Fusion abandonnée. Votre modification est conservée et n'est pas encore enregistrée.",
|
||||
"yourChange": "Votre modification non enregistrée : {change} | Vos modifications non enregistrées : {change}",
|
||||
"stillUnresolved": "Ce conflit n'est toujours pas résolu, l'enregistrement n'a donc pas été appliqué. Choisissez comment enregistrer.",
|
||||
"toastReloadedChange": "Dernière version chargée. Votre modification a été abandonnée : {change}",
|
||||
"renamedFrom": "{name} (anciennement {from})",
|
||||
"kindSettings": "Paramètres",
|
||||
"settingTitle": "nom",
|
||||
"settingDescription": "description",
|
||||
"settingTimeRange": "plage horaire par défaut",
|
||||
"settingDynamicFilters": "filtres dynamiques",
|
||||
"settingOther": "autres paramètres",
|
||||
"impactSettings": "Remplace les paramètres du tableau de bord qu'ils ont modifiés : {settings}",
|
||||
"impactNothing": "Leur enregistrement est remplacé par le vôtre.",
|
||||
"compareField": "Votre panneau",
|
||||
"compareTheirs": "Leur version",
|
||||
"compareYours": "Votre version",
|
||||
"compareTitle": "Titre",
|
||||
"compareOther": "Requête et paramètres",
|
||||
"compareSame": "Identique à la leur",
|
||||
"compareDifferent": "Différent",
|
||||
"compareRemoved": "Supprimé par eux",
|
||||
"change": {
|
||||
"editedJson": "JSON du tableau de bord modifié",
|
||||
"editedPanel": "Panneau \"{name}\" modifié",
|
||||
"addedPanel": "Panneau \"{name}\" ajouté",
|
||||
"movedPanels": "Panneaux déplacés ou redimensionnés",
|
||||
"deletedPanel": "Panneau \"{name}\" supprimé",
|
||||
"movedPanel": "Panneau \"{name}\" déplacé vers un autre onglet",
|
||||
"duplicatedPanel": "Panneau \"{name}\" dupliqué",
|
||||
"addedTab": "Onglet \"{name}\" ajouté",
|
||||
"renamedTab": "Onglet \"{from}\" renommé en \"{to}\"",
|
||||
"reorderedTabs": "Onglets réordonnés",
|
||||
"deletedTab": "Onglet \"{name}\" supprimé",
|
||||
"editedVariable": "Variable \"{name}\" modifiée",
|
||||
"addedVariable": "Variable \"{name}\" ajoutée",
|
||||
"reorderedVariables": "Variables réordonnées",
|
||||
"deletedVariable": "Variable \"{name}\" supprimée",
|
||||
"changedSettings": "Paramètres du tableau de bord modifiés"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Modifier la disposition",
|
||||
"edit": "Modifier",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "Esportatore GPU"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Non salvato: qualcun altro ha salvato prima questa dashboard.",
|
||||
"notSavedChange": "Non salvato: {change}. Qualcun altro ha salvato prima questa dashboard.",
|
||||
"resolve": "Risolvi",
|
||||
"title": "Questa dashboard è cambiata da quando l'hai aperta",
|
||||
"savedBy": "Salvata da qualcun altro {relative} ({time})",
|
||||
"savedByUnknown": "Salvata da qualcun altro da quando l'hai aperta",
|
||||
"checking": "Verifica delle modifiche…",
|
||||
"loadFailed": "Impossibile caricare l'ultima versione. La tua modifica è ancora qui. Riprova.",
|
||||
"savedAgain": "Qualcuno ha salvato di nuovo mentre risolvevi. Rivedi le ultime modifiche e scegli di nuovo.",
|
||||
"saveFailed": "Impossibile salvare: {message}. La tua modifica è ancora qui.",
|
||||
"panelTouched": "Anche il tuo pannello è stato modificato da altri",
|
||||
"panelUntouched": "Il tuo pannello non è stato modificato da altri",
|
||||
"whatChanged": "Cosa è cambiato dal caricamento",
|
||||
"nothingChanged": "Nessuna differenza in pannelli, schede, variabili o impostazioni.",
|
||||
"baseUnknown": "La versione caricata non è più disponibile, quindi le modifiche non possono essere elencate.",
|
||||
"kindPanel": "Pannello",
|
||||
"kindTab": "Scheda",
|
||||
"kindVariable": "Variabile",
|
||||
"added": "Aggiunto",
|
||||
"changed": "Modificato",
|
||||
"removed": "Rimosso",
|
||||
"panelCount": "{count} pannello | {count} pannelli",
|
||||
"tabCount": "{count} scheda | {count} schede",
|
||||
"variableCount": "{count} variabile | {count} variabili",
|
||||
"chipAdded": "{items} aggiunti",
|
||||
"chipChanged": "{items} modificati",
|
||||
"chipRemoved": "{items} rimossi",
|
||||
"mergeUnavailable": "Impossibile inserire il tuo pannello nell'ultima versione. Ricarica o sovrascrivi.",
|
||||
"reload": "Ricarica",
|
||||
"reloadHint": "Scarta la tua modifica e carica qui l'ultima versione.",
|
||||
"overwrite": "Sovrascrivi",
|
||||
"overwriteHint": "Sostituisce il loro salvataggio con il tuo.",
|
||||
"merge": "Unisci",
|
||||
"mergeHint": "Inserisce il tuo pannello nell'ultima versione e mostra il risultato prima di salvare.",
|
||||
"back": "Indietro",
|
||||
"retry": "Riprova",
|
||||
"overwriteRemoves": "Sovrascrivere rimuove {items}",
|
||||
"overwriteReplaces": "Sovrascrivere sostituisce il loro salvataggio",
|
||||
"impactRemoves": "Rimuove {items} aggiunti da altri: {names}",
|
||||
"impactReverts": "Annulla {items} che hanno modificato: {names}",
|
||||
"impactDeletesEdited": "Elimina {items} che hanno modificato: {names}",
|
||||
"impactRestores": "Ripristina {items} che hanno rimosso: {names}",
|
||||
"impactUnknown": "Le loro modifiche non possono essere elencate; vengono sostituite tutte.",
|
||||
"cannotUndo": "Non è possibile annullare da qui.",
|
||||
"removeAndOverwrite": "Rimuovi e sovrascrivi",
|
||||
"reviewIntro": "Il tuo pannello è stato applicato sull'ultima versione; le loro altre modifiche sono incluse. Controlla, poi salva o scarta.",
|
||||
"reviewHeading": "Dashboard dopo l'unione",
|
||||
"tagYours": "Tuo",
|
||||
"tagTheirsNew": "Loro, nuovo",
|
||||
"tagTheirsEdited": "Loro, modificato",
|
||||
"discard": "Scarta",
|
||||
"saveMerged": "Salva unione",
|
||||
"toastReloaded": "Ultima versione caricata. La tua modifica è stata scartata.",
|
||||
"toastOverwritten": "Sovrascritto. Le modifiche salvate alle {time} sono state sostituite dalle tue.",
|
||||
"toastOverwrittenNoTime": "Sovrascritto. Le loro modifiche sono state sostituite dalle tue.",
|
||||
"toastMerged": "Versione unita salvata.",
|
||||
"toastMergeDiscarded": "Unione scartata. La tua modifica è conservata e non ancora salvata.",
|
||||
"yourChange": "La tua modifica non salvata: {change} | Le tue modifiche non salvate: {change}",
|
||||
"stillUnresolved": "Questo conflitto non è ancora risolto, quindi il salvataggio non è stato applicato. Scegli come salvare.",
|
||||
"toastReloadedChange": "Ultima versione caricata. La tua modifica è stata scartata: {change}",
|
||||
"renamedFrom": "{name} (prima {from})",
|
||||
"kindSettings": "Impostazioni",
|
||||
"settingTitle": "nome",
|
||||
"settingDescription": "descrizione",
|
||||
"settingTimeRange": "intervallo di tempo predefinito",
|
||||
"settingDynamicFilters": "filtri dinamici",
|
||||
"settingOther": "altre impostazioni",
|
||||
"impactSettings": "Sostituisce le impostazioni della dashboard che hanno modificato: {settings}",
|
||||
"impactNothing": "Il loro salvataggio viene sostituito dal tuo.",
|
||||
"compareField": "Il tuo pannello",
|
||||
"compareTheirs": "La loro versione",
|
||||
"compareYours": "La tua versione",
|
||||
"compareTitle": "Titolo",
|
||||
"compareOther": "Query e impostazioni",
|
||||
"compareSame": "Uguale alla loro",
|
||||
"compareDifferent": "Diverso",
|
||||
"compareRemoved": "Eliminato da loro",
|
||||
"change": {
|
||||
"editedJson": "JSON della dashboard modificato",
|
||||
"editedPanel": "Pannello \"{name}\" modificato",
|
||||
"addedPanel": "Pannello \"{name}\" aggiunto",
|
||||
"movedPanels": "Pannelli spostati o ridimensionati",
|
||||
"deletedPanel": "Pannello \"{name}\" eliminato",
|
||||
"movedPanel": "Pannello \"{name}\" spostato in un'altra scheda",
|
||||
"duplicatedPanel": "Pannello \"{name}\" duplicato",
|
||||
"addedTab": "Scheda \"{name}\" aggiunta",
|
||||
"renamedTab": "Scheda \"{from}\" rinominata in \"{to}\"",
|
||||
"reorderedTabs": "Schede riordinate",
|
||||
"deletedTab": "Scheda \"{name}\" eliminata",
|
||||
"editedVariable": "Variabile \"{name}\" modificata",
|
||||
"addedVariable": "Variabile \"{name}\" aggiunta",
|
||||
"reorderedVariables": "Variabili riordinate",
|
||||
"deletedVariable": "Variabile \"{name}\" eliminata",
|
||||
"changedSettings": "Impostazioni della dashboard modificate"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Modifica layout",
|
||||
"edit": "Modifica",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU エクスポーター"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "未保存: 他のユーザーが先にこのダッシュボードを保存しました。",
|
||||
"notSavedChange": "未保存: {change}。他のユーザーが先にこのダッシュボードを保存しました。",
|
||||
"resolve": "解決",
|
||||
"title": "このダッシュボードは開いた後に変更されました",
|
||||
"savedBy": "{relative}に他のユーザーが保存しました({time})",
|
||||
"savedByUnknown": "開いた後に他のユーザーが保存しました",
|
||||
"checking": "変更内容を確認しています…",
|
||||
"loadFailed": "最新バージョンを読み込めませんでした。編集内容は残っています。もう一度お試しください。",
|
||||
"savedAgain": "解決中に別のユーザーが再度保存しました。最新の変更を確認して、もう一度選択してください。",
|
||||
"saveFailed": "保存できませんでした: {message}。編集内容は残っています。",
|
||||
"panelTouched": "あなたのパネルも他のユーザーに変更されています",
|
||||
"panelUntouched": "あなたのパネルは他のユーザーに変更されていません",
|
||||
"whatChanged": "読み込み後の変更内容",
|
||||
"nothingChanged": "パネル、タブ、変数、設定に違いはありません。",
|
||||
"baseUnknown": "読み込んだバージョンが利用できないため、変更内容を表示できません。",
|
||||
"kindPanel": "パネル",
|
||||
"kindTab": "タブ",
|
||||
"kindVariable": "変数",
|
||||
"added": "追加",
|
||||
"changed": "変更",
|
||||
"removed": "削除",
|
||||
"panelCount": "パネル {count} 件",
|
||||
"tabCount": "タブ {count} 件",
|
||||
"variableCount": "変数 {count} 件",
|
||||
"chipAdded": "{items}を追加",
|
||||
"chipChanged": "{items}を変更",
|
||||
"chipRemoved": "{items}を削除",
|
||||
"mergeUnavailable": "パネルを最新バージョンに配置できませんでした。再読み込みするか上書きしてください。",
|
||||
"reload": "再読み込み",
|
||||
"reloadHint": "編集内容を破棄し、ここに最新バージョンを読み込みます。",
|
||||
"overwrite": "上書き",
|
||||
"overwriteHint": "相手の保存内容をあなたのもので置き換えます。",
|
||||
"merge": "マージ",
|
||||
"mergeHint": "あなたのパネルを最新バージョンに配置し、保存前に結果を表示します。",
|
||||
"back": "戻る",
|
||||
"retry": "再試行",
|
||||
"overwriteRemoves": "上書きすると{items}が削除されます",
|
||||
"overwriteReplaces": "上書きすると相手の保存内容が置き換えられます",
|
||||
"impactRemoves": "他のユーザーが追加した{items}を削除: {names}",
|
||||
"impactReverts": "相手が編集した{items}を元に戻す: {names}",
|
||||
"impactDeletesEdited": "相手が編集した{items}を削除: {names}",
|
||||
"impactRestores": "相手が削除した{items}を復元: {names}",
|
||||
"impactUnknown": "相手の変更を一覧表示できません。すべて置き換えられます。",
|
||||
"cannotUndo": "ここから元に戻すことはできません。",
|
||||
"removeAndOverwrite": "削除して上書き",
|
||||
"reviewIntro": "あなたのパネルを最新バージョンに適用しました。相手のその他の変更も含まれています。確認してから保存または破棄してください。",
|
||||
"reviewHeading": "マージ後のダッシュボード",
|
||||
"tagYours": "自分",
|
||||
"tagTheirsNew": "相手、新規",
|
||||
"tagTheirsEdited": "相手、編集済み",
|
||||
"discard": "破棄",
|
||||
"saveMerged": "マージを保存",
|
||||
"toastReloaded": "最新バージョンを読み込みました。編集内容は破棄されました。",
|
||||
"toastOverwritten": "上書きしました。{time} に保存された変更はあなたのもので置き換えられました。",
|
||||
"toastOverwrittenNoTime": "上書きしました。相手の変更はあなたのもので置き換えられました。",
|
||||
"toastMerged": "マージしたバージョンを保存しました。",
|
||||
"toastMergeDiscarded": "マージを破棄しました。編集内容は保持されていますが、まだ保存されていません。",
|
||||
"yourChange": "未保存の変更: {change}",
|
||||
"stillUnresolved": "この競合はまだ解決されていないため、保存されませんでした。保存方法を選択してください。",
|
||||
"toastReloadedChange": "最新バージョンを読み込みました。破棄した変更: {change}",
|
||||
"renamedFrom": "{name}(旧名: {from})",
|
||||
"kindSettings": "設定",
|
||||
"settingTitle": "名前",
|
||||
"settingDescription": "説明",
|
||||
"settingTimeRange": "デフォルトの期間",
|
||||
"settingDynamicFilters": "動的フィルター",
|
||||
"settingOther": "その他の設定",
|
||||
"impactSettings": "相手が変更したダッシュボード設定を置き換えます: {settings}",
|
||||
"impactNothing": "相手の保存内容はあなたのもので置き換えられます。",
|
||||
"compareField": "あなたのパネル",
|
||||
"compareTheirs": "相手のバージョン",
|
||||
"compareYours": "あなたのバージョン",
|
||||
"compareTitle": "タイトル",
|
||||
"compareOther": "クエリと設定",
|
||||
"compareSame": "相手と同じ",
|
||||
"compareDifferent": "異なる",
|
||||
"compareRemoved": "相手が削除",
|
||||
"change": {
|
||||
"editedJson": "ダッシュボードの JSON を編集",
|
||||
"editedPanel": "パネル「{name}」を編集",
|
||||
"addedPanel": "パネル「{name}」を追加",
|
||||
"movedPanels": "パネルを移動またはサイズ変更",
|
||||
"deletedPanel": "パネル「{name}」を削除",
|
||||
"movedPanel": "パネル「{name}」を別のタブに移動",
|
||||
"duplicatedPanel": "パネル「{name}」を複製",
|
||||
"addedTab": "タブ「{name}」を追加",
|
||||
"renamedTab": "タブ「{from}」を「{to}」に名前変更",
|
||||
"reorderedTabs": "タブを並べ替え",
|
||||
"deletedTab": "タブ「{name}」を削除",
|
||||
"editedVariable": "変数「{name}」を編集",
|
||||
"addedVariable": "変数「{name}」を追加",
|
||||
"reorderedVariables": "変数を並べ替え",
|
||||
"deletedVariable": "変数「{name}」を削除",
|
||||
"changedSettings": "ダッシュボード設定を変更"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "レイアウトを編集",
|
||||
"edit": "編集",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU 익스포터"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "저장되지 않음: 다른 사용자가 이 대시보드를 먼저 저장했습니다.",
|
||||
"notSavedChange": "저장되지 않음: {change}. 다른 사용자가 이 대시보드를 먼저 저장했습니다.",
|
||||
"resolve": "해결",
|
||||
"title": "이 대시보드는 연 이후 변경되었습니다",
|
||||
"savedBy": "다른 사용자가 {relative} 저장함 ({time})",
|
||||
"savedByUnknown": "연 이후 다른 사용자가 저장함",
|
||||
"checking": "변경 사항 확인 중…",
|
||||
"loadFailed": "최신 버전을 불러오지 못했습니다. 편집 내용은 그대로 있습니다. 다시 시도하세요.",
|
||||
"savedAgain": "해결하는 동안 다른 사용자가 다시 저장했습니다. 최신 변경 사항을 검토하고 다시 선택하세요.",
|
||||
"saveFailed": "저장하지 못했습니다: {message}. 편집 내용은 그대로 있습니다.",
|
||||
"panelTouched": "내 패널도 다른 사용자가 변경했습니다",
|
||||
"panelUntouched": "내 패널은 다른 사용자가 변경하지 않았습니다",
|
||||
"whatChanged": "불러온 이후 변경된 내용",
|
||||
"nothingChanged": "패널, 탭, 변수, 설정에 차이가 없습니다.",
|
||||
"baseUnknown": "불러온 버전을 더 이상 사용할 수 없어 변경 사항을 나열할 수 없습니다.",
|
||||
"kindPanel": "패널",
|
||||
"kindTab": "탭",
|
||||
"kindVariable": "변수",
|
||||
"added": "추가됨",
|
||||
"changed": "변경됨",
|
||||
"removed": "삭제됨",
|
||||
"panelCount": "패널 {count}개",
|
||||
"tabCount": "탭 {count}개",
|
||||
"variableCount": "변수 {count}개",
|
||||
"chipAdded": "{items} 추가됨",
|
||||
"chipChanged": "{items} 변경됨",
|
||||
"chipRemoved": "{items} 삭제됨",
|
||||
"mergeUnavailable": "내 패널을 최신 버전에 배치할 수 없습니다. 대신 다시 불러오거나 덮어쓰세요.",
|
||||
"reload": "다시 불러오기",
|
||||
"reloadHint": "편집 내용을 버리고 여기에 최신 버전을 불러옵니다.",
|
||||
"overwrite": "덮어쓰기",
|
||||
"overwriteHint": "상대의 저장 내용을 내 것으로 바꿉니다.",
|
||||
"merge": "병합",
|
||||
"mergeHint": "내 패널을 최신 버전에 넣고 저장 전에 결과를 보여 줍니다.",
|
||||
"back": "뒤로",
|
||||
"retry": "다시 시도",
|
||||
"overwriteRemoves": "덮어쓰면 {items}이(가) 삭제됩니다",
|
||||
"overwriteReplaces": "덮어쓰면 상대의 저장 내용이 바뀝니다",
|
||||
"impactRemoves": "다른 사용자가 추가한 {items} 삭제: {names}",
|
||||
"impactReverts": "상대가 편집한 {items} 되돌리기: {names}",
|
||||
"impactDeletesEdited": "상대가 편집한 {items} 삭제: {names}",
|
||||
"impactRestores": "상대가 삭제한 {items} 복원: {names}",
|
||||
"impactUnknown": "상대의 변경 사항을 나열할 수 없으며 모두 바뀝니다.",
|
||||
"cannotUndo": "여기에서는 되돌릴 수 없습니다.",
|
||||
"removeAndOverwrite": "삭제하고 덮어쓰기",
|
||||
"reviewIntro": "내 패널이 최신 버전 위에 적용되었고 상대의 다른 변경 사항도 포함되었습니다. 검토한 후 저장하거나 버리세요.",
|
||||
"reviewHeading": "병합 후 대시보드",
|
||||
"tagYours": "내 것",
|
||||
"tagTheirsNew": "상대, 새 항목",
|
||||
"tagTheirsEdited": "상대, 편집됨",
|
||||
"discard": "버리기",
|
||||
"saveMerged": "병합 저장",
|
||||
"toastReloaded": "최신 버전을 불러왔습니다. 편집 내용은 버려졌습니다.",
|
||||
"toastOverwritten": "덮어썼습니다. {time}에 저장된 변경 사항이 내 것으로 바뀌었습니다.",
|
||||
"toastOverwrittenNoTime": "덮어썼습니다. 상대의 변경 사항이 내 것으로 바뀌었습니다.",
|
||||
"toastMerged": "병합된 버전을 저장했습니다.",
|
||||
"toastMergeDiscarded": "병합을 버렸습니다. 편집 내용은 유지되며 아직 저장되지 않았습니다.",
|
||||
"yourChange": "저장하지 않은 변경 사항: {change}",
|
||||
"stillUnresolved": "이 충돌이 아직 해결되지 않아 저장되지 않았습니다. 저장 방법을 선택하세요.",
|
||||
"toastReloadedChange": "최신 버전을 불러왔습니다. 버린 변경 사항: {change}",
|
||||
"renamedFrom": "{name} (이전 이름: {from})",
|
||||
"kindSettings": "설정",
|
||||
"settingTitle": "이름",
|
||||
"settingDescription": "설명",
|
||||
"settingTimeRange": "기본 시간 범위",
|
||||
"settingDynamicFilters": "동적 필터",
|
||||
"settingOther": "기타 설정",
|
||||
"impactSettings": "상대가 변경한 대시보드 설정을 바꿉니다: {settings}",
|
||||
"impactNothing": "상대의 저장 내용이 내 것으로 바뀝니다.",
|
||||
"compareField": "내 패널",
|
||||
"compareTheirs": "상대 버전",
|
||||
"compareYours": "내 버전",
|
||||
"compareTitle": "제목",
|
||||
"compareOther": "쿼리 및 설정",
|
||||
"compareSame": "상대와 같음",
|
||||
"compareDifferent": "다름",
|
||||
"compareRemoved": "상대가 삭제함",
|
||||
"change": {
|
||||
"editedJson": "대시보드 JSON 편집",
|
||||
"editedPanel": "패널 \"{name}\" 편집",
|
||||
"addedPanel": "패널 \"{name}\" 추가",
|
||||
"movedPanels": "패널 이동 또는 크기 변경",
|
||||
"deletedPanel": "패널 \"{name}\" 삭제",
|
||||
"movedPanel": "패널 \"{name}\"을(를) 다른 탭으로 이동",
|
||||
"duplicatedPanel": "패널 \"{name}\" 복제",
|
||||
"addedTab": "탭 \"{name}\" 추가",
|
||||
"renamedTab": "탭 \"{from}\" 이름을 \"{to}\"(으)로 변경",
|
||||
"reorderedTabs": "탭 순서 변경",
|
||||
"deletedTab": "탭 \"{name}\" 삭제",
|
||||
"editedVariable": "변수 \"{name}\" 편집",
|
||||
"addedVariable": "변수 \"{name}\" 추가",
|
||||
"reorderedVariables": "변수 순서 변경",
|
||||
"deletedVariable": "변수 \"{name}\" 삭제",
|
||||
"changedSettings": "대시보드 설정 변경"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "레이아웃 편집",
|
||||
"edit": "편집",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU-exporter"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Niet opgeslagen: iemand anders heeft dit dashboard eerst opgeslagen.",
|
||||
"notSavedChange": "Niet opgeslagen: {change}. Iemand anders heeft dit dashboard eerst opgeslagen.",
|
||||
"resolve": "Oplossen",
|
||||
"title": "Dit dashboard is gewijzigd sinds je het opende",
|
||||
"savedBy": "Opgeslagen door iemand anders {relative} ({time})",
|
||||
"savedByUnknown": "Opgeslagen door iemand anders sinds je het opende",
|
||||
"checking": "Controleren wat er is gewijzigd…",
|
||||
"loadFailed": "De nieuwste versie kon niet worden geladen. Je bewerking is er nog. Probeer het opnieuw.",
|
||||
"savedAgain": "Iemand heeft opnieuw opgeslagen terwijl je dit oploste. Bekijk de nieuwste wijzigingen en kies opnieuw.",
|
||||
"saveFailed": "Opslaan mislukt: {message}. Je bewerking is er nog.",
|
||||
"panelTouched": "Je paneel is ook door iemand anders gewijzigd",
|
||||
"panelUntouched": "Je paneel is niet door iemand anders gewijzigd",
|
||||
"whatChanged": "Wat er is gewijzigd sinds het laden",
|
||||
"nothingChanged": "Geen verschillen in panelen, tabbladen, variabelen of instellingen.",
|
||||
"baseUnknown": "De geladen versie is niet meer beschikbaar, dus de wijzigingen kunnen niet worden getoond.",
|
||||
"kindPanel": "Paneel",
|
||||
"kindTab": "Tabblad",
|
||||
"kindVariable": "Variabele",
|
||||
"added": "Toegevoegd",
|
||||
"changed": "Gewijzigd",
|
||||
"removed": "Verwijderd",
|
||||
"panelCount": "{count} paneel | {count} panelen",
|
||||
"tabCount": "{count} tabblad | {count} tabbladen",
|
||||
"variableCount": "{count} variabele | {count} variabelen",
|
||||
"chipAdded": "{items} toegevoegd",
|
||||
"chipChanged": "{items} gewijzigd",
|
||||
"chipRemoved": "{items} verwijderd",
|
||||
"mergeUnavailable": "Je paneel kon niet in de nieuwste versie worden geplaatst. Laad opnieuw of overschrijf.",
|
||||
"reload": "Opnieuw laden",
|
||||
"reloadHint": "Verwerpt je bewerking en laadt hier de nieuwste versie.",
|
||||
"overwrite": "Overschrijven",
|
||||
"overwriteHint": "Vervangt hun opslag door de jouwe.",
|
||||
"merge": "Samenvoegen",
|
||||
"mergeHint": "Plaatst je paneel in de nieuwste versie en toont het resultaat voordat je opslaat.",
|
||||
"back": "Terug",
|
||||
"retry": "Opnieuw proberen",
|
||||
"overwriteRemoves": "Overschrijven verwijdert {items}",
|
||||
"overwriteReplaces": "Overschrijven vervangt hun opslag",
|
||||
"impactRemoves": "Verwijdert {items} die iemand anders heeft toegevoegd: {names}",
|
||||
"impactReverts": "Draait {items} terug die zij hebben bewerkt: {names}",
|
||||
"impactDeletesEdited": "Verwijdert {items} die zij hebben bewerkt: {names}",
|
||||
"impactRestores": "Herstelt {items} die zij hebben verwijderd: {names}",
|
||||
"impactUnknown": "Hun wijzigingen kunnen niet worden getoond; ze worden allemaal vervangen.",
|
||||
"cannotUndo": "Dit kan hier niet ongedaan worden gemaakt.",
|
||||
"removeAndOverwrite": "Verwijderen en overschrijven",
|
||||
"reviewIntro": "Je paneel is toegepast op de nieuwste versie; hun andere wijzigingen zijn inbegrepen. Controleer het en sla op of verwerp.",
|
||||
"reviewHeading": "Dashboard na samenvoegen",
|
||||
"tagYours": "Van jou",
|
||||
"tagTheirsNew": "Van hen, nieuw",
|
||||
"tagTheirsEdited": "Van hen, bewerkt",
|
||||
"discard": "Verwerpen",
|
||||
"saveMerged": "Samenvoeging opslaan",
|
||||
"toastReloaded": "Nieuwste versie geladen. Je bewerking is verworpen.",
|
||||
"toastOverwritten": "Overschreven. De wijzigingen van {time} zijn vervangen door de jouwe.",
|
||||
"toastOverwrittenNoTime": "Overschreven. Hun wijzigingen zijn vervangen door de jouwe.",
|
||||
"toastMerged": "Samengevoegde versie opgeslagen.",
|
||||
"toastMergeDiscarded": "Samenvoegen verworpen. Je bewerking blijft behouden en is nog niet opgeslagen.",
|
||||
"yourChange": "Je niet-opgeslagen wijziging: {change} | Je niet-opgeslagen wijzigingen: {change}",
|
||||
"stillUnresolved": "Dit conflict is nog niet opgelost, dus er is niet opgeslagen. Kies hoe je wilt opslaan.",
|
||||
"toastReloadedChange": "Nieuwste versie geladen. Je wijziging is verworpen: {change}",
|
||||
"renamedFrom": "{name} (was {from})",
|
||||
"kindSettings": "Instellingen",
|
||||
"settingTitle": "naam",
|
||||
"settingDescription": "beschrijving",
|
||||
"settingTimeRange": "standaardtijdbereik",
|
||||
"settingDynamicFilters": "dynamische filters",
|
||||
"settingOther": "overige instellingen",
|
||||
"impactSettings": "Vervangt de dashboardinstellingen die zij hebben gewijzigd: {settings}",
|
||||
"impactNothing": "Hun opslag wordt vervangen door de jouwe.",
|
||||
"compareField": "Jouw paneel",
|
||||
"compareTheirs": "Hun versie",
|
||||
"compareYours": "Jouw versie",
|
||||
"compareTitle": "Titel",
|
||||
"compareOther": "Query en instellingen",
|
||||
"compareSame": "Gelijk aan die van hen",
|
||||
"compareDifferent": "Anders",
|
||||
"compareRemoved": "Door hen verwijderd",
|
||||
"change": {
|
||||
"editedJson": "Dashboard-JSON bewerkt",
|
||||
"editedPanel": "Paneel \"{name}\" bewerkt",
|
||||
"addedPanel": "Paneel \"{name}\" toegevoegd",
|
||||
"movedPanels": "Panelen verplaatst of van formaat veranderd",
|
||||
"deletedPanel": "Paneel \"{name}\" verwijderd",
|
||||
"movedPanel": "Paneel \"{name}\" naar een ander tabblad verplaatst",
|
||||
"duplicatedPanel": "Paneel \"{name}\" gedupliceerd",
|
||||
"addedTab": "Tabblad \"{name}\" toegevoegd",
|
||||
"renamedTab": "Tabblad \"{from}\" hernoemd naar \"{to}\"",
|
||||
"reorderedTabs": "Tabbladen opnieuw gerangschikt",
|
||||
"deletedTab": "Tabblad \"{name}\" verwijderd",
|
||||
"editedVariable": "Variabele \"{name}\" bewerkt",
|
||||
"addedVariable": "Variabele \"{name}\" toegevoegd",
|
||||
"reorderedVariables": "Variabelen opnieuw gerangschikt",
|
||||
"deletedVariable": "Variabele \"{name}\" verwijderd",
|
||||
"changedSettings": "Dashboardinstellingen gewijzigd"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Indeling bewerken",
|
||||
"edit": "Bewerken",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "Eksporter GPU"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Nie zapisano: ktoś inny zapisał ten pulpit wcześniej.",
|
||||
"notSavedChange": "Nie zapisano: {change}. Ktoś inny zapisał ten pulpit wcześniej.",
|
||||
"resolve": "Rozwiąż",
|
||||
"title": "Ten pulpit zmienił się od czasu jego otwarcia",
|
||||
"savedBy": "Zapisane przez inną osobę {relative} ({time})",
|
||||
"savedByUnknown": "Zapisane przez inną osobę od czasu otwarcia",
|
||||
"checking": "Sprawdzanie zmian…",
|
||||
"loadFailed": "Nie udało się wczytać najnowszej wersji. Twoja edycja nadal tu jest. Spróbuj ponownie.",
|
||||
"savedAgain": "Ktoś ponownie zapisał, gdy rozwiązywałeś konflikt. Przejrzyj najnowsze zmiany i wybierz ponownie.",
|
||||
"saveFailed": "Nie udało się zapisać: {message}. Twoja edycja nadal tu jest.",
|
||||
"panelTouched": "Twój panel również został zmieniony przez inną osobę",
|
||||
"panelUntouched": "Twój panel nie został zmieniony przez inną osobę",
|
||||
"whatChanged": "Co zmieniło się od wczytania",
|
||||
"nothingChanged": "Brak różnic w panelach, kartach, zmiennych ani ustawieniach.",
|
||||
"baseUnknown": "Wczytana wersja nie jest już dostępna, więc nie można wyświetlić zmian.",
|
||||
"kindPanel": "Panel",
|
||||
"kindTab": "Karta",
|
||||
"kindVariable": "Zmienna",
|
||||
"added": "Dodano",
|
||||
"changed": "Zmieniono",
|
||||
"removed": "Usunięto",
|
||||
"panelCount": "panele: {count}",
|
||||
"tabCount": "karty: {count}",
|
||||
"variableCount": "zmienne: {count}",
|
||||
"chipAdded": "Dodano: {items}",
|
||||
"chipChanged": "Zmieniono: {items}",
|
||||
"chipRemoved": "Usunięto: {items}",
|
||||
"mergeUnavailable": "Nie udało się umieścić panelu w najnowszej wersji. Wczytaj ponownie lub nadpisz.",
|
||||
"reload": "Wczytaj ponownie",
|
||||
"reloadHint": "Odrzuca twoją edycję i wczytuje tutaj najnowszą wersję.",
|
||||
"overwrite": "Nadpisz",
|
||||
"overwriteHint": "Zastępuje ich zapis twoim.",
|
||||
"merge": "Scal",
|
||||
"mergeHint": "Umieszcza twój panel w najnowszej wersji i pokazuje wynik przed zapisaniem.",
|
||||
"back": "Wstecz",
|
||||
"retry": "Ponów",
|
||||
"overwriteRemoves": "Nadpisanie usunie: {items}",
|
||||
"overwriteReplaces": "Nadpisanie zastąpi ich zapis",
|
||||
"impactRemoves": "Usuwa elementy dodane przez inną osobę ({items}): {names}",
|
||||
"impactReverts": "Cofa ich edycje ({items}): {names}",
|
||||
"impactDeletesEdited": "Usuwa elementy przez nich edytowane ({items}): {names}",
|
||||
"impactRestores": "Przywraca elementy przez nich usunięte ({items}): {names}",
|
||||
"impactUnknown": "Nie można wyświetlić ich zmian; wszystkie zostaną zastąpione.",
|
||||
"cannotUndo": "Tej operacji nie można tu cofnąć.",
|
||||
"removeAndOverwrite": "Usuń i nadpisz",
|
||||
"reviewIntro": "Twój panel został zastosowany na najnowszej wersji; ich pozostałe zmiany są uwzględnione. Sprawdź wynik, a potem zapisz lub odrzuć.",
|
||||
"reviewHeading": "Pulpit po scaleniu",
|
||||
"tagYours": "Twój",
|
||||
"tagTheirsNew": "Ich, nowy",
|
||||
"tagTheirsEdited": "Ich, zmieniony",
|
||||
"discard": "Odrzuć",
|
||||
"saveMerged": "Zapisz scalenie",
|
||||
"toastReloaded": "Wczytano najnowszą wersję. Twoja edycja została odrzucona.",
|
||||
"toastOverwritten": "Nadpisano. Zmiany zapisane o {time} zastąpiono twoimi.",
|
||||
"toastOverwrittenNoTime": "Nadpisano. Ich zmiany zastąpiono twoimi.",
|
||||
"toastMerged": "Zapisano scaloną wersję.",
|
||||
"toastMergeDiscarded": "Scalanie odrzucone. Twoja edycja została zachowana i nie jest jeszcze zapisana.",
|
||||
"yourChange": "Twoja niezapisana zmiana: {change} | Twoje niezapisane zmiany: {change}",
|
||||
"stillUnresolved": "Ten konflikt nadal nie jest rozwiązany, więc nic nie zapisano. Wybierz sposób zapisu.",
|
||||
"toastReloadedChange": "Wczytano najnowszą wersję. Odrzucono twoją zmianę: {change}",
|
||||
"renamedFrom": "{name} (wcześniej {from})",
|
||||
"kindSettings": "Ustawienia",
|
||||
"settingTitle": "nazwa",
|
||||
"settingDescription": "opis",
|
||||
"settingTimeRange": "domyślny zakres czasu",
|
||||
"settingDynamicFilters": "filtry dynamiczne",
|
||||
"settingOther": "inne ustawienia",
|
||||
"impactSettings": "Zastępuje zmienione przez nich ustawienia pulpitu: {settings}",
|
||||
"impactNothing": "Ich zapis zostanie zastąpiony twoim.",
|
||||
"compareField": "Twój panel",
|
||||
"compareTheirs": "Ich wersja",
|
||||
"compareYours": "Twoja wersja",
|
||||
"compareTitle": "Tytuł",
|
||||
"compareOther": "Zapytanie i ustawienia",
|
||||
"compareSame": "Taka sama jak ich",
|
||||
"compareDifferent": "Inna",
|
||||
"compareRemoved": "Usunięty przez nich",
|
||||
"change": {
|
||||
"editedJson": "Edytowano JSON pulpitu",
|
||||
"editedPanel": "Edytowano panel \"{name}\"",
|
||||
"addedPanel": "Dodano panel \"{name}\"",
|
||||
"movedPanels": "Przeniesiono lub zmieniono rozmiar paneli",
|
||||
"deletedPanel": "Usunięto panel \"{name}\"",
|
||||
"movedPanel": "Przeniesiono panel \"{name}\" na inną kartę",
|
||||
"duplicatedPanel": "Zduplikowano panel \"{name}\"",
|
||||
"addedTab": "Dodano kartę \"{name}\"",
|
||||
"renamedTab": "Zmieniono nazwę karty \"{from}\" na \"{to}\"",
|
||||
"reorderedTabs": "Zmieniono kolejność kart",
|
||||
"deletedTab": "Usunięto kartę \"{name}\"",
|
||||
"editedVariable": "Edytowano zmienną \"{name}\"",
|
||||
"addedVariable": "Dodano zmienną \"{name}\"",
|
||||
"reorderedVariables": "Zmieniono kolejność zmiennych",
|
||||
"deletedVariable": "Usunięto zmienną \"{name}\"",
|
||||
"changedSettings": "Zmieniono ustawienia pulpitu"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Edytuj układ",
|
||||
"edit": "Edytuj",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "Exportador de GPU"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Não guardado: outra pessoa guardou este painel primeiro.",
|
||||
"notSavedChange": "Não guardado: {change}. Outra pessoa guardou este painel primeiro.",
|
||||
"resolve": "Resolver",
|
||||
"title": "Este painel mudou desde que o abriu",
|
||||
"savedBy": "Guardado por outra pessoa {relative} ({time})",
|
||||
"savedByUnknown": "Guardado por outra pessoa desde que o abriu",
|
||||
"checking": "A verificar o que mudou…",
|
||||
"loadFailed": "Não foi possível carregar a versão mais recente. A sua edição continua aqui. Tente novamente.",
|
||||
"savedAgain": "Alguém guardou novamente enquanto resolvia. Reveja as alterações mais recentes e escolha de novo.",
|
||||
"saveFailed": "Não foi possível guardar: {message}. A sua edição continua aqui.",
|
||||
"panelTouched": "O seu painel também foi alterado por outra pessoa",
|
||||
"panelUntouched": "O seu painel não foi alterado por outra pessoa",
|
||||
"whatChanged": "O que mudou desde que carregou",
|
||||
"nothingChanged": "Sem diferenças em painéis, separadores, variáveis ou definições.",
|
||||
"baseUnknown": "A versão que carregou já não está disponível, por isso as alterações não podem ser listadas.",
|
||||
"kindPanel": "Painel",
|
||||
"kindTab": "Separador",
|
||||
"kindVariable": "Variável",
|
||||
"added": "Adicionado",
|
||||
"changed": "Alterado",
|
||||
"removed": "Removido",
|
||||
"panelCount": "{count} painel | {count} painéis",
|
||||
"tabCount": "{count} separador | {count} separadores",
|
||||
"variableCount": "{count} variável | {count} variáveis",
|
||||
"chipAdded": "{items} adicionados",
|
||||
"chipChanged": "{items} alterados",
|
||||
"chipRemoved": "{items} removidos",
|
||||
"mergeUnavailable": "Não foi possível colocar o seu painel na versão mais recente. Recarregue ou substitua.",
|
||||
"reload": "Recarregar",
|
||||
"reloadHint": "Descarta a sua edição e carrega aqui a versão mais recente.",
|
||||
"overwrite": "Substituir",
|
||||
"overwriteHint": "Substitui o que guardaram pela sua versão.",
|
||||
"merge": "Combinar",
|
||||
"mergeHint": "Coloca o seu painel na versão mais recente e mostra o resultado antes de guardar.",
|
||||
"back": "Voltar",
|
||||
"retry": "Tentar novamente",
|
||||
"overwriteRemoves": "Substituir remove {items}",
|
||||
"overwriteReplaces": "Substituir troca o que guardaram",
|
||||
"impactRemoves": "Remove {items} que outra pessoa adicionou: {names}",
|
||||
"impactReverts": "Reverte {items} que editaram: {names}",
|
||||
"impactDeletesEdited": "Elimina {items} que editaram: {names}",
|
||||
"impactRestores": "Restaura {items} que removeram: {names}",
|
||||
"impactUnknown": "As alterações deles não podem ser listadas; todas são substituídas.",
|
||||
"cannotUndo": "Isto não pode ser anulado a partir daqui.",
|
||||
"removeAndOverwrite": "Remover e substituir",
|
||||
"reviewIntro": "O seu painel foi aplicado sobre a versão mais recente; as outras alterações deles estão incluídas. Reveja e depois guarde ou descarte.",
|
||||
"reviewHeading": "Painel após combinar",
|
||||
"tagYours": "Seu",
|
||||
"tagTheirsNew": "Deles, novo",
|
||||
"tagTheirsEdited": "Deles, editado",
|
||||
"discard": "Descartar",
|
||||
"saveMerged": "Guardar combinação",
|
||||
"toastReloaded": "Versão mais recente carregada. A sua edição foi descartada.",
|
||||
"toastOverwritten": "Substituído. As alterações guardadas às {time} foram substituídas pelas suas.",
|
||||
"toastOverwrittenNoTime": "Substituído. As alterações deles foram substituídas pelas suas.",
|
||||
"toastMerged": "Versão combinada guardada.",
|
||||
"toastMergeDiscarded": "Combinação descartada. A sua edição é mantida e ainda não está guardada.",
|
||||
"yourChange": "A sua alteração não guardada: {change} | As suas alterações não guardadas: {change}",
|
||||
"stillUnresolved": "Este conflito continua por resolver, por isso nada foi guardado. Escolha como guardar.",
|
||||
"toastReloadedChange": "Versão mais recente carregada. A sua alteração foi descartada: {change}",
|
||||
"renamedFrom": "{name} (antes {from})",
|
||||
"kindSettings": "Definições",
|
||||
"settingTitle": "nome",
|
||||
"settingDescription": "descrição",
|
||||
"settingTimeRange": "intervalo de tempo predefinido",
|
||||
"settingDynamicFilters": "filtros dinâmicos",
|
||||
"settingOther": "outras definições",
|
||||
"impactSettings": "Substitui as definições do painel que alteraram: {settings}",
|
||||
"impactNothing": "O que guardaram é substituído pela sua versão.",
|
||||
"compareField": "O seu painel",
|
||||
"compareTheirs": "Versão deles",
|
||||
"compareYours": "A sua versão",
|
||||
"compareTitle": "Título",
|
||||
"compareOther": "Consulta e definições",
|
||||
"compareSame": "Igual à deles",
|
||||
"compareDifferent": "Diferente",
|
||||
"compareRemoved": "Eliminado por eles",
|
||||
"change": {
|
||||
"editedJson": "Editou o JSON do painel",
|
||||
"editedPanel": "Editou o painel \"{name}\"",
|
||||
"addedPanel": "Adicionou o painel \"{name}\"",
|
||||
"movedPanels": "Moveu ou redimensionou painéis",
|
||||
"deletedPanel": "Eliminou o painel \"{name}\"",
|
||||
"movedPanel": "Moveu o painel \"{name}\" para outro separador",
|
||||
"duplicatedPanel": "Duplicou o painel \"{name}\"",
|
||||
"addedTab": "Adicionou o separador \"{name}\"",
|
||||
"renamedTab": "Mudou o nome do separador \"{from}\" para \"{to}\"",
|
||||
"reorderedTabs": "Reordenou os separadores",
|
||||
"deletedTab": "Eliminou o separador \"{name}\"",
|
||||
"editedVariable": "Editou a variável \"{name}\"",
|
||||
"addedVariable": "Adicionou a variável \"{name}\"",
|
||||
"reorderedVariables": "Reordenou as variáveis",
|
||||
"deletedVariable": "Eliminou a variável \"{name}\"",
|
||||
"changedSettings": "Alterou as definições do painel"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Editar esquema",
|
||||
"edit": "Editar",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "Экспортёр GPU"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Не сохранено: другой пользователь сохранил дашборд раньше.",
|
||||
"notSavedChange": "Не сохранено: {change}. Другой пользователь сохранил дашборд раньше.",
|
||||
"resolve": "Разрешить",
|
||||
"title": "Этот дашборд изменился с момента открытия",
|
||||
"savedBy": "Сохранено другим пользователем {relative} ({time})",
|
||||
"savedByUnknown": "Сохранено другим пользователем после того, как вы его открыли",
|
||||
"checking": "Проверка изменений…",
|
||||
"loadFailed": "Не удалось загрузить последнюю версию. Ваша правка сохранена здесь. Повторите попытку.",
|
||||
"savedAgain": "Кто-то снова сохранил, пока вы разрешали конфликт. Просмотрите последние изменения и выберите снова.",
|
||||
"saveFailed": "Не удалось сохранить: {message}. Ваша правка сохранена здесь.",
|
||||
"panelTouched": "Вашу панель тоже изменил другой пользователь",
|
||||
"panelUntouched": "Вашу панель другой пользователь не менял",
|
||||
"whatChanged": "Что изменилось после загрузки",
|
||||
"nothingChanged": "Нет различий в панелях, вкладках, переменных или настройках.",
|
||||
"baseUnknown": "Загруженная версия больше недоступна, поэтому изменения нельзя перечислить.",
|
||||
"kindPanel": "Панель",
|
||||
"kindTab": "Вкладка",
|
||||
"kindVariable": "Переменная",
|
||||
"added": "Добавлено",
|
||||
"changed": "Изменено",
|
||||
"removed": "Удалено",
|
||||
"panelCount": "панели: {count}",
|
||||
"tabCount": "вкладки: {count}",
|
||||
"variableCount": "переменные: {count}",
|
||||
"chipAdded": "Добавлено — {items}",
|
||||
"chipChanged": "Изменено — {items}",
|
||||
"chipRemoved": "Удалено — {items}",
|
||||
"mergeUnavailable": "Не удалось поместить вашу панель в последнюю версию. Перезагрузите или перезапишите.",
|
||||
"reload": "Перезагрузить",
|
||||
"reloadHint": "Отменяет вашу правку и загружает здесь последнюю версию.",
|
||||
"overwrite": "Перезаписать",
|
||||
"overwriteHint": "Заменяет их сохранение вашим.",
|
||||
"merge": "Объединить",
|
||||
"mergeHint": "Помещает вашу панель в последнюю версию и показывает результат перед сохранением.",
|
||||
"back": "Назад",
|
||||
"retry": "Повторить",
|
||||
"overwriteRemoves": "Перезапись удалит: {items}",
|
||||
"overwriteReplaces": "Перезапись заменит их сохранение",
|
||||
"impactRemoves": "Удаляет добавленное другим пользователем ({items}): {names}",
|
||||
"impactReverts": "Отменяет их правки ({items}): {names}",
|
||||
"impactDeletesEdited": "Удаляет изменённое ими ({items}): {names}",
|
||||
"impactRestores": "Восстанавливает удалённое ими ({items}): {names}",
|
||||
"impactUnknown": "Их изменения нельзя перечислить; заменяются все.",
|
||||
"cannotUndo": "Отменить это здесь нельзя.",
|
||||
"removeAndOverwrite": "Удалить и перезаписать",
|
||||
"reviewIntro": "Ваша панель применена поверх последней версии; их остальные изменения сохранены. Проверьте результат, затем сохраните или отмените.",
|
||||
"reviewHeading": "Дашборд после объединения",
|
||||
"tagYours": "Ваша",
|
||||
"tagTheirsNew": "Чужая, новая",
|
||||
"tagTheirsEdited": "Чужая, изменена",
|
||||
"discard": "Отменить",
|
||||
"saveMerged": "Сохранить объединение",
|
||||
"toastReloaded": "Загружена последняя версия. Ваша правка отменена.",
|
||||
"toastOverwritten": "Перезаписано. Изменения, сохранённые в {time}, заменены вашими.",
|
||||
"toastOverwrittenNoTime": "Перезаписано. Их изменения заменены вашими.",
|
||||
"toastMerged": "Объединённая версия сохранена.",
|
||||
"toastMergeDiscarded": "Объединение отменено. Ваша правка сохранена здесь, но ещё не записана.",
|
||||
"yourChange": "Ваше несохранённое изменение: {change} | Ваши несохранённые изменения: {change}",
|
||||
"stillUnresolved": "Конфликт ещё не разрешён, поэтому сохранение не выполнено. Выберите, как сохранить.",
|
||||
"toastReloadedChange": "Загружена последняя версия. Ваше изменение отменено: {change}",
|
||||
"renamedFrom": "{name} (прежнее название: {from})",
|
||||
"kindSettings": "Настройки",
|
||||
"settingTitle": "название",
|
||||
"settingDescription": "описание",
|
||||
"settingTimeRange": "период по умолчанию",
|
||||
"settingDynamicFilters": "динамические фильтры",
|
||||
"settingOther": "другие настройки",
|
||||
"impactSettings": "Заменяет изменённые ими настройки дашборда: {settings}",
|
||||
"impactNothing": "Их сохранение заменяется вашим.",
|
||||
"compareField": "Ваша панель",
|
||||
"compareTheirs": "Их версия",
|
||||
"compareYours": "Ваша версия",
|
||||
"compareTitle": "Заголовок",
|
||||
"compareOther": "Запрос и настройки",
|
||||
"compareSame": "Совпадает с их версией",
|
||||
"compareDifferent": "Отличается",
|
||||
"compareRemoved": "Удалена ими",
|
||||
"change": {
|
||||
"editedJson": "Изменён JSON дашборда",
|
||||
"editedPanel": "Изменена панель \"{name}\"",
|
||||
"addedPanel": "Добавлена панель \"{name}\"",
|
||||
"movedPanels": "Панели перемещены или изменён их размер",
|
||||
"deletedPanel": "Удалена панель \"{name}\"",
|
||||
"movedPanel": "Панель \"{name}\" перемещена на другую вкладку",
|
||||
"duplicatedPanel": "Создана копия панели \"{name}\"",
|
||||
"addedTab": "Добавлена вкладка \"{name}\"",
|
||||
"renamedTab": "Вкладка \"{from}\" переименована в \"{to}\"",
|
||||
"reorderedTabs": "Изменён порядок вкладок",
|
||||
"deletedTab": "Удалена вкладка \"{name}\"",
|
||||
"editedVariable": "Изменена переменная \"{name}\"",
|
||||
"addedVariable": "Добавлена переменная \"{name}\"",
|
||||
"reorderedVariables": "Изменён порядок переменных",
|
||||
"deletedVariable": "Удалена переменная \"{name}\"",
|
||||
"changedSettings": "Изменены настройки дашборда"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Изменить макет",
|
||||
"edit": "Изменить",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU dışa aktarıcı"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Kaydedilmedi: bu panoyu önce başka biri kaydetti.",
|
||||
"notSavedChange": "Kaydedilmedi: {change}. Bu panoyu önce başka biri kaydetti.",
|
||||
"resolve": "Çöz",
|
||||
"title": "Bu pano, açtığınızdan beri değişti",
|
||||
"savedBy": "Başka biri tarafından kaydedildi: {relative} ({time})",
|
||||
"savedByUnknown": "Açtığınızdan beri başka biri tarafından kaydedildi",
|
||||
"checking": "Değişiklikler kontrol ediliyor…",
|
||||
"loadFailed": "En son sürüm yüklenemedi. Düzenlemeniz hâlâ burada. Tekrar deneyin.",
|
||||
"savedAgain": "Siz çözerken biri yeniden kaydetti. En son değişiklikleri inceleyip yeniden seçin.",
|
||||
"saveFailed": "Kaydedilemedi: {message}. Düzenlemeniz hâlâ burada.",
|
||||
"panelTouched": "Paneliniz de başka biri tarafından değiştirildi",
|
||||
"panelUntouched": "Paneliniz başka biri tarafından değiştirilmedi",
|
||||
"whatChanged": "Yüklediğinizden beri neler değişti",
|
||||
"nothingChanged": "Panellerde, sekmelerde, değişkenlerde veya ayarlarda fark yok.",
|
||||
"baseUnknown": "Yüklediğiniz sürüm artık mevcut değil, bu yüzden değişiklikler listelenemiyor.",
|
||||
"kindPanel": "Panel",
|
||||
"kindTab": "Sekme",
|
||||
"kindVariable": "Değişken",
|
||||
"added": "Eklendi",
|
||||
"changed": "Değişti",
|
||||
"removed": "Kaldırıldı",
|
||||
"panelCount": "{count} panel",
|
||||
"tabCount": "{count} sekme",
|
||||
"variableCount": "{count} değişken",
|
||||
"chipAdded": "{items} eklendi",
|
||||
"chipChanged": "{items} değişti",
|
||||
"chipRemoved": "{items} kaldırıldı",
|
||||
"mergeUnavailable": "Paneliniz en son sürüme yerleştirilemedi. Bunun yerine yeniden yükleyin veya üzerine yazın.",
|
||||
"reload": "Yeniden yükle",
|
||||
"reloadHint": "Düzenlemenizi atar ve en son sürümü burada yükler.",
|
||||
"overwrite": "Üzerine yaz",
|
||||
"overwriteHint": "Onların kaydını sizinkiyle değiştirir.",
|
||||
"merge": "Birleştir",
|
||||
"mergeHint": "Panelinizi en son sürüme yerleştirir ve kaydetmeden önce sonucu gösterir.",
|
||||
"back": "Geri",
|
||||
"retry": "Yeniden dene",
|
||||
"overwriteRemoves": "Üzerine yazmak şunları kaldırır: {items}",
|
||||
"overwriteReplaces": "Üzerine yazmak onların kaydını değiştirir",
|
||||
"impactRemoves": "Başka birinin eklediği {items} kaldırılır: {names}",
|
||||
"impactReverts": "Düzenledikleri {items} geri alınır: {names}",
|
||||
"impactDeletesEdited": "Düzenledikleri {items} silinir: {names}",
|
||||
"impactRestores": "Kaldırdıkları {items} geri yüklenir: {names}",
|
||||
"impactUnknown": "Değişiklikleri listelenemiyor; tümü değiştirilir.",
|
||||
"cannotUndo": "Bu işlem buradan geri alınamaz.",
|
||||
"removeAndOverwrite": "Kaldır ve üzerine yaz",
|
||||
"reviewIntro": "Paneliniz en son sürümün üzerine uygulandı; diğer değişiklikleri dahil. İnceleyin, ardından kaydedin veya atın.",
|
||||
"reviewHeading": "Birleştirme sonrası pano",
|
||||
"tagYours": "Sizin",
|
||||
"tagTheirsNew": "Onların, yeni",
|
||||
"tagTheirsEdited": "Onların, düzenlendi",
|
||||
"discard": "At",
|
||||
"saveMerged": "Birleştirmeyi kaydet",
|
||||
"toastReloaded": "En son sürüm yüklendi. Düzenlemeniz atıldı.",
|
||||
"toastOverwritten": "Üzerine yazıldı. {time} saatinde kaydedilen değişiklikler sizinkilerle değiştirildi.",
|
||||
"toastOverwrittenNoTime": "Üzerine yazıldı. Onların değişiklikleri sizinkilerle değiştirildi.",
|
||||
"toastMerged": "Birleştirilmiş sürüm kaydedildi.",
|
||||
"toastMergeDiscarded": "Birleştirme atıldı. Düzenlemeniz korunuyor ve henüz kaydedilmedi.",
|
||||
"yourChange": "Kaydedilmemiş değişikliğiniz: {change} | Kaydedilmemiş değişiklikleriniz: {change}",
|
||||
"stillUnresolved": "Bu çakışma hâlâ çözülmedi, bu yüzden kaydedilmedi. Nasıl kaydedileceğini seçin.",
|
||||
"toastReloadedChange": "En son sürüm yüklendi. Değişikliğiniz atıldı: {change}",
|
||||
"renamedFrom": "{name} (önceki adı {from})",
|
||||
"kindSettings": "Ayarlar",
|
||||
"settingTitle": "ad",
|
||||
"settingDescription": "açıklama",
|
||||
"settingTimeRange": "varsayılan zaman aralığı",
|
||||
"settingDynamicFilters": "dinamik filtreler",
|
||||
"settingOther": "diğer ayarlar",
|
||||
"impactSettings": "Değiştirdikleri pano ayarlarının yerine geçer: {settings}",
|
||||
"impactNothing": "Onların kaydı sizinkiyle değiştirilir.",
|
||||
"compareField": "Paneliniz",
|
||||
"compareTheirs": "Onların sürümü",
|
||||
"compareYours": "Sizin sürümünüz",
|
||||
"compareTitle": "Başlık",
|
||||
"compareOther": "Sorgu ve ayarlar",
|
||||
"compareSame": "Onlarınkiyle aynı",
|
||||
"compareDifferent": "Farklı",
|
||||
"compareRemoved": "Onlar tarafından silindi",
|
||||
"change": {
|
||||
"editedJson": "Pano JSON'u düzenlendi",
|
||||
"editedPanel": "\"{name}\" paneli düzenlendi",
|
||||
"addedPanel": "\"{name}\" paneli eklendi",
|
||||
"movedPanels": "Paneller taşındı veya yeniden boyutlandırıldı",
|
||||
"deletedPanel": "\"{name}\" paneli silindi",
|
||||
"movedPanel": "\"{name}\" paneli başka bir sekmeye taşındı",
|
||||
"duplicatedPanel": "\"{name}\" paneli çoğaltıldı",
|
||||
"addedTab": "\"{name}\" sekmesi eklendi",
|
||||
"renamedTab": "\"{from}\" sekmesinin adı \"{to}\" olarak değiştirildi",
|
||||
"reorderedTabs": "Sekmeler yeniden sıralandı",
|
||||
"deletedTab": "\"{name}\" sekmesi silindi",
|
||||
"editedVariable": "\"{name}\" değişkeni düzenlendi",
|
||||
"addedVariable": "\"{name}\" değişkeni eklendi",
|
||||
"reorderedVariables": "Değişkenler yeniden sıralandı",
|
||||
"deletedVariable": "\"{name}\" değişkeni silindi",
|
||||
"changedSettings": "Pano ayarları değiştirildi"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Düzeni düzenle",
|
||||
"edit": "Düzenle",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU exporter"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "Chưa lưu: người khác đã lưu bảng điều khiển này trước.",
|
||||
"notSavedChange": "Chưa lưu: {change}. Người khác đã lưu bảng điều khiển này trước.",
|
||||
"resolve": "Giải quyết",
|
||||
"title": "Bảng điều khiển này đã thay đổi kể từ khi bạn mở",
|
||||
"savedBy": "Người khác đã lưu {relative} ({time})",
|
||||
"savedByUnknown": "Người khác đã lưu kể từ khi bạn mở",
|
||||
"checking": "Đang kiểm tra thay đổi…",
|
||||
"loadFailed": "Không thể tải phiên bản mới nhất. Chỉnh sửa của bạn vẫn còn. Hãy thử lại.",
|
||||
"savedAgain": "Có người đã lưu lại trong khi bạn đang xử lý. Hãy xem các thay đổi mới nhất và chọn lại.",
|
||||
"saveFailed": "Không thể lưu: {message}. Chỉnh sửa của bạn vẫn còn.",
|
||||
"panelTouched": "Bảng của bạn cũng đã bị người khác thay đổi",
|
||||
"panelUntouched": "Bảng của bạn không bị người khác thay đổi",
|
||||
"whatChanged": "Những gì đã thay đổi kể từ khi bạn tải",
|
||||
"nothingChanged": "Không có khác biệt về bảng, tab, biến hoặc cài đặt.",
|
||||
"baseUnknown": "Phiên bản bạn đã tải không còn khả dụng nên không thể liệt kê thay đổi.",
|
||||
"kindPanel": "Bảng",
|
||||
"kindTab": "Tab",
|
||||
"kindVariable": "Biến",
|
||||
"added": "Đã thêm",
|
||||
"changed": "Đã thay đổi",
|
||||
"removed": "Đã xóa",
|
||||
"panelCount": "{count} bảng",
|
||||
"tabCount": "{count} tab",
|
||||
"variableCount": "{count} biến",
|
||||
"chipAdded": "Đã thêm {items}",
|
||||
"chipChanged": "Đã thay đổi {items}",
|
||||
"chipRemoved": "Đã xóa {items}",
|
||||
"mergeUnavailable": "Không thể đặt bảng của bạn vào phiên bản mới nhất. Hãy tải lại hoặc ghi đè.",
|
||||
"reload": "Tải lại",
|
||||
"reloadHint": "Bỏ chỉnh sửa của bạn và tải phiên bản mới nhất tại đây.",
|
||||
"overwrite": "Ghi đè",
|
||||
"overwriteHint": "Thay bản lưu của họ bằng của bạn.",
|
||||
"merge": "Hợp nhất",
|
||||
"mergeHint": "Đặt bảng của bạn vào phiên bản mới nhất và hiển thị kết quả trước khi lưu.",
|
||||
"back": "Quay lại",
|
||||
"retry": "Thử lại",
|
||||
"overwriteRemoves": "Ghi đè sẽ xóa {items}",
|
||||
"overwriteReplaces": "Ghi đè sẽ thay bản lưu của họ",
|
||||
"impactRemoves": "Xóa {items} do người khác thêm: {names}",
|
||||
"impactReverts": "Hoàn tác {items} họ đã sửa: {names}",
|
||||
"impactDeletesEdited": "Xóa {items} họ đã sửa: {names}",
|
||||
"impactRestores": "Khôi phục {items} họ đã xóa: {names}",
|
||||
"impactUnknown": "Không thể liệt kê thay đổi của họ; tất cả sẽ bị thay thế.",
|
||||
"cannotUndo": "Không thể hoàn tác thao tác này tại đây.",
|
||||
"removeAndOverwrite": "Xóa và ghi đè",
|
||||
"reviewIntro": "Bảng của bạn đã được áp dụng lên phiên bản mới nhất; các thay đổi khác của họ được giữ lại. Hãy xem lại rồi lưu hoặc bỏ.",
|
||||
"reviewHeading": "Bảng điều khiển sau khi hợp nhất",
|
||||
"tagYours": "Của bạn",
|
||||
"tagTheirsNew": "Của họ, mới",
|
||||
"tagTheirsEdited": "Của họ, đã sửa",
|
||||
"discard": "Bỏ",
|
||||
"saveMerged": "Lưu bản hợp nhất",
|
||||
"toastReloaded": "Đã tải phiên bản mới nhất. Chỉnh sửa của bạn đã bị bỏ.",
|
||||
"toastOverwritten": "Đã ghi đè. Các thay đổi lưu lúc {time} đã được thay bằng của bạn.",
|
||||
"toastOverwrittenNoTime": "Đã ghi đè. Thay đổi của họ đã được thay bằng của bạn.",
|
||||
"toastMerged": "Đã lưu phiên bản hợp nhất.",
|
||||
"toastMergeDiscarded": "Đã bỏ hợp nhất. Chỉnh sửa của bạn vẫn được giữ và chưa được lưu.",
|
||||
"yourChange": "Thay đổi chưa lưu của bạn: {change}",
|
||||
"stillUnresolved": "Xung đột này vẫn chưa được giải quyết nên chưa lưu. Hãy chọn cách lưu.",
|
||||
"toastReloadedChange": "Đã tải phiên bản mới nhất. Đã bỏ thay đổi của bạn: {change}",
|
||||
"renamedFrom": "{name} (trước đây là {from})",
|
||||
"kindSettings": "Cài đặt",
|
||||
"settingTitle": "tên",
|
||||
"settingDescription": "mô tả",
|
||||
"settingTimeRange": "khoảng thời gian mặc định",
|
||||
"settingDynamicFilters": "bộ lọc động",
|
||||
"settingOther": "cài đặt khác",
|
||||
"impactSettings": "Thay thế các cài đặt bảng điều khiển họ đã đổi: {settings}",
|
||||
"impactNothing": "Bản lưu của họ sẽ được thay bằng của bạn.",
|
||||
"compareField": "Bảng của bạn",
|
||||
"compareTheirs": "Phiên bản của họ",
|
||||
"compareYours": "Phiên bản của bạn",
|
||||
"compareTitle": "Tiêu đề",
|
||||
"compareOther": "Truy vấn và cài đặt",
|
||||
"compareSame": "Giống của họ",
|
||||
"compareDifferent": "Khác",
|
||||
"compareRemoved": "Họ đã xóa",
|
||||
"change": {
|
||||
"editedJson": "Đã sửa JSON của bảng điều khiển",
|
||||
"editedPanel": "Đã sửa bảng \"{name}\"",
|
||||
"addedPanel": "Đã thêm bảng \"{name}\"",
|
||||
"movedPanels": "Đã di chuyển hoặc đổi kích thước bảng",
|
||||
"deletedPanel": "Đã xóa bảng \"{name}\"",
|
||||
"movedPanel": "Đã chuyển bảng \"{name}\" sang tab khác",
|
||||
"duplicatedPanel": "Đã nhân bản bảng \"{name}\"",
|
||||
"addedTab": "Đã thêm tab \"{name}\"",
|
||||
"renamedTab": "Đã đổi tên tab \"{from}\" thành \"{to}\"",
|
||||
"reorderedTabs": "Đã sắp xếp lại các tab",
|
||||
"deletedTab": "Đã xóa tab \"{name}\"",
|
||||
"editedVariable": "Đã sửa biến \"{name}\"",
|
||||
"addedVariable": "Đã thêm biến \"{name}\"",
|
||||
"reorderedVariables": "Đã sắp xếp lại các biến",
|
||||
"deletedVariable": "Đã xóa biến \"{name}\"",
|
||||
"changedSettings": "Đã thay đổi cài đặt bảng điều khiển"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "Chỉnh sửa bố cục",
|
||||
"edit": "Chỉnh sửa",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU 导出器"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "未保存:其他人先保存了此仪表板。",
|
||||
"notSavedChange": "未保存:{change}。其他人先保存了此仪表板。",
|
||||
"resolve": "解决",
|
||||
"title": "此仪表板在你打开后已被更改",
|
||||
"savedBy": "其他人于{relative}保存({time})",
|
||||
"savedByUnknown": "在你打开后其他人已保存",
|
||||
"checking": "正在检查更改…",
|
||||
"loadFailed": "无法加载最新版本。你的编辑仍在。请重试。",
|
||||
"savedAgain": "在你处理期间有人再次保存。请查看最新更改并重新选择。",
|
||||
"saveFailed": "无法保存:{message}。你的编辑仍在。",
|
||||
"panelTouched": "你的面板也被其他人更改了",
|
||||
"panelUntouched": "你的面板未被其他人更改",
|
||||
"whatChanged": "加载后发生的更改",
|
||||
"nothingChanged": "面板、标签页、变量或设置均无差异。",
|
||||
"baseUnknown": "你加载的版本已不可用,因此无法列出更改。",
|
||||
"kindPanel": "面板",
|
||||
"kindTab": "标签页",
|
||||
"kindVariable": "变量",
|
||||
"added": "已添加",
|
||||
"changed": "已更改",
|
||||
"removed": "已删除",
|
||||
"panelCount": "{count} 个面板",
|
||||
"tabCount": "{count} 个标签页",
|
||||
"variableCount": "{count} 个变量",
|
||||
"chipAdded": "添加了{items}",
|
||||
"chipChanged": "更改了{items}",
|
||||
"chipRemoved": "删除了{items}",
|
||||
"mergeUnavailable": "无法将你的面板放入最新版本。请改为重新加载或覆盖。",
|
||||
"reload": "重新加载",
|
||||
"reloadHint": "放弃你的编辑并在此加载最新版本。",
|
||||
"overwrite": "覆盖",
|
||||
"overwriteHint": "用你的版本替换他人的保存。",
|
||||
"merge": "合并",
|
||||
"mergeHint": "将你的面板放入最新版本,并在保存前显示结果。",
|
||||
"back": "返回",
|
||||
"retry": "重试",
|
||||
"overwriteRemoves": "覆盖将删除{items}",
|
||||
"overwriteReplaces": "覆盖将替换他人的保存",
|
||||
"impactRemoves": "删除他人添加的{items}:{names}",
|
||||
"impactReverts": "还原他人编辑的{items}:{names}",
|
||||
"impactDeletesEdited": "删除他人编辑过的{items}:{names}",
|
||||
"impactRestores": "恢复他人删除的{items}:{names}",
|
||||
"impactUnknown": "无法列出他人的更改;将全部替换。",
|
||||
"cannotUndo": "此操作无法在此撤销。",
|
||||
"removeAndOverwrite": "删除并覆盖",
|
||||
"reviewIntro": "你的面板已应用到最新版本上,他人的其他更改已包含在内。请检查后保存或放弃。",
|
||||
"reviewHeading": "合并后的仪表板",
|
||||
"tagYours": "你的",
|
||||
"tagTheirsNew": "他人,新增",
|
||||
"tagTheirsEdited": "他人,已编辑",
|
||||
"discard": "放弃",
|
||||
"saveMerged": "保存合并",
|
||||
"toastReloaded": "已加载最新版本。你的编辑已放弃。",
|
||||
"toastOverwritten": "已覆盖。{time} 保存的更改已被你的版本替换。",
|
||||
"toastOverwrittenNoTime": "已覆盖。他人的更改已被你的版本替换。",
|
||||
"toastMerged": "已保存合并后的版本。",
|
||||
"toastMergeDiscarded": "已放弃合并。你的编辑仍保留,尚未保存。",
|
||||
"yourChange": "你未保存的更改:{change}",
|
||||
"stillUnresolved": "此冲突仍未解决,因此未保存。请选择保存方式。",
|
||||
"toastReloadedChange": "已加载最新版本。已放弃你的更改:{change}",
|
||||
"renamedFrom": "{name}(原名 {from})",
|
||||
"kindSettings": "设置",
|
||||
"settingTitle": "名称",
|
||||
"settingDescription": "描述",
|
||||
"settingTimeRange": "默认时间范围",
|
||||
"settingDynamicFilters": "动态筛选",
|
||||
"settingOther": "其他设置",
|
||||
"impactSettings": "替换他人更改的仪表板设置:{settings}",
|
||||
"impactNothing": "他人的保存将被你的版本替换。",
|
||||
"compareField": "你的面板",
|
||||
"compareTheirs": "他人的版本",
|
||||
"compareYours": "你的版本",
|
||||
"compareTitle": "标题",
|
||||
"compareOther": "查询和设置",
|
||||
"compareSame": "与他人相同",
|
||||
"compareDifferent": "不同",
|
||||
"compareRemoved": "已被他人删除",
|
||||
"change": {
|
||||
"editedJson": "编辑了仪表板 JSON",
|
||||
"editedPanel": "编辑了面板“{name}”",
|
||||
"addedPanel": "添加了面板“{name}”",
|
||||
"movedPanels": "移动或调整了面板大小",
|
||||
"deletedPanel": "删除了面板“{name}”",
|
||||
"movedPanel": "将面板“{name}”移到了其他标签页",
|
||||
"duplicatedPanel": "复制了面板“{name}”",
|
||||
"addedTab": "添加了标签页“{name}”",
|
||||
"renamedTab": "将标签页“{from}”重命名为“{to}”",
|
||||
"reorderedTabs": "重新排列了标签页",
|
||||
"deletedTab": "删除了标签页“{name}”",
|
||||
"editedVariable": "编辑了变量“{name}”",
|
||||
"addedVariable": "添加了变量“{name}”",
|
||||
"reorderedVariables": "重新排列了变量",
|
||||
"deletedVariable": "删除了变量“{name}”",
|
||||
"changedSettings": "更改了仪表板设置"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "编辑布局",
|
||||
"edit": "编辑",
|
||||
|
||||
@@ -5763,6 +5763,103 @@
|
||||
"gpuExporter": "GPU 匯出器"
|
||||
},
|
||||
"dashboard": {
|
||||
"saveConflict": {
|
||||
"notSaved": "未儲存:其他人先儲存了此儀表板。",
|
||||
"notSavedChange": "未儲存:{change}。其他人先儲存了此儀表板。",
|
||||
"resolve": "解決",
|
||||
"title": "此儀表板在你開啟後已被變更",
|
||||
"savedBy": "其他人於{relative}儲存({time})",
|
||||
"savedByUnknown": "在你開啟後其他人已儲存",
|
||||
"checking": "正在檢查變更…",
|
||||
"loadFailed": "無法載入最新版本。你的編輯仍在。請重試。",
|
||||
"savedAgain": "在你處理期間有人再次儲存。請檢視最新變更並重新選擇。",
|
||||
"saveFailed": "無法儲存:{message}。你的編輯仍在。",
|
||||
"panelTouched": "你的面板也被其他人變更了",
|
||||
"panelUntouched": "你的面板未被其他人變更",
|
||||
"whatChanged": "載入後發生的變更",
|
||||
"nothingChanged": "面板、分頁、變數或設定皆無差異。",
|
||||
"baseUnknown": "你載入的版本已無法使用,因此無法列出變更。",
|
||||
"kindPanel": "面板",
|
||||
"kindTab": "分頁",
|
||||
"kindVariable": "變數",
|
||||
"added": "已新增",
|
||||
"changed": "已變更",
|
||||
"removed": "已刪除",
|
||||
"panelCount": "{count} 個面板",
|
||||
"tabCount": "{count} 個分頁",
|
||||
"variableCount": "{count} 個變數",
|
||||
"chipAdded": "新增了{items}",
|
||||
"chipChanged": "變更了{items}",
|
||||
"chipRemoved": "刪除了{items}",
|
||||
"mergeUnavailable": "無法將你的面板放入最新版本。請改為重新載入或覆寫。",
|
||||
"reload": "重新載入",
|
||||
"reloadHint": "捨棄你的編輯並在此載入最新版本。",
|
||||
"overwrite": "覆寫",
|
||||
"overwriteHint": "以你的版本取代他人的儲存。",
|
||||
"merge": "合併",
|
||||
"mergeHint": "將你的面板放入最新版本,並在儲存前顯示結果。",
|
||||
"back": "返回",
|
||||
"retry": "重試",
|
||||
"overwriteRemoves": "覆寫將刪除{items}",
|
||||
"overwriteReplaces": "覆寫將取代他人的儲存",
|
||||
"impactRemoves": "刪除他人新增的{items}:{names}",
|
||||
"impactReverts": "還原他人編輯的{items}:{names}",
|
||||
"impactDeletesEdited": "刪除他人編輯過的{items}:{names}",
|
||||
"impactRestores": "復原他人刪除的{items}:{names}",
|
||||
"impactUnknown": "無法列出他人的變更;將全部取代。",
|
||||
"cannotUndo": "此操作無法在此復原。",
|
||||
"removeAndOverwrite": "刪除並覆寫",
|
||||
"reviewIntro": "你的面板已套用到最新版本上,他人的其他變更已包含在內。請檢查後儲存或捨棄。",
|
||||
"reviewHeading": "合併後的儀表板",
|
||||
"tagYours": "你的",
|
||||
"tagTheirsNew": "他人,新增",
|
||||
"tagTheirsEdited": "他人,已編輯",
|
||||
"discard": "捨棄",
|
||||
"saveMerged": "儲存合併",
|
||||
"toastReloaded": "已載入最新版本。你的編輯已捨棄。",
|
||||
"toastOverwritten": "已覆寫。{time} 儲存的變更已被你的版本取代。",
|
||||
"toastOverwrittenNoTime": "已覆寫。他人的變更已被你的版本取代。",
|
||||
"toastMerged": "已儲存合併後的版本。",
|
||||
"toastMergeDiscarded": "已捨棄合併。你的編輯仍保留,尚未儲存。",
|
||||
"yourChange": "你未儲存的變更:{change}",
|
||||
"stillUnresolved": "此衝突仍未解決,因此未儲存。請選擇儲存方式。",
|
||||
"toastReloadedChange": "已載入最新版本。已捨棄你的變更:{change}",
|
||||
"renamedFrom": "{name}(原名 {from})",
|
||||
"kindSettings": "設定",
|
||||
"settingTitle": "名稱",
|
||||
"settingDescription": "說明",
|
||||
"settingTimeRange": "預設時間範圍",
|
||||
"settingDynamicFilters": "動態篩選",
|
||||
"settingOther": "其他設定",
|
||||
"impactSettings": "取代他人變更的儀表板設定:{settings}",
|
||||
"impactNothing": "他人的儲存將被你的版本取代。",
|
||||
"compareField": "你的面板",
|
||||
"compareTheirs": "他人的版本",
|
||||
"compareYours": "你的版本",
|
||||
"compareTitle": "標題",
|
||||
"compareOther": "查詢與設定",
|
||||
"compareSame": "與他人相同",
|
||||
"compareDifferent": "不同",
|
||||
"compareRemoved": "已被他人刪除",
|
||||
"change": {
|
||||
"editedJson": "編輯了儀表板 JSON",
|
||||
"editedPanel": "編輯了面板「{name}」",
|
||||
"addedPanel": "新增了面板「{name}」",
|
||||
"movedPanels": "移動或調整了面板大小",
|
||||
"deletedPanel": "刪除了面板「{name}」",
|
||||
"movedPanel": "將面板「{name}」移到其他分頁",
|
||||
"duplicatedPanel": "複製了面板「{name}」",
|
||||
"addedTab": "新增了分頁「{name}」",
|
||||
"renamedTab": "將分頁「{from}」重新命名為「{to}」",
|
||||
"reorderedTabs": "重新排列了分頁",
|
||||
"deletedTab": "刪除了分頁「{name}」",
|
||||
"editedVariable": "編輯了變數「{name}」",
|
||||
"addedVariable": "新增了變數「{name}」",
|
||||
"reorderedVariables": "重新排列了變數",
|
||||
"deletedVariable": "刪除了變數「{name}」",
|
||||
"changedSettings": "變更了儀表板設定"
|
||||
}
|
||||
},
|
||||
"layoutEdit": {
|
||||
"enter": "編輯版面配置",
|
||||
"edit": "編輯",
|
||||
|
||||
+146
-9
@@ -28,11 +28,26 @@ import { convertDashboardSchemaVersion } from "./dashboard/convertDashboardSchem
|
||||
import { normalizeReservedTimestampAlias } from "./dashboard/timestampAliasRewrite";
|
||||
import commonService from "../services/common";
|
||||
import type { TranslateFn } from "@/types/i18n";
|
||||
import type { Ref } from "vue";
|
||||
import { toRaw, type Ref } from "vue";
|
||||
import { dashboardSaveConflictState } from "@/composables/dashboard/dashboardSaveConflictState";
|
||||
|
||||
let moment: any;
|
||||
let momentInitialized = false;
|
||||
|
||||
export interface LoadedDashboardVersion {
|
||||
hash: string;
|
||||
updatedAt: number | null;
|
||||
snapshot: any;
|
||||
}
|
||||
|
||||
const loadedDashboardVersions = new Map<string, LoadedDashboardVersion>();
|
||||
|
||||
// A dashboard object that lost a save conflict keeps saving against its own base hash, so a stale screen can never silently overwrite.
|
||||
const staleDashboardHashes = new WeakMap<object, string>();
|
||||
|
||||
// A conflict closed without a choice keeps every later save on the old base hash, so it reopens the dialog instead of saving silently.
|
||||
const unresolvedDashboardConflicts = new Map<string, LoadedDashboardVersion>();
|
||||
|
||||
const importMoment = async () => {
|
||||
if (!momentInitialized) {
|
||||
const momentModule: any = await import("moment");
|
||||
@@ -301,11 +316,26 @@ export const addPanel = async (
|
||||
|
||||
// find tab from tabId
|
||||
const tab = getTabDataFromTabId(currentDashboard, tabId);
|
||||
if (!tab) return;
|
||||
|
||||
if (!tab.panels) {
|
||||
tab.panels = [];
|
||||
}
|
||||
|
||||
// A save that lost a conflict left this panel in the working copy; saving it again replaces it instead of adding a twin.
|
||||
const existingIndex = tab.panels.findIndex((panel: any) => panel.id === panelData.id);
|
||||
if (existingIndex !== -1) {
|
||||
panelData.layout = tab.panels[existingIndex].layout;
|
||||
tab.panels[existingIndex] = panelData;
|
||||
return await updateDashboard(
|
||||
store,
|
||||
store.state.selectedOrganization.identifier,
|
||||
dashboardId,
|
||||
currentDashboard,
|
||||
folderId ?? "default",
|
||||
);
|
||||
}
|
||||
|
||||
const { maxI, maxY, lastPanel } = getMaxIAndMaxYFromTab(tab);
|
||||
|
||||
const newLayoutObj = {
|
||||
@@ -393,6 +423,7 @@ export const deleteVariable = async (
|
||||
const variableIndex = currentDashboard.variables.list.findIndex(
|
||||
(variable: any) => variable.name == variableName,
|
||||
);
|
||||
if (variableIndex === -1) return;
|
||||
currentDashboard.variables.list.splice(variableIndex, 1);
|
||||
|
||||
return await updateDashboard(
|
||||
@@ -420,7 +451,9 @@ export const deletePanel = async (
|
||||
const tab = getTabDataFromTabId(currentDashboard, tabId);
|
||||
|
||||
//remove panel from current dashboard
|
||||
const panelIndex = tab.panels.findIndex((panel: any) => panel.id == panelId);
|
||||
const panelIndex = tab?.panels?.findIndex((panel: any) => panel.id == panelId) ?? -1;
|
||||
// The panel may already be gone from this copy; splice(-1, 1) would drop the last panel instead.
|
||||
if (panelIndex === -1) return;
|
||||
tab.panels.splice(panelIndex, 1);
|
||||
// currentDashboard.panels = currentDashboard.panels;
|
||||
|
||||
@@ -461,6 +494,7 @@ export const updateVariable = async (
|
||||
const variableIndex = currentDashboard.variables.list.findIndex(
|
||||
(variable: any) => variable.name == variableName,
|
||||
);
|
||||
if (variableIndex === -1) return;
|
||||
//if name already exists
|
||||
const variableExists = currentDashboard.variables.list.filter(
|
||||
(it: any) => it.name == variableData.name,
|
||||
@@ -497,7 +531,8 @@ export const updatePanel = async (
|
||||
// find tab from tabId
|
||||
const tab = getTabDataFromTabId(currentDashboard, tabId);
|
||||
|
||||
const panelIndex = tab.panels.findIndex((panel: any) => panel.id == panelData.id);
|
||||
const panelIndex = tab?.panels?.findIndex((panel: any) => panel.id == panelData.id) ?? -1;
|
||||
if (panelIndex === -1) return;
|
||||
tab.panels[panelIndex] = panelData;
|
||||
return await updateDashboard(
|
||||
store,
|
||||
@@ -508,6 +543,16 @@ export const updatePanel = async (
|
||||
);
|
||||
};
|
||||
|
||||
const clearUnresolvedDashboardConflict = (dashboardId: any) => {
|
||||
unresolvedDashboardConflicts.delete(String(dashboardId));
|
||||
if (dashboardSaveConflictState.unresolvedDashboardId === String(dashboardId))
|
||||
dashboardSaveConflictState.unresolvedDashboardId = "";
|
||||
};
|
||||
|
||||
// The dashboard model serialises camelCase; snake_case is kept for older servers.
|
||||
const updatedAtOf = (data: any): number | null =>
|
||||
Number(data?.updatedAt ?? data?.updated_at) || null;
|
||||
|
||||
const retrieveAndStoreDashboardData = async (
|
||||
store: any,
|
||||
dashboardId: any,
|
||||
@@ -521,6 +566,13 @@ const retrieveAndStoreDashboardData = async (
|
||||
|
||||
const convertedData = convertDashboardSchemaVersion(dashboardData);
|
||||
|
||||
loadedDashboardVersions.clear();
|
||||
loadedDashboardVersions.set(String(dashboardId), {
|
||||
hash,
|
||||
updatedAt: updatedAtOf(apiResponse.data),
|
||||
snapshot: structuredClone(toRaw(convertedData ?? null)),
|
||||
});
|
||||
|
||||
store.dispatch("setAllDashboardListHash", {
|
||||
[dashboardId]: hash,
|
||||
});
|
||||
@@ -539,14 +591,25 @@ export const updateDashboard = async (
|
||||
dashboardId: any,
|
||||
currentDashboardData: any,
|
||||
folderId: any,
|
||||
hash?: string,
|
||||
) => {
|
||||
const isObject = !!currentDashboardData && typeof currentDashboardData === "object";
|
||||
// The server only stamps updatedAt on create and stores the client's value on update, so the save carries its own time.
|
||||
const body = isObject
|
||||
? { ...toRaw(currentDashboardData), updatedAt: Date.now() * 1000 }
|
||||
: currentDashboardData;
|
||||
const res = await dashboardService.save(
|
||||
org,
|
||||
dashboardId,
|
||||
currentDashboardData,
|
||||
body,
|
||||
folderId,
|
||||
store.state.organizationData.allDashboardListHash[dashboardId],
|
||||
hash ??
|
||||
(isObject ? staleDashboardHashes.get(toRaw(currentDashboardData)) : undefined) ??
|
||||
unresolvedDashboardConflicts.get(String(dashboardId))?.hash ??
|
||||
store.state.organizationData.allDashboardListHash[dashboardId],
|
||||
);
|
||||
if (isObject) staleDashboardHashes.delete(toRaw(currentDashboardData));
|
||||
clearUnresolvedDashboardConflict(dashboardId);
|
||||
|
||||
const apiResponse = await dashboardService.get_Dashboard(
|
||||
store.state.selectedOrganization.identifier,
|
||||
@@ -560,6 +623,74 @@ export const updateDashboard = async (
|
||||
return res;
|
||||
};
|
||||
|
||||
/** The server version the cached dashboard was last loaded from, captured before any in-place edit. */
|
||||
export const getLoadedDashboardVersion = (dashboardId: any): LoadedDashboardVersion | null =>
|
||||
loadedDashboardVersions.get(String(dashboardId)) ?? null;
|
||||
|
||||
/** This object keeps saving with its old hash, so it conflicts again instead of overwriting. */
|
||||
export const markDashboardStale = (dashboardData: any, hash: string | undefined) => {
|
||||
if (!dashboardData || typeof dashboardData !== "object" || !hash) return;
|
||||
staleDashboardHashes.set(toRaw(dashboardData), hash);
|
||||
};
|
||||
|
||||
export const getUnresolvedDashboardConflict = (dashboardId: any) =>
|
||||
unresolvedDashboardConflicts.get(String(dashboardId)) ?? null;
|
||||
|
||||
/** Records the base an unresolved conflict saves against; null clears it. */
|
||||
export const setUnresolvedDashboardConflict = (
|
||||
dashboardId: any,
|
||||
base: LoadedDashboardVersion | null,
|
||||
) => {
|
||||
if (base) unresolvedDashboardConflicts.set(String(dashboardId), base);
|
||||
else clearUnresolvedDashboardConflict(dashboardId);
|
||||
};
|
||||
|
||||
/** Reads the server's current version without touching the cache, which stays the user's working copy. */
|
||||
export const fetchServerDashboard = async (store: any, dashboardId: any, folderId: any) => {
|
||||
const apiResponse = await dashboardService.get_Dashboard(
|
||||
store.state.selectedOrganization.identifier,
|
||||
dashboardId,
|
||||
folderId,
|
||||
);
|
||||
const version = apiResponse.data.version;
|
||||
return {
|
||||
data: convertDashboardSchemaVersion(apiResponse.data[`v${version}`]),
|
||||
hash: apiResponse.data.hash.toString() as string,
|
||||
updatedAt: updatedAtOf(apiResponse.data),
|
||||
};
|
||||
};
|
||||
|
||||
/** Makes the cached dashboard equal to `source` in place, so views holding the cached object show it too. */
|
||||
export const adoptDashboardWorkingCopy = (store: any, dashboardId: any, source: any) => {
|
||||
const target = store.state.organizationData.allDashboardData?.[dashboardId];
|
||||
if (!target || !source || toRaw(target) === toRaw(source)) return;
|
||||
const next = JSON.parse(JSON.stringify(toRaw(source)));
|
||||
// The source can be JSON-editor input, so keys that reach Object.prototype are never copied or deleted.
|
||||
for (const key of Object.keys(target)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(next, key)) delete target[key];
|
||||
}
|
||||
for (const key of Object.keys(next)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
||||
target[key] = next[key];
|
||||
}
|
||||
};
|
||||
|
||||
/** Re-fetches a dashboard into the store, replacing the cached copy and its hash. */
|
||||
export const fetchLatestDashboard = async (store: any, dashboardId: any, folderId: any) => {
|
||||
const apiResponse = await dashboardService.get_Dashboard(
|
||||
store.state.selectedOrganization.identifier,
|
||||
dashboardId,
|
||||
folderId,
|
||||
);
|
||||
const data = await retrieveAndStoreDashboardData(store, dashboardId, folderId, apiResponse);
|
||||
return {
|
||||
data,
|
||||
hash: apiResponse.data.hash.toString() as string,
|
||||
updatedAt: updatedAtOf(apiResponse.data),
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to ensure variables structure exists with proper defaults
|
||||
const ensureVariablesStructure = (dashboard: any): void => {
|
||||
if (!dashboard.variables) {
|
||||
@@ -584,6 +715,7 @@ export const getDashboard = async (store: any, dashboardId: any, folderId: any)
|
||||
let dashboardJson = store.state.organizationData.allDashboardData[dashboardId];
|
||||
|
||||
if (!dashboardJson && dashboardId) {
|
||||
clearUnresolvedDashboardConflict(dashboardId);
|
||||
const apiResponse = await dashboardService.get_Dashboard(
|
||||
store.state.selectedOrganization.identifier,
|
||||
dashboardId,
|
||||
@@ -849,22 +981,25 @@ export const deleteTab = async (
|
||||
moveToTabId?: any,
|
||||
) => {
|
||||
const currentDashboard = await getDashboard(store, dashboardId, folderId);
|
||||
const deleteTabData = getTabDataFromTabId(currentDashboard, deleteTabId);
|
||||
if (!deleteTabData) return;
|
||||
|
||||
if (moveToTabId) {
|
||||
const deleteTabData = getTabDataFromTabId(currentDashboard, deleteTabId);
|
||||
// move panels to other tab
|
||||
const moveToTabData = getTabDataFromTabId(currentDashboard, moveToTabId);
|
||||
if (!moveToTabData) return;
|
||||
if (!moveToTabData.panels) moveToTabData.panels = [];
|
||||
|
||||
let { maxI, maxY } = getMaxIAndMaxYFromTab(moveToTabData);
|
||||
|
||||
// for each panel, need to recalculate layout object
|
||||
deleteTabData.panels.forEach((panel: any) => {
|
||||
(deleteTabData.panels ?? []).forEach((panel: any) => {
|
||||
maxY += 20;
|
||||
panel.layout.i = ++maxI;
|
||||
panel.layout.y = maxY;
|
||||
});
|
||||
|
||||
moveToTabData.panels.push(...deleteTabData.panels);
|
||||
moveToTabData.panels.push(...(deleteTabData.panels ?? []));
|
||||
}
|
||||
// delete the tab
|
||||
currentDashboard.tabs = currentDashboard.tabs.filter((tab: any) => tab.tabId != deleteTabId);
|
||||
@@ -887,6 +1022,7 @@ export const editTab = async (
|
||||
) => {
|
||||
const currentDashboardData = await getDashboard(store, dashboardId, folderId);
|
||||
const tab = getTabDataFromTabId(currentDashboardData, tabId);
|
||||
if (!tab) return;
|
||||
|
||||
// only name will change
|
||||
tab.name = tabData.name;
|
||||
@@ -938,7 +1074,8 @@ export const movePanelToAnotherTab = async (
|
||||
const moveToTabData = getTabDataFromTabId(currentDashboard, moveToTabId);
|
||||
|
||||
// panel data
|
||||
const panelData = currentTabData.panels.find((it: any) => it.id == panelId);
|
||||
const panelData = currentTabData?.panels?.find((it: any) => it.id == panelId);
|
||||
if (!moveToTabData || !panelData) return;
|
||||
|
||||
// delete panel in currentTab
|
||||
currentTabData.panels = currentTabData.panels.filter((panel: any) => panel.id != panelId);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
comparePanel,
|
||||
countChanges,
|
||||
diffDashboards,
|
||||
formatClockTime,
|
||||
formatRelativeTime,
|
||||
isPanelTouched,
|
||||
mergePanelOntoLatest,
|
||||
toEpochMs,
|
||||
} from "./saveConflict";
|
||||
|
||||
const panel = (id: string, title: string, layout = { x: 0, y: 0, w: 96, h: 18, i: 1 }) => ({
|
||||
id,
|
||||
title,
|
||||
type: "line",
|
||||
queries: [{ query: `select ${id}` }],
|
||||
layout,
|
||||
});
|
||||
|
||||
const base = () => ({
|
||||
dashboardId: "d1",
|
||||
title: "Ops overview",
|
||||
description: "",
|
||||
tabs: [
|
||||
{
|
||||
tabId: "default",
|
||||
name: "Overview",
|
||||
panels: [
|
||||
panel("p1", "Error rate", { x: 0, y: 0, w: 96, h: 18, i: 1 }),
|
||||
panel("p2", "Latency", { x: 96, y: 0, w: 96, h: 18, i: 2 }),
|
||||
],
|
||||
},
|
||||
],
|
||||
variables: { showDynamicFilters: false, list: [{ name: "env", value: "prod" }] },
|
||||
});
|
||||
|
||||
describe("diffDashboards", () => {
|
||||
it("lists panels, tabs and variables added, changed and removed by id", () => {
|
||||
const latest = base();
|
||||
latest.tabs[0].panels[1].title = "Latency p99";
|
||||
latest.tabs[0].panels.push(panel("p3", "Disk usage", { x: 0, y: 18, w: 96, h: 18, i: 3 }));
|
||||
latest.tabs.push({ tabId: "t2", name: "Database", panels: [] });
|
||||
latest.variables.list = [{ name: "region", value: "us" }];
|
||||
|
||||
const { changes, changedSettings } = diffDashboards(base(), latest);
|
||||
|
||||
expect(changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
kind: "panel",
|
||||
change: "changed",
|
||||
key: "p2",
|
||||
name: "Latency p99",
|
||||
previousName: "Latency",
|
||||
},
|
||||
{ kind: "panel", change: "added", key: "p3", name: "Disk usage" },
|
||||
{ kind: "tab", change: "added", key: "t2", name: "Database" },
|
||||
{ kind: "variable", change: "added", key: "region", name: "region" },
|
||||
{ kind: "variable", change: "removed", key: "env", name: "env" },
|
||||
]),
|
||||
);
|
||||
expect(changes).toHaveLength(5);
|
||||
expect(changedSettings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not report a tab as changed when only its panels changed", () => {
|
||||
const latest = base();
|
||||
latest.tabs[0].panels[0].title = "Errors";
|
||||
const { changes } = diffDashboards(base(), latest);
|
||||
expect(changes.map((c) => c.kind)).toEqual(["panel"]);
|
||||
});
|
||||
|
||||
it("names which dashboard settings changed, ignoring server-managed fields", () => {
|
||||
const latest: any = base();
|
||||
latest.title = "Renamed";
|
||||
latest.description = "Now with SLOs";
|
||||
latest.variables.showDynamicFilters = true;
|
||||
latest.owner = "someone";
|
||||
latest.updatedAt = 123;
|
||||
const diff = diffDashboards(base(), latest);
|
||||
expect(diff.changes).toEqual([]);
|
||||
expect(diff.changedSettings).toEqual(["title", "description", "showDynamicFilters"]);
|
||||
|
||||
latest.refreshInterval = "5m";
|
||||
expect(diffDashboards(base(), latest).changedSettings).toContain("other");
|
||||
});
|
||||
|
||||
it("records the old name of a renamed tab", () => {
|
||||
const latest = base();
|
||||
latest.tabs[0].name = "Overview (prod)";
|
||||
expect(diffDashboards(base(), latest).changes).toEqual([
|
||||
{
|
||||
kind: "tab",
|
||||
change: "changed",
|
||||
key: "default",
|
||||
name: "Overview (prod)",
|
||||
previousName: "Overview",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores key order when comparing", () => {
|
||||
const latest: any = base();
|
||||
const p = latest.tabs[0].panels[0];
|
||||
latest.tabs[0].panels[0] = Object.fromEntries(Object.entries(p).reverse());
|
||||
expect(diffDashboards(base(), latest).changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts changes per kind and type", () => {
|
||||
const latest = base();
|
||||
latest.tabs[0].panels = [];
|
||||
const counts = countChanges(diffDashboards(base(), latest).changes);
|
||||
expect(counts.panel.removed).toBe(2);
|
||||
expect(counts.tab.changed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparePanel", () => {
|
||||
it("shows their title against mine and whether the rest differs", () => {
|
||||
const mine = base();
|
||||
mine.tabs[0].panels[0].title = "Errors (mine)";
|
||||
const latest = base();
|
||||
latest.tabs[0].panels[0].title = "Errors (theirs)";
|
||||
latest.tabs[0].panels[0].queries = [{ query: "select theirs" }];
|
||||
|
||||
expect(comparePanel(latest, mine, "p1")).toEqual({
|
||||
theirsRemoved: false,
|
||||
theirsTitle: "Errors (theirs)",
|
||||
mineTitle: "Errors (mine)",
|
||||
sameTitle: false,
|
||||
sameOtherFields: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a panel they deleted", () => {
|
||||
const latest = base();
|
||||
latest.tabs[0].panels.shift();
|
||||
expect(comparePanel(latest, base(), "p1")).toMatchObject({
|
||||
theirsRemoved: true,
|
||||
mineTitle: "Error rate",
|
||||
});
|
||||
expect(comparePanel(latest, base(), undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPanelTouched", () => {
|
||||
it("is true when they edited or removed my panel, never for their additions", () => {
|
||||
const latest = base();
|
||||
latest.tabs[0].panels[0].title = "Errors";
|
||||
const { changes } = diffDashboards(base(), latest);
|
||||
expect(isPanelTouched(changes, "p1")).toBe(true);
|
||||
expect(isPanelTouched(changes, "p2")).toBe(false);
|
||||
expect(isPanelTouched(changes, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePanelOntoLatest", () => {
|
||||
it("replaces their copy of my panel in place and keeps their other changes", () => {
|
||||
const mine = base();
|
||||
mine.tabs[0].panels[0].queries = [{ query: "select mine" }];
|
||||
const latest = base();
|
||||
latest.tabs[0].panels.push(panel("p3", "Disk usage", { x: 0, y: 18, w: 96, h: 18, i: 3 }));
|
||||
latest.tabs[0].panels[1].title = "Latency p99";
|
||||
|
||||
const merged = mergePanelOntoLatest(latest, mine, "p1", base());
|
||||
|
||||
const panels = merged.tabs[0].panels;
|
||||
expect(panels.map((p: any) => p.id)).toEqual(["p1", "p2", "p3"]);
|
||||
expect(panels[0].queries[0].query).toBe("select mine");
|
||||
expect(panels[1].title).toBe("Latency p99");
|
||||
expect(latest.tabs[0].panels[0].queries[0].query).toBe("select p1");
|
||||
});
|
||||
|
||||
it("moves a new panel below theirs when its slot was taken", () => {
|
||||
const mine = base();
|
||||
mine.tabs[0].panels.push(panel("pNew", "Mine", { x: 0, y: 18, w: 96, h: 18, i: 3 }));
|
||||
const latest = base();
|
||||
latest.tabs[0].panels.push(panel("p3", "Theirs", { x: 0, y: 18, w: 96, h: 18, i: 3 }));
|
||||
|
||||
const merged = mergePanelOntoLatest(latest, mine, "pNew", base());
|
||||
const added = merged.tabs[0].panels.find((p: any) => p.id === "pNew");
|
||||
|
||||
expect(added.layout).toMatchObject({ x: 0, y: 36, i: 4 });
|
||||
});
|
||||
|
||||
it("re-adds my panel when they deleted it, and to the first tab when its tab is gone", () => {
|
||||
const mine = base();
|
||||
mine.tabs.push({ tabId: "t2", name: "Mine", panels: [panel("p9", "Moved")] });
|
||||
const latest = base();
|
||||
const merged = mergePanelOntoLatest(latest, mine, "p9", base());
|
||||
expect(merged.tabs[0].panels.map((p: any) => p.id)).toContain("p9");
|
||||
});
|
||||
|
||||
it("carries my new and edited variables, but not over their edits", () => {
|
||||
const mine = base();
|
||||
mine.variables.list = [
|
||||
{ name: "env", value: "staging" },
|
||||
{ name: "svc", value: "api" },
|
||||
];
|
||||
const latest = base();
|
||||
const merged = mergePanelOntoLatest(latest, mine, "p1", base());
|
||||
expect(merged.variables.list).toEqual([
|
||||
{ name: "env", value: "staging" },
|
||||
{ name: "svc", value: "api" },
|
||||
]);
|
||||
|
||||
latest.variables.list = [{ name: "env", value: "theirs" }];
|
||||
const kept = mergePanelOntoLatest(latest, mine, "p1", base());
|
||||
expect(kept.variables.list[0]).toEqual({ name: "env", value: "theirs" });
|
||||
});
|
||||
|
||||
it("returns null when my panel is not in my version", () => {
|
||||
expect(mergePanelOntoLatest(base(), base(), "missing", base())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("time helpers", () => {
|
||||
it("normalises backend microseconds and ignores unset values", () => {
|
||||
expect(toEpochMs(1_700_000_000_000_000)).toBe(1_700_000_000_000);
|
||||
expect(toEpochMs(1_700_000_000_000)).toBe(1_700_000_000_000);
|
||||
expect(toEpochMs(0)).toBeNull();
|
||||
expect(toEpochMs(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a past time relative to now", () => {
|
||||
const now = Date.UTC(2026, 9, 12, 14, 6);
|
||||
expect(formatRelativeTime(now - 4 * 60_000, "en-US", now)).toBe("4 minutes ago");
|
||||
expect(formatRelativeTime(now - 2 * 3_600_000, "en-US", now)).toBe("2 hours ago");
|
||||
expect(formatRelativeTime(now, "en-US", now)).toBe("now");
|
||||
});
|
||||
|
||||
it("adds the date only when the save was not today", () => {
|
||||
const now = new Date(2026, 9, 12, 14, 6).getTime();
|
||||
expect(formatClockTime(new Date(2026, 9, 12, 14, 2).getTime(), "en-US", now)).toBe("02:02 PM");
|
||||
expect(formatClockTime(new Date(2026, 9, 10, 9, 0).getTime(), "en-US", now)).toContain(
|
||||
"Oct 10, 2026",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Shapes taken from a real GET /dashboards/{id} response (pt-re-own-2) and the panel editor's state.
|
||||
const serverPanel = () => ({
|
||||
id: "Panel_A",
|
||||
type: "table",
|
||||
title: "pt-panel-a",
|
||||
description: "",
|
||||
config: {
|
||||
show_legends: true,
|
||||
legends_position: null,
|
||||
promql_legend: "",
|
||||
drilldown: [],
|
||||
connect_nulls: false,
|
||||
no_value_replacement: "",
|
||||
},
|
||||
queryType: "sql",
|
||||
queries: [
|
||||
{
|
||||
query: "SELECT 1 as x",
|
||||
vrlFunctionQuery: "",
|
||||
customQuery: true,
|
||||
fields: { stream: "default", stream_type: "logs", x: [], y: [], z: [], breakdown: [] },
|
||||
config: { promql_legend: "", limit: 0, time_shift: [] },
|
||||
},
|
||||
],
|
||||
layout: { x: 0, y: 0, w: 12, h: 8, i: 1 },
|
||||
htmlContent: "",
|
||||
markdownContent: "",
|
||||
customChartContent: "",
|
||||
});
|
||||
|
||||
const serverDashboard = () => ({
|
||||
version: 8,
|
||||
dashboardId: "ptre2own1",
|
||||
title: "pt-re-own-2",
|
||||
description: "orig desc",
|
||||
role: "",
|
||||
owner: "pt@russia.com",
|
||||
created: "2026-10-12T10:00:00Z",
|
||||
tabs: [{ tabId: "tab-one", name: "TabOne", panels: [serverPanel()] }],
|
||||
variables: { list: [], showDynamicFilters: false },
|
||||
defaultDatetimeDuration: { type: "relative", relativeTimePeriod: "15m" },
|
||||
});
|
||||
|
||||
describe("real-shaped payloads", () => {
|
||||
it("does not report the time range when a teammate saved General settings with only a new name", () => {
|
||||
const latest: any = serverDashboard();
|
||||
latest.title = "pt-re-own-2 renamed";
|
||||
latest.defaultDatetimeDuration = {
|
||||
startTime: 1_791_790_000_000_000,
|
||||
endTime: 1_791_790_900_000_000,
|
||||
relativeTimePeriod: "15m",
|
||||
type: "relative",
|
||||
};
|
||||
expect(diffDashboards(serverDashboard(), latest).changedSettings).toEqual(["title"]);
|
||||
});
|
||||
|
||||
it("still reports a real time-range change", () => {
|
||||
const relative: any = serverDashboard();
|
||||
relative.defaultDatetimeDuration = { type: "relative", relativeTimePeriod: "1h" };
|
||||
expect(diffDashboards(serverDashboard(), relative).changedSettings).toEqual([
|
||||
"defaultDatetimeDuration",
|
||||
]);
|
||||
|
||||
const absolute: any = serverDashboard();
|
||||
absolute.defaultDatetimeDuration = { type: "absolute", startTime: 1, endTime: 2 };
|
||||
expect(diffDashboards(serverDashboard(), absolute).changedSettings).toEqual([
|
||||
"defaultDatetimeDuration",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats null and missing as the same value", () => {
|
||||
const latest: any = serverDashboard();
|
||||
delete latest.tabs[0].panels[0].config.legends_position;
|
||||
expect(diffDashboards(serverDashboard(), latest).changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("says Same when only the title differs from the editor's copy of the panel", () => {
|
||||
const editorPanel: any = { ...serverPanel(), version: 8, customChartResult: {} };
|
||||
editorPanel.title = "pt-panel-a (mine)";
|
||||
editorPanel.config = { ...editorPanel.config, decimals: 2, legends_position: "auto" };
|
||||
const latest = serverDashboard();
|
||||
latest.tabs[0].panels[0].title = "pt-panel-a (theirs)";
|
||||
const mine: any = serverDashboard();
|
||||
mine.tabs[0].panels = [editorPanel];
|
||||
|
||||
expect(comparePanel(latest, mine, "Panel_A")).toMatchObject({
|
||||
sameTitle: false,
|
||||
sameOtherFields: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("says Different when the query differs", () => {
|
||||
const editorPanel: any = { ...serverPanel(), version: 8 };
|
||||
editorPanel.queries = [{ ...editorPanel.queries[0], query: "SELECT 2 as x" }];
|
||||
const mine: any = serverDashboard();
|
||||
mine.tabs[0].panels = [editorPanel];
|
||||
expect(comparePanel(serverDashboard(), mine, "Panel_A")?.sameOtherFields).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
export type ConflictItemKind = "panel" | "tab" | "variable";
|
||||
export type ConflictChangeType = "added" | "changed" | "removed";
|
||||
export type DashboardSettingKey =
|
||||
"title" | "description" | "defaultDatetimeDuration" | "showDynamicFilters" | "other";
|
||||
|
||||
export interface ConflictChange {
|
||||
kind: ConflictItemKind;
|
||||
change: ConflictChangeType;
|
||||
key: string;
|
||||
name: string;
|
||||
/** Set on a change when the name itself changed (a rename). */
|
||||
previousName?: string;
|
||||
}
|
||||
|
||||
export interface DashboardDiff {
|
||||
changes: ConflictChange[];
|
||||
changedSettings: DashboardSettingKey[];
|
||||
}
|
||||
|
||||
/** What saving my copy over theirs undoes of their save, each list naming their items. */
|
||||
export interface OverwriteImpact {
|
||||
/** Items they added that my copy does not have. */
|
||||
removes: ConflictChange[];
|
||||
/** Items they edited that my copy deletes. */
|
||||
deletesEdited: ConflictChange[];
|
||||
/** Items they edited that my copy replaces with my version. */
|
||||
reverts: ConflictChange[];
|
||||
/** Items they removed that my copy brings back. */
|
||||
restores: ConflictChange[];
|
||||
/** Dashboard settings they changed that my copy replaces. */
|
||||
settings: DashboardSettingKey[];
|
||||
}
|
||||
|
||||
export interface PanelComparison {
|
||||
theirsRemoved: boolean;
|
||||
theirsTitle: string;
|
||||
mineTitle: string;
|
||||
sameTitle: boolean;
|
||||
sameOtherFields: boolean;
|
||||
}
|
||||
|
||||
export type ConflictCounts = Record<ConflictItemKind, Record<ConflictChangeType, number>>;
|
||||
|
||||
interface PanelLocation {
|
||||
panel: any;
|
||||
tabId: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const GRID_COLUMNS = 192;
|
||||
|
||||
// Server-managed fields that are not settings a person edits.
|
||||
const NON_SETTING_FIELDS = new Set([
|
||||
"tabs",
|
||||
"variables",
|
||||
"dashboardId",
|
||||
"owner",
|
||||
"created",
|
||||
"role",
|
||||
"version",
|
||||
"hash",
|
||||
"updatedAt",
|
||||
]);
|
||||
|
||||
const KNOWN_SETTINGS: DashboardSettingKey[] = ["title", "description", "defaultDatetimeDuration"];
|
||||
|
||||
const clone = <T>(value: T): T => structuredClone(value ?? null) as T;
|
||||
|
||||
const isBlank = (value: unknown) => value === null || value === undefined;
|
||||
|
||||
// Null and missing are the same to the dashboard; editors and the server differ on which they write.
|
||||
const stable = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.keys(value as object)
|
||||
.filter((key) => !isBlank((value as Record<string, unknown>)[key]))
|
||||
.sort()
|
||||
.map((key) => [key, stable((value as Record<string, unknown>)[key])]),
|
||||
);
|
||||
};
|
||||
|
||||
// Key order differs between server JSON and editor state, so compare canonically.
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(stable(a)) === JSON.stringify(stable(b));
|
||||
|
||||
const isEmptyValue = (value: unknown) =>
|
||||
isBlank(value) ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0);
|
||||
|
||||
// The editor fills in defaults the saved panel never had, so only fields both sides set are compared.
|
||||
const sameWhereBothSet = (a: unknown, b: unknown): boolean => {
|
||||
if (isEmptyValue(a) && isEmptyValue(b)) return true;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((item, i) => sameWhereBothSet(item, b[i]));
|
||||
}
|
||||
if (a && b && typeof a === "object" && typeof b === "object") {
|
||||
const left = a as Record<string, unknown>;
|
||||
const right = b as Record<string, unknown>;
|
||||
return Object.keys(left)
|
||||
.filter((key) => !isEmptyValue(left[key]) && !isEmptyValue(right[key]))
|
||||
.every((key) => sameWhereBothSet(left[key], right[key]));
|
||||
}
|
||||
return a === b;
|
||||
};
|
||||
|
||||
// A relative range only means its period; the start/end a picker writes alongside it go stale at once.
|
||||
const timeRangeShape = (range: any) => {
|
||||
const type = range?.type ?? "relative";
|
||||
return type === "relative"
|
||||
? { type, period: range?.relativeTimePeriod ?? null }
|
||||
: { type, start: range?.startTime ?? null, end: range?.endTime ?? null };
|
||||
};
|
||||
|
||||
const settingValue = (doc: any, key: string) =>
|
||||
key === "defaultDatetimeDuration" ? timeRangeShape(doc?.[key]) : (doc?.[key] ?? null);
|
||||
|
||||
const tabsOf = (doc: any): any[] => (Array.isArray(doc?.tabs) ? doc.tabs : []);
|
||||
|
||||
const variablesOf = (doc: any): any[] =>
|
||||
Array.isArray(doc?.variables?.list) ? doc.variables.list : [];
|
||||
|
||||
const panelsById = (doc: any): Map<string, PanelLocation> => {
|
||||
const out = new Map<string, PanelLocation>();
|
||||
for (const tab of tabsOf(doc)) {
|
||||
(tab?.panels ?? []).forEach((panel: any, index: number) => {
|
||||
if (panel?.id != null) out.set(String(panel.id), { panel, tabId: tab.tabId, index });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const tabsById = (doc: any): Map<string, any> =>
|
||||
new Map(tabsOf(doc).map((tab: any) => [String(tab.tabId), tab]));
|
||||
|
||||
const variablesByName = (doc: any): Map<string, any> =>
|
||||
new Map(variablesOf(doc).map((v: any) => [String(v.name), v]));
|
||||
|
||||
// A tab's identity is its name and order; its panels are diffed separately.
|
||||
const tabShape = (tab: any) => ({ ...tab, panels: undefined });
|
||||
|
||||
const diffMaps = (
|
||||
kind: ConflictItemKind,
|
||||
before: Map<string, any>,
|
||||
after: Map<string, any>,
|
||||
nameOf: (item: any) => string,
|
||||
shape: (item: any) => unknown = (item) => item,
|
||||
): ConflictChange[] => {
|
||||
const out: ConflictChange[] = [];
|
||||
for (const [key, item] of after) {
|
||||
const prev = before.get(key);
|
||||
const name = nameOf(item) || key;
|
||||
if (!prev) out.push({ kind, change: "added", key, name });
|
||||
else if (!same(shape(prev), shape(item))) {
|
||||
const previousName = nameOf(prev) || key;
|
||||
out.push({
|
||||
kind,
|
||||
change: "changed",
|
||||
key,
|
||||
name,
|
||||
...(previousName !== name ? { previousName } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [key, item] of before) {
|
||||
if (!after.has(key)) out.push({ kind, change: "removed", key, name: nameOf(item) || key });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const changedSettingsOf = (base: any, latest: any): DashboardSettingKey[] => {
|
||||
const out: DashboardSettingKey[] = KNOWN_SETTINGS.filter(
|
||||
(key) => !same(settingValue(base, key), settingValue(latest, key)),
|
||||
);
|
||||
if (!same(!!base?.variables?.showDynamicFilters, !!latest?.variables?.showDynamicFilters))
|
||||
out.push("showDynamicFilters");
|
||||
const otherKeys = new Set([...Object.keys(base ?? {}), ...Object.keys(latest ?? {})]);
|
||||
const otherChanged = [...otherKeys].some(
|
||||
(key) =>
|
||||
!NON_SETTING_FIELDS.has(key) &&
|
||||
!KNOWN_SETTINGS.includes(key as DashboardSettingKey) &&
|
||||
!same(base?.[key] ?? null, latest?.[key] ?? null),
|
||||
);
|
||||
if (otherChanged) out.push("other");
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Panels, tabs and variables that differ between two dashboard versions, matched by id (variables by name). */
|
||||
export const diffDashboards = (base: any, latest: any): DashboardDiff => {
|
||||
const panelShape = (loc: PanelLocation) => ({ tabId: loc.tabId, panel: loc.panel });
|
||||
const changes = [
|
||||
...diffMaps(
|
||||
"panel",
|
||||
panelsById(base),
|
||||
panelsById(latest),
|
||||
(loc) => loc.panel?.title,
|
||||
panelShape,
|
||||
),
|
||||
...diffMaps("tab", tabsById(base), tabsById(latest), (tab) => tab?.name, tabShape),
|
||||
...diffMaps("variable", variablesByName(base), variablesByName(latest), (v) => v?.name),
|
||||
];
|
||||
return { changes, changedSettings: changedSettingsOf(base, latest) };
|
||||
};
|
||||
|
||||
const impactFor = (
|
||||
kind: ConflictItemKind,
|
||||
base: Map<string, any> | null,
|
||||
latest: Map<string, any>,
|
||||
mine: Map<string, any>,
|
||||
nameOf: (item: any) => string,
|
||||
sameServer: (a: any, b: any) => boolean,
|
||||
sameAsMine: (a: any, b: any) => boolean,
|
||||
out: OverwriteImpact,
|
||||
) => {
|
||||
const item = (key: string, value: any): ConflictChange => ({
|
||||
kind,
|
||||
change: "changed",
|
||||
key,
|
||||
name: nameOf(value) || key,
|
||||
});
|
||||
// Without the loaded version every difference has to be treated as theirs.
|
||||
const theirsChanged = (key: string) =>
|
||||
!base || !base.has(key) || !sameServer(base.get(key), latest.get(key));
|
||||
for (const [key, theirs] of latest) {
|
||||
if (!mine.has(key)) {
|
||||
if (!base?.has(key)) out.removes.push(item(key, theirs));
|
||||
else if (theirsChanged(key)) out.deletesEdited.push(item(key, theirs));
|
||||
} else if (!sameAsMine(theirs, mine.get(key)) && theirsChanged(key)) {
|
||||
out.reverts.push(item(key, theirs));
|
||||
}
|
||||
}
|
||||
for (const [key, value] of mine) {
|
||||
if (!latest.has(key) && base?.has(key)) out.restores.push(item(key, value));
|
||||
}
|
||||
};
|
||||
|
||||
/** What Overwrite undoes of their save when it stores `mine`, found by comparing all three versions. */
|
||||
export const overwriteImpact = (base: any | null, latest: any, mine: any): OverwriteImpact => {
|
||||
const out: OverwriteImpact = {
|
||||
removes: [],
|
||||
deletesEdited: [],
|
||||
reverts: [],
|
||||
restores: [],
|
||||
settings: [],
|
||||
};
|
||||
const panelShape = (loc: PanelLocation) => ({ tabId: loc.tabId, panel: loc.panel });
|
||||
const strict = (shape: (v: any) => unknown) => (a: any, b: any) => same(shape(a), shape(b));
|
||||
const lenientPanel = (a: PanelLocation, b: PanelLocation) =>
|
||||
a.tabId === b.tabId && sameWhereBothSet(a.panel, b.panel);
|
||||
impactFor(
|
||||
"panel",
|
||||
base ? panelsById(base) : null,
|
||||
panelsById(latest),
|
||||
panelsById(mine),
|
||||
(loc) => loc.panel?.title,
|
||||
strict(panelShape),
|
||||
lenientPanel,
|
||||
out,
|
||||
);
|
||||
impactFor(
|
||||
"tab",
|
||||
base ? tabsById(base) : null,
|
||||
tabsById(latest),
|
||||
tabsById(mine),
|
||||
(tab) => tab?.name,
|
||||
strict(tabShape),
|
||||
strict(tabShape),
|
||||
out,
|
||||
);
|
||||
impactFor(
|
||||
"variable",
|
||||
base ? variablesByName(base) : null,
|
||||
variablesByName(latest),
|
||||
variablesByName(mine),
|
||||
(v) => v?.name,
|
||||
strict((v) => v),
|
||||
strict((v) => v),
|
||||
out,
|
||||
);
|
||||
const theirSettings = base ? changedSettingsOf(base, latest) : null;
|
||||
out.settings = changedSettingsOf(latest, mine).filter(
|
||||
(key) => !theirSettings || theirSettings.includes(key),
|
||||
);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Their version of my panel against mine, for the panel editor's same-panel conflict. */
|
||||
export const comparePanel = (
|
||||
latest: any,
|
||||
mine: any,
|
||||
panelId: string | undefined,
|
||||
): PanelComparison | null => {
|
||||
if (!panelId) return null;
|
||||
const minePanel = panelsById(mine).get(String(panelId))?.panel;
|
||||
if (!minePanel) return null;
|
||||
const theirsPanel = panelsById(latest).get(String(panelId))?.panel;
|
||||
const withoutTitle = (p: any) => ({ ...p, id: undefined, title: undefined, layout: undefined });
|
||||
return {
|
||||
theirsRemoved: !theirsPanel,
|
||||
theirsTitle: theirsPanel?.title ?? "",
|
||||
mineTitle: minePanel.title ?? "",
|
||||
sameTitle: (theirsPanel?.title ?? "") === (minePanel.title ?? ""),
|
||||
sameOtherFields:
|
||||
!!theirsPanel && sameWhereBothSet(withoutTitle(theirsPanel), withoutTitle(minePanel)),
|
||||
};
|
||||
};
|
||||
|
||||
export const countChanges = (changes: ConflictChange[]): ConflictCounts => {
|
||||
const empty = () => ({ added: 0, changed: 0, removed: 0 });
|
||||
const counts: ConflictCounts = { panel: empty(), tab: empty(), variable: empty() };
|
||||
for (const c of changes) counts[c.kind][c.change] += 1;
|
||||
return counts;
|
||||
};
|
||||
|
||||
/** A panel they only added counts as untouched, which keeps Merge available. */
|
||||
export const isPanelTouched = (changes: ConflictChange[], panelId: string | undefined) =>
|
||||
!!panelId &&
|
||||
changes.some((c) => c.kind === "panel" && c.key === String(panelId) && c.change !== "added");
|
||||
|
||||
const overlaps = (a: any, b: any) =>
|
||||
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
|
||||
// A new panel was laid out against my version; their new panels may now occupy that spot.
|
||||
const placeNewPanel = (panel: any, tab: any) => {
|
||||
const others = (tab.panels ?? []).map((p: any) => p?.layout).filter(Boolean);
|
||||
const layout = panel.layout;
|
||||
if (!layout) return;
|
||||
const maxI = others.reduce((m: number, l: any) => Math.max(m, Number(l.i) || 0), 0);
|
||||
if (others.some((l: any) => String(l.i) === String(layout.i))) layout.i = maxI + 1;
|
||||
if (!others.some((l: any) => overlaps(layout, l))) return;
|
||||
layout.x = 0;
|
||||
layout.y = others.reduce((m: number, l: any) => Math.max(m, (l.y ?? 0) + (l.h ?? 0)), 0);
|
||||
layout.w = Math.min(layout.w ?? GRID_COLUMNS / 2, GRID_COLUMNS);
|
||||
};
|
||||
|
||||
// Variables I added, or changed where they did not, are carried onto the latest version.
|
||||
const mergeVariables = (merged: any, mine: any, base: any) => {
|
||||
const baseVars = variablesByName(base);
|
||||
if (!merged.variables) merged.variables = { showDynamicFilters: false, list: [] };
|
||||
if (!Array.isArray(merged.variables.list)) merged.variables.list = [];
|
||||
const list: any[] = merged.variables.list;
|
||||
for (const v of variablesOf(mine)) {
|
||||
const name = String(v.name);
|
||||
const before = baseVars.get(name);
|
||||
const index = list.findIndex((l: any) => String(l.name) === name);
|
||||
if (!before && index === -1) list.push(clone(v));
|
||||
else if (before && index !== -1 && same(list[index], before) && !same(v, before))
|
||||
list[index] = clone(v);
|
||||
}
|
||||
};
|
||||
|
||||
/** The latest dashboard with my version of one panel (and my variable edits) applied on top. */
|
||||
export const mergePanelOntoLatest = (
|
||||
latest: any,
|
||||
mine: any,
|
||||
panelId: string,
|
||||
base: any,
|
||||
): any | null => {
|
||||
const minePanel = panelsById(mine).get(String(panelId));
|
||||
if (!minePanel) return null;
|
||||
const merged = clone(latest);
|
||||
const theirs = panelsById(merged).get(String(panelId));
|
||||
const tabs = tabsOf(merged);
|
||||
if (!tabs.length) return null;
|
||||
const panel = clone(minePanel.panel);
|
||||
|
||||
if (theirs && theirs.tabId === minePanel.tabId) {
|
||||
tabsById(merged).get(String(theirs.tabId)).panels[theirs.index] = panel;
|
||||
} else {
|
||||
if (theirs) {
|
||||
const from = tabsById(merged).get(String(theirs.tabId));
|
||||
from.panels.splice(theirs.index, 1);
|
||||
}
|
||||
const target = tabsById(merged).get(String(minePanel.tabId)) ?? tabs[0];
|
||||
if (!Array.isArray(target.panels)) target.panels = [];
|
||||
placeNewPanel(panel, target);
|
||||
target.panels.push(panel);
|
||||
}
|
||||
|
||||
mergeVariables(merged, mine, base);
|
||||
return merged;
|
||||
};
|
||||
|
||||
/** Microsecond (backend) or millisecond epoch to milliseconds; null when unknown. */
|
||||
export const toEpochMs = (value: unknown): number | null => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return n > 1e14 ? Math.floor(n / 1000) : n;
|
||||
};
|
||||
|
||||
const RELATIVE_STEPS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["second", 60],
|
||||
["minute", 60],
|
||||
["hour", 24],
|
||||
["day", 30],
|
||||
["month", 12],
|
||||
["year", Number.POSITIVE_INFINITY],
|
||||
];
|
||||
|
||||
/** Localised "4 minutes ago" for a past epoch-ms value. */
|
||||
export const formatRelativeTime = (ms: number, locale: string, now = Date.now()) => {
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
let amount = Math.round((ms - now) / 1000);
|
||||
for (const [unit, size] of RELATIVE_STEPS) {
|
||||
if (Math.abs(amount) < size) return rtf.format(Math.min(amount, 0), unit);
|
||||
amount = Math.round(amount / size);
|
||||
}
|
||||
return rtf.format(amount, "year");
|
||||
};
|
||||
|
||||
/** Minute-precision clock time, with the date when it is not today. */
|
||||
export const formatClockTime = (ms: number, locale: string, now = Date.now()) => {
|
||||
const date = new Date(ms);
|
||||
const sameDay = new Date(now).toDateString() === date.toDateString();
|
||||
return sameDay
|
||||
? date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
|
||||
: date.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||
};
|
||||
@@ -97,10 +97,20 @@ vi.mock("gridstack/dist/gridstack.min.css", () => ({}));
|
||||
const mockNotifications = {
|
||||
showPositiveNotification: vi.fn(),
|
||||
showErrorNotification: vi.fn(),
|
||||
showConfictErrorNotificationWithRefreshBtn: vi.fn(),
|
||||
};
|
||||
const mockOpenConflict = vi.fn();
|
||||
vi.mock("@/composables/dashboard/useDashboardSaveConflict", () => ({
|
||||
useDashboardSaveConflict: () => ({
|
||||
isConflict: (error: any) => error?.response?.status === 409,
|
||||
openConflict: mockOpenConflict,
|
||||
}),
|
||||
}));
|
||||
// `run` is the real save body handed to useLoading, so the save path can be driven directly.
|
||||
const mockLoading: { isLoading: any; execute: any; run?: () => Promise<boolean> } = {
|
||||
const mockLoading: {
|
||||
isLoading: any;
|
||||
execute: any;
|
||||
run?: (change?: string) => Promise<boolean>;
|
||||
} = {
|
||||
isLoading: ref(false),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
@@ -1185,18 +1195,34 @@ describe("RenderDashboardCharts", () => {
|
||||
expect(wrapper.emitted("refresh")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns false, shows the conflict toast and reloads on a 409", async () => {
|
||||
it("returns false and opens the save-conflict dialog with my layout on a 409, without reloading", async () => {
|
||||
mockOpenConflict.mockClear();
|
||||
wrapper = createWrapper();
|
||||
vi.mocked(updateDashboard).mockRejectedValueOnce({
|
||||
response: { status: 409, data: { message: "Dashboard was changed elsewhere" } },
|
||||
});
|
||||
|
||||
await expect(mockLoading.run!()).resolves.toBe(false);
|
||||
expect(mockNotifications.showConfictErrorNotificationWithRefreshBtn).toHaveBeenCalledWith(
|
||||
"Dashboard was changed elsewhere",
|
||||
expect.any(Function),
|
||||
expect(mockOpenConflict).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboardId: wrapper.props("dashboardData").dashboardId,
|
||||
mine: wrapper.props("dashboardData"),
|
||||
change: "Moved or resized panels",
|
||||
}),
|
||||
);
|
||||
expect(mockNotifications.showErrorNotification).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted("refresh")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the caller's change in the conflict dialog when one is passed", async () => {
|
||||
mockOpenConflict.mockClear();
|
||||
wrapper = createWrapper({ layoutMode: "edit" });
|
||||
vi.mocked(updateDashboard).mockRejectedValueOnce({ response: { status: 409 } });
|
||||
|
||||
await expect(mockLoading.run!("Edited the dashboard JSON")).resolves.toBe(false);
|
||||
expect(mockOpenConflict).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ change: "Edited the dashboard JSON" }),
|
||||
);
|
||||
expect(wrapper.emitted("refresh")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns false and shows an error on a network failure", async () => {
|
||||
|
||||
@@ -337,7 +337,7 @@ import {
|
||||
type Ref,
|
||||
} from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import PanelContainer from "../../components/dashboards/PanelContainer.vue";
|
||||
import DateTimePickerDashboard from "../../components/DateTimePickerDashboard.vue";
|
||||
@@ -348,6 +348,7 @@ import NoPanel from "../../components/shared/grid/NoPanel.vue";
|
||||
import VariablesValueSelector from "../../components/dashboards/VariablesValueSelector.vue";
|
||||
import TabList from "@/components/dashboards/tabs/TabList.vue";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict";
|
||||
import { getVariableKey, useVariablesManager } from "@/composables/dashboard/useVariablesManager";
|
||||
import {
|
||||
useDashboardTimeGate,
|
||||
@@ -684,11 +685,8 @@ export default defineComponent({
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
showPositiveNotification,
|
||||
showErrorNotification,
|
||||
showConfictErrorNotificationWithRefreshBtn,
|
||||
} = useNotifications();
|
||||
const { showPositiveNotification, showErrorNotification } = useNotifications();
|
||||
const saveConflict = useDashboardSaveConflict();
|
||||
const refreshDashboard = (onlyIfRequired = false) => {
|
||||
emit("refresh", onlyIfRequired);
|
||||
};
|
||||
@@ -850,7 +848,8 @@ export default defineComponent({
|
||||
provide("hoveredSeriesState", hoveredSeriesState);
|
||||
|
||||
// save the dashboard value
|
||||
const saveDashboardData = useLoading(async () => {
|
||||
// `change` names the edit in the conflict dialog when the caller is not a layout move.
|
||||
const saveDashboardData = useLoading(async (change?: I18nText) => {
|
||||
try {
|
||||
await updateDashboard(
|
||||
store,
|
||||
@@ -863,21 +862,22 @@ export default defineComponent({
|
||||
showPositiveNotification(t("dashboard.renderDashboardCharts.dashboardUpdatedSuccessfully"));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 409) {
|
||||
showConfictErrorNotificationWithRefreshBtn(
|
||||
error?.response?.data?.message ??
|
||||
error?.message ??
|
||||
t("dashboard.renderDashboardCharts.dashboardUpdateFailed"),
|
||||
t,
|
||||
);
|
||||
} else {
|
||||
showErrorNotification(
|
||||
error?.message ?? t("dashboard.renderDashboardCharts.dashboardUpdateFailed"),
|
||||
{
|
||||
timeout: 2000,
|
||||
},
|
||||
);
|
||||
if (saveConflict.isConflict(error)) {
|
||||
// No refresh, in any layout mode: the layout I just made stays on screen (and edit mode keeps its draft) while the dialog is open.
|
||||
void saveConflict.openConflict({
|
||||
dashboardId: props.dashboardData.dashboardId,
|
||||
folderId: (route.query.folder as string) ?? "default",
|
||||
mine: props.dashboardData,
|
||||
change: change ?? t("dashboard.saveConflict.change.movedPanels"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
showErrorNotification(
|
||||
error?.message ?? t("dashboard.renderDashboardCharts.dashboardUpdateFailed"),
|
||||
{
|
||||
timeout: 2000,
|
||||
},
|
||||
);
|
||||
|
||||
// A reload swaps the dashboard object and ends the edit session, so edit mode keeps the draft for a retry.
|
||||
if (props.layoutMode !== "edit") refreshDashboard();
|
||||
|
||||
@@ -414,6 +414,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<OBanner
|
||||
v-if="hasUnsavedConflict"
|
||||
variant="warning"
|
||||
icon="warning"
|
||||
dense
|
||||
inline-actions
|
||||
class="mx-page-edge mt-2 shrink-0"
|
||||
data-test="dashboard-unsaved-conflict-banner"
|
||||
>
|
||||
{{
|
||||
saveConflictState.myChange
|
||||
? t("dashboard.saveConflict.notSavedChange", { change: saveConflictState.myChange })
|
||||
: t("dashboard.saveConflict.notSaved")
|
||||
}}
|
||||
<template #actions>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-test="dashboard-unsaved-conflict-resolve"
|
||||
@click="resolveSaveConflict"
|
||||
>
|
||||
{{ t("dashboard.saveConflict.resolve") }}
|
||||
</OButton>
|
||||
</template>
|
||||
</OBanner>
|
||||
|
||||
<RenderDashboardCharts
|
||||
:frame="false"
|
||||
:class="store.state.printMode ? '' : 'min-h-0 flex-1'"
|
||||
@@ -556,6 +582,12 @@ import AutoRefreshInterval from "@/components/AutoRefreshInterval.vue";
|
||||
import ExportDashboard from "@/components/dashboards/ExportDashboard.vue";
|
||||
import RenderDashboardCharts from "./RenderDashboardCharts.vue";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
import {
|
||||
onDashboardConflictResolved,
|
||||
useDashboardSaveConflict,
|
||||
useDashboardSaveConflictDialog,
|
||||
} from "@/composables/dashboard/useDashboardSaveConflict";
|
||||
import { dashboardSaveConflictState } from "@/composables/dashboard/dashboardSaveConflictState";
|
||||
import { DASHBOARD_FRESH_PANEL_TIME } from "@/composables/dashboard/dashboardFreshPanelTime";
|
||||
import { useHomeDashboard } from "@/composables/useHomeDashboard";
|
||||
import reports from "@/services/reports";
|
||||
@@ -569,6 +601,7 @@ import {
|
||||
} from "@/composables/dashboard/dashboardFreshOnOpen";
|
||||
import PanelLayoutSettings from "./PanelLayoutSettings.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
|
||||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
|
||||
@@ -622,6 +655,7 @@ export default defineComponent({
|
||||
name: "ViewDashboard",
|
||||
emits: ["onDeletePanel", "sendToAiChat"],
|
||||
components: {
|
||||
OBanner,
|
||||
OPageLayout,
|
||||
DateTimePickerDashboard,
|
||||
ShareButton,
|
||||
@@ -653,11 +687,8 @@ export default defineComponent({
|
||||
data: {},
|
||||
});
|
||||
const showScheduledReportsDialog = ref(false);
|
||||
const {
|
||||
showPositiveNotification,
|
||||
showErrorNotification,
|
||||
showConfictErrorNotificationWithRefreshBtn,
|
||||
} = useNotifications();
|
||||
const { showPositiveNotification, showErrorNotification } = useNotifications();
|
||||
const saveConflict = useDashboardSaveConflict();
|
||||
|
||||
// Variables manager will be initialized by RenderDashboardCharts
|
||||
// and we'll receive a reference to it via the @variablesManagerReady event
|
||||
@@ -1171,6 +1202,17 @@ export default defineComponent({
|
||||
if (!isSavingLayout.value) layoutDraft.redo();
|
||||
};
|
||||
|
||||
// The page keeps my unsaved edit on screen, so it must say it is not saved until the conflict is resolved.
|
||||
const hasUnsavedConflict = computed(
|
||||
() =>
|
||||
!!dashboardSaveConflictState.unresolvedDashboardId &&
|
||||
dashboardSaveConflictState.unresolvedDashboardId === String(route.query.dashboard),
|
||||
);
|
||||
const { reopen: reopenSaveConflict, reload: reloadSaveConflict } =
|
||||
useDashboardSaveConflictDialog();
|
||||
// Set when the edit-mode Save lost a conflict, so Discard can resolve that conflict too.
|
||||
let layoutSaveConflicted = false;
|
||||
|
||||
const saveLayout = async () => {
|
||||
if (!layoutDraft.isDirty.value || isSavingLayout.value) return;
|
||||
isSavingLayout.value = true;
|
||||
@@ -1178,6 +1220,7 @@ export default defineComponent({
|
||||
// saveDashboardData owns the toast and the 409 path; a failed save keeps the draft.
|
||||
const saved = await renderDashboardChartsRef.value?.saveDashboardData?.execute?.();
|
||||
if (saved) layoutDraft.finish();
|
||||
else if (hasUnsavedConflict.value) layoutSaveConflicted = true;
|
||||
} finally {
|
||||
isSavingLayout.value = false;
|
||||
}
|
||||
@@ -1205,6 +1248,9 @@ export default defineComponent({
|
||||
if (!isEditingLayout.value || isSavingLayout.value) return;
|
||||
if (layoutDraft.isDirty.value && !(await confirmDiscard("exit"))) return;
|
||||
layoutDraft.discard();
|
||||
// Discarding the layout that lost a save conflict is the conflict's Reload: show the latest and drop the not-saved banner.
|
||||
if (layoutSaveConflicted && hasUnsavedConflict.value) await reloadSaveConflict();
|
||||
layoutSaveConflicted = false;
|
||||
};
|
||||
|
||||
// Esc belongs to an open dialog or menu first; the layout only hears it when nothing else would.
|
||||
@@ -1922,7 +1968,30 @@ export default defineComponent({
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Resolve only reopens the dialog: no save is attempted, and the dialog re-reads the working copy, so an edit-mode draft that moved on is what Overwrite saves.
|
||||
const resolveSaveConflict = () => reopenSaveConflict();
|
||||
|
||||
onDashboardConflictResolved((dashboardId, outcome) => {
|
||||
if (dashboardId !== route.query.dashboard) return;
|
||||
layoutSaveConflicted = false;
|
||||
// Overwrite saved my layout, so edit mode ends as after a normal save; Reload drops the draft and its undo history.
|
||||
if (isEditingLayout.value) {
|
||||
if (outcome === "overwritten") layoutDraft.finish();
|
||||
else layoutDraft.discard();
|
||||
}
|
||||
loadDashboard();
|
||||
});
|
||||
|
||||
// Read before the save helpers edit the cached dashboard in place.
|
||||
const panelTitleOf = (panelId: any) =>
|
||||
(currentDashboardData.data as any)?.tabs
|
||||
?.flatMap((tab: any) => tab?.panels ?? [])
|
||||
.find((panel: any) => panel?.id === panelId)?.title ?? String(panelId);
|
||||
|
||||
const onDeletePanel = async (panelId: any) => {
|
||||
const change = t("dashboard.saveConflict.change.deletedPanel", {
|
||||
name: panelTitleOf(panelId),
|
||||
});
|
||||
try {
|
||||
await deletePanel(
|
||||
store,
|
||||
@@ -1937,13 +2006,12 @@ export default defineComponent({
|
||||
timeout: 2000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 409) {
|
||||
showConfictErrorNotificationWithRefreshBtn(
|
||||
error?.response?.data?.message ??
|
||||
error?.message ??
|
||||
t("dashboard.viewDashboard.panelDeletionFailed"),
|
||||
t,
|
||||
);
|
||||
if (saveConflict.isConflict(error)) {
|
||||
void saveConflict.openConflict({
|
||||
dashboardId: route.query.dashboard as string,
|
||||
folderId: (route.query.folder as string) ?? "default",
|
||||
change,
|
||||
});
|
||||
} else {
|
||||
showErrorNotification(
|
||||
error?.message ?? t("dashboard.viewDashboard.panelDeletionFailed"),
|
||||
@@ -1957,6 +2025,7 @@ export default defineComponent({
|
||||
|
||||
// move single panel to another tab
|
||||
const onMovePanel = async (panelId: any, newTabId: any) => {
|
||||
const change = t("dashboard.saveConflict.change.movedPanel", { name: panelTitleOf(panelId) });
|
||||
try {
|
||||
await movePanelToAnotherTab(
|
||||
store,
|
||||
@@ -1972,13 +2041,12 @@ export default defineComponent({
|
||||
timeout: 2000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 409) {
|
||||
showConfictErrorNotificationWithRefreshBtn(
|
||||
error?.response?.data?.message ??
|
||||
error?.message ??
|
||||
t("dashboard.viewDashboard.panelMoveFailed"),
|
||||
t,
|
||||
);
|
||||
if (saveConflict.isConflict(error)) {
|
||||
void saveConflict.openConflict({
|
||||
dashboardId: route.query.dashboard as string,
|
||||
folderId: (route.query.folder as string) ?? "default",
|
||||
change,
|
||||
});
|
||||
} else {
|
||||
showErrorNotification(error?.message ?? t("dashboard.viewDashboard.panelMoveFailed"), {
|
||||
timeout: 2000,
|
||||
@@ -2183,7 +2251,11 @@ export default defineComponent({
|
||||
|
||||
// Save changes using existing renderDashboardChartsRef
|
||||
if (renderDashboardChartsRef?.value?.saveDashboardData?.execute) {
|
||||
await renderDashboardChartsRef.value.saveDashboardData.execute();
|
||||
const saved = await renderDashboardChartsRef.value.saveDashboardData.execute(
|
||||
t("dashboard.saveConflict.change.editedJson"),
|
||||
);
|
||||
// A conflict opened the dialog with my JSON; reloading here would replace it on screen with the latest.
|
||||
if (!saved) return;
|
||||
|
||||
// Reload the dashboard to reflect changes
|
||||
await loadDashboard();
|
||||
@@ -2292,6 +2364,9 @@ export default defineComponent({
|
||||
variablesData,
|
||||
variablesDataUpdated,
|
||||
showDashboardSettingsDialog,
|
||||
hasUnsavedConflict,
|
||||
resolveSaveConflict,
|
||||
saveConflictState: dashboardSaveConflictState,
|
||||
openSettingsDialog,
|
||||
loadDashboard,
|
||||
refreshPanelRequest,
|
||||
|
||||
@@ -3,7 +3,13 @@ import { mount, shallowMount } from "@vue/test-utils";
|
||||
import { nextTick, reactive } from "vue";
|
||||
import AddPanel from "./AddPanel.vue";
|
||||
import { createStore } from "vuex";
|
||||
import { createRouter, createWebHistory, onBeforeRouteLeave, useRoute } from "vue-router";
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
onBeforeRouteLeave,
|
||||
useRoute,
|
||||
useRouter,
|
||||
} from "vue-router";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { addPanel, getDashboard, updatePanel } from "@/utils/commons";
|
||||
import analytics from "@/services/product_analytics";
|
||||
@@ -135,6 +141,14 @@ vi.mock("lodash-es", () => ({
|
||||
isEqual: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOpenConflict = vi.fn();
|
||||
vi.mock("@/composables/dashboard/useDashboardSaveConflict", () => ({
|
||||
useDashboardSaveConflict: () => ({
|
||||
isConflict: (error: any) => error?.response?.status === 409,
|
||||
openConflict: mockOpenConflict,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock vue-router hooks
|
||||
vi.mock("vue-router", async () => {
|
||||
const actual = await vi.importActual("vue-router");
|
||||
@@ -4804,6 +4818,68 @@ describe("AddPanel.vue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("save conflict", () => {
|
||||
const mountEditor = async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
query: { dashboard: "d1", panelId: "p1", tab: "t1", folder: "f1" },
|
||||
params: {},
|
||||
} as any);
|
||||
vi.mocked(getDashboard).mockResolvedValue({
|
||||
title: "d",
|
||||
tabs: [{ tabId: "t1", panels: [] }],
|
||||
});
|
||||
wrapper = shallowMount(AddPanel, {
|
||||
global: {
|
||||
plugins: [store, router, i18n],
|
||||
stubs: { PanelEditor: true, DateTimePickerDashboard: true, QueryInspector: true },
|
||||
},
|
||||
props: { metaData: null },
|
||||
});
|
||||
await nextTick();
|
||||
wrapper.vm.dashboardPanelData.data.id = "p1";
|
||||
wrapper.vm.dashboardPanelData.data.title = "Latency";
|
||||
wrapper.vm.dashboardPanelData.data.type = "bar";
|
||||
mockOpenConflict.mockClear();
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(useRoute).mockReset();
|
||||
});
|
||||
|
||||
it("opens the conflict dialog with my panel and a description of my change", async () => {
|
||||
vi.mocked(updatePanel).mockRejectedValue({ response: { status: 409 } });
|
||||
await mountEditor();
|
||||
|
||||
await wrapper.vm.savePanelChangesToDashboard("d1");
|
||||
|
||||
expect(mockOpenConflict).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboardId: "d1",
|
||||
folderId: "f1",
|
||||
panelId: "p1",
|
||||
change: "dashboard.saveConflict.change.editedPanel",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Reload leaves the editor for the view, so the screen shows what is saved", async () => {
|
||||
vi.mocked(updatePanel).mockRejectedValue({ response: { status: 409 } });
|
||||
await mountEditor();
|
||||
const routerInstance = vi.mocked(useRouter).mock.results.at(-1)!.value;
|
||||
await wrapper.vm.savePanelChangesToDashboard("d1");
|
||||
const { onResolved } = mockOpenConflict.mock.calls[0][0];
|
||||
|
||||
await onResolved("reloaded");
|
||||
|
||||
expect(routerInstance.push).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "/dashboards/view",
|
||||
query: expect.objectContaining({ dashboard: "d1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Save validation reporting", () => {
|
||||
const mountForValidation = async () => {
|
||||
const w = mount(AddPanel, {
|
||||
|
||||
@@ -296,6 +296,8 @@ import { debounce, isEqual } from "lodash-es";
|
||||
import { rangesFromServerError, type SqlErrorRange } from "@/utils/query/sqlDiagnostics";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict";
|
||||
import type { ConflictOutcome } from "@/composables/dashboard/dashboardSaveConflictState";
|
||||
import config from "@/aws-exports";
|
||||
import useCancelQuery from "@/composables/dashboard/useCancelQuery";
|
||||
import { isQueryVrlEnabled } from "@/composables/dashboard/useVrlFunction";
|
||||
@@ -389,8 +391,8 @@ export default defineComponent({
|
||||
if (!injectedManager) {
|
||||
provide("variablesManager", variablesManager);
|
||||
}
|
||||
const { showErrorNotification, showConfictErrorNotificationWithRefreshBtn } =
|
||||
useNotifications();
|
||||
const { showErrorNotification } = useNotifications();
|
||||
const saveConflict = useDashboardSaveConflict();
|
||||
const {
|
||||
dashboardPanelData,
|
||||
resetDashboardPanelData,
|
||||
@@ -1333,6 +1335,51 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
const finishPanelSave = async (dashId: string) => {
|
||||
rememberPanelStream();
|
||||
analytics.track("dashboard_panel_saved", {
|
||||
chart_type: dashboardPanelData.data.type,
|
||||
is_new: !editMode.value,
|
||||
});
|
||||
|
||||
isUnsavedTrackingActive = false;
|
||||
|
||||
// The author sees the value just saved, not an older view-mode override of this panel.
|
||||
clearExemplarOverride(
|
||||
exemplarOverrideKey(
|
||||
store.state.selectedOrganization.identifier,
|
||||
dashId,
|
||||
String(dashboardPanelData.data.id),
|
||||
),
|
||||
);
|
||||
|
||||
// Clear variables created during session since panel is being saved
|
||||
variablesCreatedInSession.value = [];
|
||||
variablesWithCurrentPanel.value = [];
|
||||
|
||||
await nextTick();
|
||||
return router.push({
|
||||
path: "/dashboards/view",
|
||||
query: {
|
||||
...routeQueryParamsOnMount,
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
dashboard: dashId,
|
||||
folder: route.query.folder ?? "default",
|
||||
tab: route.query.tab ?? currentDashboardData.data.tabs[0].tabId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onPanelConflictResolved = async (dashId: string, outcome: ConflictOutcome) => {
|
||||
// Reload discards my edit, so the editor must not prompt; the view then shows exactly what is saved.
|
||||
if (outcome === "reloaded") {
|
||||
isUnsavedTrackingActive = false;
|
||||
await goBack();
|
||||
return;
|
||||
}
|
||||
await finishPanelSave(dashId);
|
||||
};
|
||||
|
||||
const savePanelChangesToDashboard = async (dashId: string) => {
|
||||
// Left generic: these errors are never cleared before this guard, so they can be stale.
|
||||
if (dashboardPanelData.data.type === "custom_chart" && errorData.errors.length > 0) {
|
||||
@@ -1468,48 +1515,27 @@ export default defineComponent({
|
||||
return;
|
||||
}
|
||||
}
|
||||
rememberPanelStream();
|
||||
analytics.track("dashboard_panel_saved", {
|
||||
chart_type: dashboardPanelData.data.type,
|
||||
is_new: !editMode.value,
|
||||
});
|
||||
|
||||
isUnsavedTrackingActive = false;
|
||||
|
||||
// The author sees the value just saved, not an older view-mode override of this panel.
|
||||
clearExemplarOverride(
|
||||
exemplarOverrideKey(
|
||||
store.state.selectedOrganization.identifier,
|
||||
dashId,
|
||||
String(dashboardPanelData.data.id),
|
||||
),
|
||||
);
|
||||
|
||||
// Clear variables created during session since panel is being saved
|
||||
variablesCreatedInSession.value = [];
|
||||
variablesWithCurrentPanel.value = [];
|
||||
|
||||
await nextTick();
|
||||
return router.push({
|
||||
path: "/dashboards/view",
|
||||
query: {
|
||||
...routeQueryParamsOnMount,
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
dashboard: dashId,
|
||||
folder: route.query.folder ?? "default",
|
||||
tab: route.query.tab ?? currentDashboardData.data.tabs[0].tabId,
|
||||
},
|
||||
});
|
||||
return await finishPanelSave(dashId);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 409) {
|
||||
showConfictErrorNotificationWithRefreshBtn(
|
||||
error?.response?.data?.message ??
|
||||
error?.message ??
|
||||
(editMode.value
|
||||
? t("dashboard.addPanel.errorUpdatingPanel")
|
||||
: t("dashboard.addPanel.errorCreatingPanel")),
|
||||
t,
|
||||
);
|
||||
if (saveConflict.isConflict(error)) {
|
||||
void saveConflict.openConflict({
|
||||
dashboardId: dashId,
|
||||
folderId: (route.query.folder as string) ?? "default",
|
||||
mine:
|
||||
editMode.value && variablesCreatedInSession.value.length > 0
|
||||
? currentDashboardData.data
|
||||
: undefined,
|
||||
panelId: String(dashboardPanelData.data.id),
|
||||
change: t(
|
||||
editMode.value
|
||||
? "dashboard.saveConflict.change.editedPanel"
|
||||
: "dashboard.saveConflict.change.addedPanel",
|
||||
{
|
||||
name: dashboardPanelData.data.title,
|
||||
},
|
||||
),
|
||||
onResolved: (outcome) => onPanelConflictResolved(dashId, outcome),
|
||||
});
|
||||
} else {
|
||||
showErrorNotification(
|
||||
error?.message ??
|
||||
|
||||
@@ -257,6 +257,7 @@ import enUS from "@/locales/languages/en-US.json";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import analytics from "@/services/product_analytics";
|
||||
import ShareButton from "@/components/common/ShareButton.vue";
|
||||
import { dashboardSaveConflictState } from "@/composables/dashboard/dashboardSaveConflictState";
|
||||
import { getDashboard } from "@/utils/commons.ts";
|
||||
import { getManager } from "@/lib/vue-shortcut-manager";
|
||||
import { DASHBOARD_FRESH_PANEL_TIME } from "@/composables/dashboard/dashboardFreshPanelTime";
|
||||
@@ -661,6 +662,37 @@ describe("ViewDashboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("save conflict resolution", () => {
|
||||
it("marks the page not saved while this dashboard's conflict is unresolved", async () => {
|
||||
wrapper = shallowMount(ViewDashboard, {
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
config: { globalProperties: { store } },
|
||||
stubs: {
|
||||
OPageLayout: { template: "<div><slot /></div>" },
|
||||
OBanner: { template: "<div><slot /><slot name='actions' /></div>" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const banner = () => wrapper.find('[data-test="dashboard-unsaved-conflict-banner"]');
|
||||
expect(banner().exists()).toBe(false);
|
||||
|
||||
dashboardSaveConflictState.myChange = 'Deleted panel "CPU"';
|
||||
dashboardSaveConflictState.unresolvedDashboardId = "test-dashboard-1";
|
||||
await flushPromises();
|
||||
expect(banner().exists()).toBe(true);
|
||||
// This spec's i18n has no messages, so the banner carries its key.
|
||||
expect(banner().text()).toContain("dashboard.saveConflict.notSavedChange");
|
||||
expect(wrapper.find('[data-test="dashboard-unsaved-conflict-resolve"]').exists()).toBe(true);
|
||||
|
||||
dashboardSaveConflictState.unresolvedDashboardId = "other-dashboard";
|
||||
await flushPromises();
|
||||
expect(banner().exists()).toBe(false);
|
||||
dashboardSaveConflictState.unresolvedDashboardId = "";
|
||||
});
|
||||
});
|
||||
|
||||
describe("Basic Component Tests", () => {
|
||||
it("should create component without crashing", async () => {
|
||||
expect(() => {
|
||||
@@ -1851,6 +1883,10 @@ describe("ViewDashboard", () => {
|
||||
'<div><slot name="title" /><slot name="actions" /><slot name="actions-overflow" /><slot /></div>',
|
||||
},
|
||||
RenderDashboardCharts: RenderDashboardChartsStub,
|
||||
OBanner: {
|
||||
props: ["dataTest"],
|
||||
template: '<div :data-test="dataTest"><slot /><slot name="actions" /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2107,6 +2143,20 @@ describe("ViewDashboard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps my JSON on screen when its save hits a conflict, naming it in the dialog", async () => {
|
||||
await mountDashboard();
|
||||
const edited = JSON.parse(JSON.stringify(wrapper.vm.currentDashboardData.data));
|
||||
edited.tabs[0].panels[0].layout.w = 48;
|
||||
const loadsBefore = vi.mocked(getDashboard).mock.calls.length;
|
||||
mockSaveExecute.mockResolvedValueOnce(false);
|
||||
|
||||
await wrapper.vm.saveJsonDashboard.execute(edited);
|
||||
|
||||
expect(mockSaveExecute).toHaveBeenCalledWith("dashboard.saveConflict.change.editedJson");
|
||||
expect(vi.mocked(getDashboard).mock.calls.length).toBe(loadsBefore);
|
||||
expect(panelLayout().w).toBe(48);
|
||||
});
|
||||
|
||||
it("warns on page unload only while the draft has changes", async () => {
|
||||
await mountDashboard();
|
||||
wrapper.vm.enterLayoutEdit();
|
||||
@@ -2179,5 +2229,100 @@ describe("ViewDashboard", () => {
|
||||
manager.unregisterById(chatId);
|
||||
}
|
||||
});
|
||||
|
||||
describe("save conflict on Save", () => {
|
||||
const conflictLost = async () => {
|
||||
await mountDashboard();
|
||||
wrapper.vm.enterLayoutEdit();
|
||||
await dragPanelTo(10);
|
||||
// What saveDashboardData does on a 409: open the dialog (which marks the dashboard unresolved) and report failure.
|
||||
mockSaveExecute.mockImplementationOnce(async () => {
|
||||
dashboardSaveConflictState.unresolvedDashboardId = "test-dashboard-1";
|
||||
return false;
|
||||
});
|
||||
await wrapper.vm.saveLayout();
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
const resolve = async (outcome: "reloaded" | "overwritten") => {
|
||||
dashboardSaveConflictState.unresolvedDashboardId = "";
|
||||
dashboardSaveConflictState.resolvedDashboardId = "test-dashboard-1";
|
||||
dashboardSaveConflictState.resolvedOutcome = outcome;
|
||||
dashboardSaveConflictState.resolvedSeq += 1;
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
dashboardSaveConflictState.unresolvedDashboardId = "";
|
||||
});
|
||||
|
||||
it("keeps edit mode, the draft and its undo history while the dialog is open or closed", async () => {
|
||||
await conflictLost();
|
||||
|
||||
expect(wrapper.vm.isEditingLayout).toBe(true);
|
||||
expect(wrapper.vm.layoutChangeCount).toBe(1);
|
||||
expect(wrapper.vm.canUndoLayout).toBe(true);
|
||||
expect(panelLayout().x).toBe(10);
|
||||
expect(has("dashboard-unsaved-conflict-banner")).toBe(true);
|
||||
expect(has("dashboard-edit-badge")).toBe(true);
|
||||
});
|
||||
|
||||
it("a time-range change applies without saving and leaves the not-saved banner up", async () => {
|
||||
await conflictLost();
|
||||
const loadsBefore = vi.mocked(getDashboard).mock.calls.length;
|
||||
|
||||
wrapper.vm.timeGate.onShow();
|
||||
wrapper.vm.timeGate.onPick({ valueType: "relative", relativeTimePeriod: "1h" });
|
||||
wrapper.vm.onTimePickerHide();
|
||||
wrapper.vm.timeGate.apply();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.selectedDate).toMatchObject({ relativeTimePeriod: "1h" });
|
||||
expect(mockSaveExecute).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(getDashboard).mock.calls.length).toBe(loadsBefore);
|
||||
expect(dashboardSaveConflictState.unresolvedDashboardId).toBe("test-dashboard-1");
|
||||
expect(has("dashboard-unsaved-conflict-banner")).toBe(true);
|
||||
expect(wrapper.vm.isEditingLayout).toBe(true);
|
||||
expect(wrapper.vm.layoutChangeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("Reload leaves edit mode, drops the draft and shows the latest version", async () => {
|
||||
await conflictLost();
|
||||
const drafted = wrapper.vm.currentDashboardData.data;
|
||||
const callsBefore = vi.mocked(getDashboard).mock.calls.length;
|
||||
|
||||
await resolve("reloaded");
|
||||
|
||||
expect(wrapper.vm.isEditingLayout).toBe(false);
|
||||
expect(wrapper.vm.layoutChangeCount).toBe(0);
|
||||
expect(wrapper.vm.canUndoLayout).toBe(false);
|
||||
expect(drafted.tabs[0].panels[0].layout.x).toBe(0);
|
||||
expect(vi.mocked(getDashboard).mock.calls.length).toBe(callsBefore + 1);
|
||||
expect(has("dashboard-unsaved-conflict-banner")).toBe(false);
|
||||
});
|
||||
|
||||
it("Overwrite ends edit mode like a normal save, keeping my layout", async () => {
|
||||
await conflictLost();
|
||||
const drafted = wrapper.vm.currentDashboardData.data;
|
||||
|
||||
await resolve("overwritten");
|
||||
|
||||
expect(wrapper.vm.isEditingLayout).toBe(false);
|
||||
expect(wrapper.vm.layoutChangeCount).toBe(0);
|
||||
expect(drafted.tabs[0].panels[0].layout.x).toBe(10);
|
||||
});
|
||||
|
||||
it("the banner's Resolve reopens the dialog without saving again, and edit mode stays", async () => {
|
||||
await conflictLost();
|
||||
await dragPanelTo(20);
|
||||
|
||||
await wrapper.find('[data-test="dashboard-unsaved-conflict-resolve"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(mockSaveExecute).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.vm.isEditingLayout).toBe(true);
|
||||
expect(panelLayout().x).toBe(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user