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:
ktx-vaidehi
2026-10-15 12:14:06 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent 7366654175
commit 00be51d925
48 changed files with 5665 additions and 247 deletions
@@ -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 () => {
+20 -15
View File
@@ -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();
+28 -17
View File
@@ -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[] = []) => ({
+10
View File
@@ -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,
+3 -2
View File
@@ -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;
};
+97
View File
@@ -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": "تعديل",
+97
View File
@@ -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",
+97
View File
@@ -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",
+97
View File
@@ -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",
+97
View File
@@ -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",
+97
View File
@@ -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",
+97
View File
@@ -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": "編集",
+97
View File
@@ -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": "편집",
+97
View File
@@ -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",
+97
View File
@@ -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",
+97
View File
@@ -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",
+97
View File
@@ -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": "Изменить",
+97
View File
@@ -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",
+97
View File
@@ -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",
+97
View File
@@ -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": "编辑",
+97
View File
@@ -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
View File
@@ -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);
});
});
+435
View File
@@ -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();
+95 -20
View File
@@ -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, {
+69 -43
View File
@@ -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);
});
});
});
});