Compare commits

...

4 Commits

Author SHA1 Message Date
Abhi Kumar
77f7a2cceb fix: ui fixes for panel selection modal 2026-07-02 14:30:25 +05:30
Abhi kumar
c36226050e feat(dashboards-v2): substitute dashboard variables when creating an alert from a panel (#11929)
Wire the `/substitute_vars` round-trip into the panel create-alert flow so
`$var` / dynamic variable references resolve to the values selected in the
variable bar before the alert is seeded — V1 parity with `useCreateAlerts`,
which the previous V2 path skipped (it shipped variable refs verbatim).

When the dashboard has resolved variables, `useCreateAlertFromPanel` builds a
V5 query-range request (panel queries + resolved variables) and POSTs it through
the generated `useReplaceVariables` hook; on success the substituted envelopes
are translated to the V1 `Query` the alert page reads. With no variables to
substitute the round-trip is a no-op, so we keep seeding synchronously and the
new tab stays tied to the click.

- persesQueryAdapters: extract `envelopesToQuery` from `fromPerses` (the
  substitute response hands back envelopes, not panel-query wrappers)
- buildCreateAlertUrl: export `buildAlertUrl` + `readPanelUnit` so the sync and
  substituted paths share URL assembly
2026-07-02 08:43:48 +00:00
Ashwin Bhatkal
a72484f12c feat(dashboard-v2): optimistic updates for dashboard spec mutations (#11936)
* feat(dashboard-v2): add lenient RFC-6902 JSON-Patch applier

Add applyJsonPatch, a pure applier for the add/replace/remove ops our patchOps
builders emit. Deep-clones and returns a new document (never mutates input), and
is deliberately lenient like the backend apply (remove/replace on a missing path
is a no-op, add creates missing object parents). This lets a dashboard edit be
reflected in the react-query cache optimistically, with the mutation's settle
refetch as the reconcile safety net.

* feat(dashboard-v2): add useOptimisticPatch central mutation hook

A single react-query mutation over patchDashboardV2 that applies the ops to the
cached dashboard on onMutate (via applyJsonPatch, patching the envelope's .data),
snapshots for rollback on onError, and invalidates on settle to reconcile. Reads
dashboardId from the edit-context store (with an optional override for the panel
editor, which receives its id as a prop) and exposes error; rethrows so call sites
keep their own error handling.

* feat(dashboard-v2): route section/layout edits through optimistic patch

Migrate the section and layout mutations (rename, add, delete, reorder, resize/
move persist, first-section migration) off the 'await patchDashboardV2(...);
refetch()' pattern onto useOptimisticPatch, so section edits render instantly and
reconcile in the background. The explicit refetch is dropped (onSettled invalidates)
and each hook keeps its own toast/error handling.

* feat(dashboard-v2): route panel add/move/delete through optimistic patch

Migrate the grid-item panel mutations (delete, move-between-sections, clone) onto
useOptimisticPatch so they render instantly and reconcile on settle. useClonePanel
keeps its toast.promise UX over the patch promise. Update the clone test to assert
against the ops passed to patchAsync.

* feat(dashboard-v2): route panel editor save through optimistic patch

Migrate usePanelEditorSave off usePatchDashboardV2 + invalidateQueries onto the
central useOptimisticPatch (passing the editor's explicit dashboardId). The isNew
branch still reads cached layouts to resolve the target section.

* feat(dashboard-v2): route settings/toolbar edits through optimistic patch

Migrate the remaining patchDashboardV2 spec edits — variable-definition save,
Overview metadata (title/description/image/tags), and toolbar rename — onto
useOptimisticPatch. The toolbar keeps its refetch prop for the lock/unlock toggle
(a non-patch API), and the edit-context refetch stays for the JSON editor's
full-document save; both are outside this PR's patch-op scope.

* chore(dashboard-v2): ban direct patchDashboardV2 via oxlint

Add a no-restricted-imports rule forbidding patchDashboardV2 / usePatchDashboardV2
from api/generated/services/dashboard, directing callers to useOptimisticPatch().
patchAsync so every spec edit goes through the optimistic cache path. The hook
itself (the one sanctioned caller) and its test carry a scoped inline exception.
2026-07-02 07:28:50 +00:00
Ashwin Bhatkal
71eabac1e7 fix(dashboards): stop query cache collisions on public dashboards (#11935)
The public payload redacts each widget's query (filters/limit/orderBy
stripped), so panels differing only by their filter arrive with identical
query bodies. The react-query key was built from that query body, so those
panels hashed to the same key and were deduped into one request — its data
filled every colliding panel while other indices were never fetched.

Key each panel on what determines its response — widget id + index + time —
instead of the redacted query body.

Fixes SigNoz/engineering-pod#5503
2026-07-02 06:07:21 +00:00
29 changed files with 931 additions and 206 deletions

View File

@@ -328,6 +328,11 @@
{
"name": "immer",
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
},
{
"name": "api/generated/services/dashboard",
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
}
]
}

View File

@@ -79,13 +79,11 @@ function Panel({
},
ENTITY_VERSION_V5,
{
queryKey: [
widget?.query,
widget?.panelTypes,
requestData,
startTime,
endTime,
],
// Public data is fetched by index and the payload redacts each widget's
// filters, so query bodies are identical across panels. Key on panel
// identity + time — the only inputs that determine the response — so
// panels don't collapse onto one cache entry.
queryKey: [widget?.id, index, startTime, endTime],
retry(failureCount, error): boolean {
if (
String(error).includes('status: error') &&

View File

@@ -0,0 +1,79 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render } from 'tests/test-utils';
import { Widgets } from 'types/api/dashboard/getAll';
import Panel from '../Panel';
const useGetQueryRangeMock = jest.fn();
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
useGetQueryRange: (...args: unknown[]): unknown => {
useGetQueryRangeMock(...args);
return {
data: undefined,
isFetching: false,
isLoading: false,
isSuccess: true,
isError: false,
};
},
}));
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="widget-graph" />,
}));
const buildWidget = (id: string): Widgets =>
({
id,
panelTypes: PANEL_TYPES.LIST,
query: {
builder: {
queryData: [{ dataSource: 'logs', limit: 100, orderBy: [] }],
},
},
timePreferance: 'GLOBAL_TIME',
}) as unknown as Widgets;
describe('Public dashboard Panel', () => {
beforeEach(() => {
useGetQueryRangeMock.mockClear();
});
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {
render(
<>
<Panel
widget={buildWidget('widget-a')}
index={2}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>
<Panel
widget={buildWidget('widget-b')}
index={62}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>
</>,
);
const [callA, callB] = useGetQueryRangeMock.mock.calls;
const queryKeyA = callA[2].queryKey;
const metaA = callA[4];
const queryKeyB = callB[2].queryKey;
const metaB = callB[4];
// Key is panel identity + time only — the redacted query body is not part
// of it, so identical query bodies can't collapse two panels onto one key.
expect(queryKeyA).toStrictEqual(['widget-a', 2, 100, 200]);
expect(queryKeyB).toStrictEqual(['widget-b', 62, 100, 200]);
expect(queryKeyA).not.toStrictEqual(queryKeyB);
expect(metaA.widgetIndex).toBe(2);
expect(metaB.widgetIndex).toBe(62);
});
});

View File

@@ -4,7 +4,6 @@ import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
@@ -18,6 +17,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
@@ -51,6 +51,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
@@ -88,14 +89,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
value: next,
},
];
await patchDashboardV2({ id }, patch);
await patchAsync(patch);
toast.success('Dashboard renamed successfully');
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[id, refetch, showErrorModal],
[id, patchAsync, showErrorModal],
);
const { isEditing, draft, setDraft, startEdit, cancel, commit } =

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -9,7 +8,7 @@ import { isEqual } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
@@ -23,7 +22,7 @@ interface OverviewProps {
function Overview({ dashboard }: OverviewProps): JSX.Element {
const id = dashboard.id;
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -96,15 +95,14 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
try {
setIsSaving(true);
await patchDashboardV2({ id }, ops);
await patchAsync(ops);
toast.success('Dashboard updated');
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [id, buildPatch, refetch, showErrorModal]);
}, [buildPatch, patchAsync, showErrorModal]);
useEffect(() => {
let numberOfUnsavedChanges = 0;

View File

@@ -1,9 +1,9 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
@@ -14,14 +14,9 @@ interface UseSaveVariables {
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
@@ -33,9 +28,8 @@ export function useSaveVariables(): UseSaveVariables {
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
await patchAsync(buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -44,7 +38,7 @@ export function useSaveVariables(): UseSaveVariables {
setIsSaving(false);
}
},
[dashboardId, refetch, showErrorModal],
[dashboardId, patchAsync, showErrorModal],
);
return { save, isSaving };

View File

@@ -1,40 +1,36 @@
import { renderHook } from '@testing-library/react';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorSave } from '../usePanelEditorSave';
const mockInvalidateQueries = jest.fn();
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
let mockIsPatching = false;
jest.mock('../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): {
patchAsync: jest.Mock;
isPatching: boolean;
error: Error | null;
} => ({ patchAsync: mockPatchAsync, isPatching: mockIsPatching, error: null }),
}));
// The hook reads getQueryData only for the isNew branch; a stub client is enough here.
jest.mock('react-query', () => ({
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
invalidateQueries: mockInvalidateQueries,
useQueryClient: (): { getQueryData: jest.Mock } => ({
getQueryData: jest.fn(),
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
usePatchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
describe('usePanelEditorSave', () => {
const mutateAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
mockIsPatching = false;
});
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
it('optimistically patches an add replacing the whole panel spec', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
@@ -50,28 +46,17 @@ describe('usePanelEditorSave', () => {
await result.current.save(spec);
expect(mutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'dash-1' },
data: [
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: spec,
},
],
});
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
expect(mockInvalidateQueries).toHaveBeenCalledWith([
'/api/v2/dashboards/dash-1',
expect(mockPatchAsync).toHaveBeenCalledWith([
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: spec,
},
]);
});
it('surfaces the mutation loading state as isSaving', () => {
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: true,
error: null,
});
it('surfaces the patch in-flight state as isSaving', () => {
mockIsPatching = true;
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),

View File

@@ -1,10 +1,7 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { v4 as uuid } from 'uuid';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
@@ -13,6 +10,7 @@ import {
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { createPanelOps } from '../../patchOps';
interface UsePanelEditorSaveArgs {
@@ -43,15 +41,14 @@ export function usePanelEditorSave({
layoutIndex,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
let ops: DashboardtypesJSONPatchOperationDTO[];
if (isNew) {
// Resolve the target section against the freshest dashboard we have.
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const cached =
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
ops = createPanelOps({
@@ -70,11 +67,11 @@ export function usePanelEditorSave({
];
}
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(dashboardQueryKey);
// Optimistic cache write + settle refetch (replaces the manual invalidate).
await patchAsync(ops);
},
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
);
return { save, isSaving: isLoading, error: (error as Error) ?? null };
return { save, isSaving: isPatching, error };
}

View File

@@ -4,19 +4,25 @@
gap: 8px;
}
.typeButton {
.panelTypeCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
gap: 12px;
cursor: pointer;
font: inherit;
border-radius: 4px;
color: var(--l1-foreground);
cursor: pointer;
text-align: left;
transition:
transform 180ms ease,
border-color 180ms ease;
&:hover {
border-color: var(--bg-robin-500);
background-color: var(--l2-background-hover);
transform: translateY(-2px);
border-color: var(--bg-robin-400);
}
}

View File

@@ -1,5 +1,5 @@
import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Color } from '@signozhq/design-tokens';
import { DialogWrapper } from '@signozhq/ui/dialog';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
@@ -17,29 +17,30 @@ function PanelTypeSelectionModal({
onSelect,
}: PanelTypeSelectionModalProps): JSX.Element {
return (
<Modal
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<Button
<button
key={panelKind}
type="button"
variant="ghost"
className={styles.typeButton}
className={styles.panelTypeCard}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={14} />
<Icon size={24} color={Color.BG_ROBIN_400} />
{label}
</Button>
</button>
))}
</div>
</Modal>
</DialogWrapper>
);
}

View File

@@ -1,12 +1,15 @@
import { renderHook } from '@testing-library/react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import type { DashboardSection } from '../../../../utils';
import { useClonePanel } from '../useClonePanel';
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
jest.mock('../../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
patchAsync: mockPatchAsync,
isPatching: false,
}),
}));
const mockToastPromise = jest.fn();
@@ -16,8 +19,6 @@ jest.mock('@signozhq/ui/sonner', () => ({
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
const sourcePanel = {
kind: 'Panel',
spec: {
@@ -45,7 +46,7 @@ function sections(): DashboardSection[] {
describe('useClonePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
useDashboardStore.setState({ dashboardId: 'dash-1' });
});
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
@@ -53,7 +54,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
expect(mockPatchAsync).toHaveBeenCalledWith([
{
op: 'add',
path: '/spec/panels/cloned-id',
@@ -92,7 +93,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatch.mock.calls[0][1];
const ops = mockPatchAsync.mock.calls[0][0];
// Room in the last row (4 + 4 = 8 ≤ 12 cols) → sits to the right at y:0.
expect(ops[1].value).toMatchObject({ x: 4, y: 0, width: 4, height: 5 });
});
@@ -102,7 +103,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatch.mock.calls[0][1];
const ops = mockPatchAsync.mock.calls[0][0];
expect(ops[0].value).toStrictEqual(sourcePanel);
expect(ops[0].value).not.toBe(sourcePanel);
});
@@ -112,7 +113,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'missing', layoutIndex: 0 });
expect(mockPatch).not.toHaveBeenCalled();
expect(mockPatchAsync).not.toHaveBeenCalled();
expect(mockToastPromise).not.toHaveBeenCalled();
});
@@ -132,7 +133,7 @@ describe('useClonePanel', () => {
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatch.mockRejectedValueOnce(new Error('boom'));
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await expect(

View File

@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5VariableTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
@@ -18,12 +19,55 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
// The V5→V1 query→URL translation is covered by buildCreateAlertUrl's own tests;
// stub it so this asserts only the hook's side effects (analytics + navigation).
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=1',
const mockToastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
toast: { error: (...args: unknown[]): void => mockToastError(...args) },
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: { minTime: 1_000_000, maxTime: 2_000_000 } }),
}));
const mockSubstituteVars = jest.fn();
jest.mock('api/generated/services/querier', () => ({
useReplaceVariables: (): { mutate: jest.Mock } => ({
mutate: mockSubstituteVars,
}),
}));
// Stub the builders so this asserts only the hook's orchestration.
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=sync',
buildAlertUrl: (): string => '/alerts/new?composite=substituted',
readPanelUnit: (): string | undefined => undefined,
}));
// Keep the real exports (getPanelQueryType reads them); stub only the builder.
const mockBuildQueryRangeRequest = jest.fn((_args?: unknown) => ({
request: 'payload',
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest',
() => ({
...jest.requireActual(
'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest',
),
buildQueryRangeRequest: (args: unknown): unknown =>
mockBuildQueryRangeRequest(args),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
...jest.requireActual(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
),
envelopesToQuery: (): unknown => ({ resolved: 'query' }),
}),
);
const mockLogEvent = logEvent as jest.Mock;
const panel = {
@@ -38,17 +82,7 @@ const panel = {
describe('useCreateAlertFromPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
});
it('opens the seeded alert builder in a new tab', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=1', {
newTab: true,
});
useDashboardStore.setState({ dashboardId: 'dash-1', resolvedVariables: {} });
});
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
@@ -66,4 +100,80 @@ describe('useCreateAlertFromPanel', () => {
}),
);
});
describe('with no variable selections', () => {
it('seeds the alert synchronously without a substitute round-trip', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockSubstituteVars).not.toHaveBeenCalled();
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=sync', {
newTab: true,
});
});
});
describe('with variable selections', () => {
beforeEach(() => {
useDashboardStore.setState({
dashboardId: 'dash-1',
resolvedVariables: {
'dash-1': {
service: {
type: Querybuildertypesv5VariableTypeDTO.query,
value: 'checkout',
},
},
},
});
});
it('substitutes variables before seeding, then opens the resolved alert', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
// Round-trips the panel's queries + resolved variables.
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
expect.objectContaining({
queries: panel.spec.queries,
panelType: PANEL_TYPES.TIME_SERIES,
variables: { service: { type: 'query', value: 'checkout' } },
}),
);
expect(mockSubstituteVars).toHaveBeenCalledWith(
{ data: { request: 'payload' } },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
// Nothing opens until the round-trip resolves.
expect(mockSafeNavigate).not.toHaveBeenCalled();
const { onSuccess } = mockSubstituteVars.mock.calls[0][1];
onSuccess({ data: { compositeQuery: { queries: [{ type: 'builder' }] } } });
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/alerts/new?composite=substituted',
{ newTab: true },
);
});
it('notifies and does not navigate when substitution fails', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
const { onError } = mockSubstituteVars.mock.calls[0][1];
onError();
expect(mockToastError).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ description: expect.any(String) }),
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,8 +3,7 @@ import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import {
addPanelToSectionOps,
findFreeSlot,
@@ -32,7 +31,7 @@ export function useClonePanel({
sections,
}: Params): (args: ClonePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
return useCallback(
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
@@ -45,8 +44,7 @@ export function useClonePanel({
const newPanelId = uuid();
const { x, y } = findFreeSlot(section.items, source.width);
const clone = patchDashboardV2(
{ id: dashboardId },
const clone = patchAsync(
addPanelToSectionOps({
panelId: newPanelId,
panel: cloneDeep(source.panel),
@@ -68,15 +66,14 @@ export function useClonePanel({
position: 'top-center',
});
// Refetch only on success; toast.promise owns the error UX, so swallow
// the rejection to avoid an unhandled rejection.
// toast.promise owns the error UX; swallow here to avoid an unhandled
// rejection (the optimistic cache write + settle refetch handle state).
try {
await clone;
refetch();
} catch {
// no-op
}
},
[sections, dashboardId, refetch],
[sections, dashboardId, patchAsync],
);
}

View File

@@ -1,18 +1,32 @@
import { useCallback } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
import {
buildAlertUrl,
buildCreateAlertUrl,
readPanelUnit,
} from '../utils/buildCreateAlertUrl';
/**
* Returns a callback that opens the alert builder in a new tab, seeded from a
* panel's query, and logs the action — mirroring V1's `useCreateAlerts`
* ('dashboardView' caller). The panel is supplied at call time so the callback
* stays stable across panels (and the dashboard's react-query refetches).
* Callback that seeds the alert builder from a panel's query in a new tab (V1 parity
* with `useCreateAlerts`; panel supplied at call time so the callback stays stable).
* With variable selections, resolves them via `/substitute_vars` first; otherwise
* seeds synchronously (the round-trip would be a no-op).
*/
export function useCreateAlertFromPanel(): (
panel: DashboardtypesPanelDTO,
@@ -20,18 +34,61 @@ export function useCreateAlertFromPanel(): (
) => void {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { mutate: substituteVars } = useReplaceVariables();
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
void logEvent('Dashboard Detail: Panel action', {
action: 'createAlerts',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
panelType,
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
});
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
if (Object.keys(variables).length === 0) {
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
return;
}
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
panelType,
startMs: minTime / 1e6,
endMs: maxTime / 1e6,
variables,
});
substituteVars(
{ data: request },
{
onSuccess: (response) => {
const query = envelopesToQuery(
response.data.compositeQuery?.queries ?? [],
panelType,
);
const url = buildAlertUrl(
query,
panelType,
readPanelUnit(panel.spec.plugin),
);
safeNavigate(url, { newTab: true });
},
onError: () => {
toast.error(SOMETHING_WENT_WRONG, {
description: 'Failed to create alert from panel',
});
},
},
);
},
[dashboardId, safeNavigate],
[dashboardId, variables, minTime, maxTime, substituteVars, safeNavigate],
);
}

View File

@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -25,7 +25,7 @@ export function useDeletePanel({
sections,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -40,15 +40,14 @@ export function useDeletePanel({
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchDashboardV2({ id: dashboardId }, [
await patchAsync([
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
[sections, dashboardId, patchAsync, showErrorModal],
);
}

View File

@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -27,7 +27,7 @@ export function useMovePanelToSection({
sections,
}: Params): (args: MovePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -60,8 +60,7 @@ export function useMovePanelToSection({
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchDashboardV2(
{ id: dashboardId },
await patchAsync(
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
@@ -69,11 +68,10 @@ export function useMovePanelToSection({
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
[sections, dashboardId, patchAsync, showErrorModal],
);
}

View File

@@ -5,11 +5,14 @@ import type {
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
function readPanelUnit(
/** The panel's configured y-axis unit, for the kinds that carry one. */
export function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
): string | undefined {
switch (plugin.kind) {
@@ -24,20 +27,17 @@ function readPanelUnit(
}
/**
* Builds the `/alerts/new` URL that seeds the alert builder from a panel's query,
* mirroring V1's `useCreateAlerts`: the panel's V5 queries are translated to the
* V1 `Query` the alert page reads from `compositeQuery`, tagged with the panel
* type, entity version, and a `dashboards` source.
*
* Unlike V1 there is no `/substitute_vars` round-trip — V2 has no query-variable
* plumbing yet, so any dashboard-variable references travel through verbatim.
* Assembles the `/alerts/new` URL from a ready V1 `Query`: the alert page reads it
* from `compositeQuery`, tagged with the panel type, entity version, and a
* `dashboards` source.
*/
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
): string {
if (unit) {
// eslint-disable-next-line no-param-reassign
query.unit = unit;
}
@@ -52,3 +52,15 @@ export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
}
/**
* Seeds the alert builder from a panel's query — the no-variable path, so any
* dashboard-variable references travel through verbatim. When the dashboard has
* selections, `useCreateAlertFromPanel` runs a `/substitute_vars` round-trip first
* and assembles the URL from the resolved queries via {@link buildAlertUrl}.
*/
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
return buildAlertUrl(query, panelType, readPanelUnit(panel.spec.plugin));
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import {
addSectionOp,
newGridLayout,
@@ -15,9 +15,9 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the refetch to render the appended section, then scrolls
* it into view. Polls because `refetch` resolves before React commits the new
* section to the DOM; bails after ~40 frames.
* Waits (via rAF) for the appended section to render, then scrolls it into view.
* Polls because the optimistic cache write commits to the DOM a frame or two after
* the patch call; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
@@ -49,7 +49,7 @@ interface Result {
*/
export function useAddSection({ layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -66,8 +66,7 @@ export function useAddSection({ layouts }: Params): Result {
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
await patchAsync([op]);
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
@@ -75,7 +74,7 @@ export function useAddSection({ layouts }: Params): Result {
setIsSaving(false);
}
},
[layouts, dashboardId, refetch, showErrorModal],
[layouts, dashboardId, patchAsync, showErrorModal],
);
return { addSection, isSaving };

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { removePanelOp, removeSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -24,7 +24,7 @@ interface Result {
*/
export function useDeleteSection({ section }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -38,14 +38,13 @@ export function useDeleteSection({ section }: Params): Result {
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
await patchAsync(ops);
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, refetch, showErrorModal]);
}, [section, dashboardId, patchAsync, showErrorModal]);
return { deleteSection, isSaving };
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -26,7 +26,7 @@ interface Result {
*/
export function useFirstSectionMigration({ sections }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -49,15 +49,14 @@ export function useFirstSectionMigration({ sections }: Params): Result {
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
await patchAsync(ops);
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, refetch, showErrorModal],
[sections, dashboardId, patchAsync, showErrorModal],
);
return { migrate, isSaving };

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import type { Layout } from 'react-grid-layout';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { GridItem } from '../../../utils';
@@ -65,7 +65,7 @@ function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean {
*/
export function usePersistLayout({ layoutIndex, items }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -80,17 +80,14 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
refetch();
await patchAsync([replaceSectionItemsOp(layoutIndex, nextItems)]);
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, refetch, showErrorModal],
[dashboardId, items, layoutIndex, patchAsync, showErrorModal],
);
return { handleLayoutChange, isSaving };

View File

@@ -1,9 +1,9 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { renameSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
@@ -19,7 +19,7 @@ interface Result {
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
export function useRenameSection({ layoutIndex }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -31,10 +31,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
refetch();
await patchAsync([renameSectionOp(layoutIndex, trimmed)]);
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -43,7 +40,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, refetch, showErrorModal],
[dashboardId, layoutIndex, patchAsync, showErrorModal],
);
return { rename, isSaving };

View File

@@ -9,11 +9,11 @@ import {
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { reorderLayoutsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -43,7 +43,7 @@ interface Result {
*/
export function useSectionDragReorder({ sections, layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { patchAsync } = useOptimisticPatch();
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
@@ -99,14 +99,13 @@ export function useSectionDragReorder({ sections, layouts }: Params): Result {
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
refetch();
await patchAsync([reorderLayoutsOp(newLayouts)]);
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
[orderedSections, layouts, dashboardId, patchAsync, showErrorModal],
);
const activeSection = useMemo(

View File

@@ -0,0 +1,107 @@
import { renderHook } from '@testing-library/react';
import { useMutation, useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports -- the hook's own test mocks and asserts the underlying patchDashboardV2 call.
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { useOptimisticPatch } from '../useOptimisticPatch';
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
jest.mock('react-query', () => ({
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string }) => unknown) =>
selector({ dashboardId: 'dash-1' }),
),
}));
const queryClient = {
cancelQueries: jest.fn().mockResolvedValue(undefined),
getQueryData: jest.fn(),
setQueryData: jest.fn(),
invalidateQueries: jest.fn().mockResolvedValue(undefined),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let captured: { fn: (ops: any) => unknown; options: any };
function dashboardEnvelope(name: string): GetDashboardV2200 {
return {
data: { spec: { display: { name } } },
} as unknown as GetDashboardV2200;
}
const replaceNameOp = {
op: DashboardtypesPatchOpDTO.replace,
path: '/spec/display/name',
value: 'B',
};
beforeEach(() => {
jest.clearAllMocks();
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
(useMutation as jest.Mock).mockImplementation((fn, options) => {
captured = { fn, options };
return { mutateAsync: jest.fn(), isLoading: false };
});
renderHook(() => useOptimisticPatch());
});
describe('useOptimisticPatch', () => {
it('mutationFn sends the ops to patchDashboardV2 for the current dashboard', () => {
captured.fn([replaceNameOp]);
expect(patchDashboardV2).toHaveBeenCalledWith({ id: 'dash-1' }, [
replaceNameOp,
]);
});
it('onMutate cancels fetches, snapshots, and writes the patched dashboard to the cache', async () => {
const previous = dashboardEnvelope('A');
queryClient.getQueryData.mockReturnValue(previous);
const context = await captured.options.onMutate([replaceNameOp]);
expect(queryClient.cancelQueries).toHaveBeenCalledWith(QUERY_KEY);
// Optimistic write reflects the op immediately.
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, {
data: { spec: { display: { name: 'B' } } },
});
// Snapshot returned for rollback; original left untouched.
expect(context).toStrictEqual({ previous });
expect(previous.data).toStrictEqual({ spec: { display: { name: 'A' } } });
});
it('onMutate is a no-op write when there is no cached dashboard', async () => {
queryClient.getQueryData.mockReturnValue(undefined);
const context = await captured.options.onMutate([replaceNameOp]);
expect(queryClient.setQueryData).not.toHaveBeenCalled();
expect(context).toStrictEqual({ previous: undefined });
});
it('onError rolls the cache back to the snapshot', () => {
const previous = dashboardEnvelope('A');
captured.options.onError(new Error('boom'), [replaceNameOp], { previous });
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, previous);
});
it('onError without a snapshot does not touch the cache', () => {
captured.options.onError(new Error('boom'), [replaceNameOp], {});
expect(queryClient.setQueryData).not.toHaveBeenCalled();
});
it('onSettled invalidates the dashboard query to reconcile', () => {
captured.options.onSettled();
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(QUERY_KEY);
});
});

View File

@@ -0,0 +1,73 @@
import { useMutation, useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
// eslint-disable-next-line no-restricted-imports -- this hook is the one sanctioned caller of patchDashboardV2; everything else goes through patchAsync.
patchDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import APIError from 'types/api/error';
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
import { useDashboardStore } from '../store/useDashboardStore';
/** Cached dashboard snapshot, kept for rollback on error. */
interface OptimisticPatchContext {
previous?: GetDashboardV2200;
}
export interface UseOptimisticPatch {
patchAsync: (ops: DashboardtypesJSONPatchOperationDTO[]) => Promise<unknown>;
isPatching: boolean;
error: Error | null;
}
/**
* Central optimistic mutation for V2 dashboard spec edits: writes the ops to the
* cached dashboard immediately, rolls back on error, reconciles on settle.
* `dashboardId` defaults to the edit-context store; the panel editor passes its own.
*/
export function useOptimisticPatch(
dashboardIdOverride?: string,
): UseOptimisticPatch {
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const mutation = useMutation<
Awaited<ReturnType<typeof patchDashboardV2>>,
APIError,
DashboardtypesJSONPatchOperationDTO[],
OptimisticPatchContext
>((ops) => patchDashboardV2({ id: dashboardId }, ops), {
onMutate: async (ops) => {
await queryClient.cancelQueries(queryKey);
const previous = queryClient.getQueryData<GetDashboardV2200>(queryKey);
if (previous?.data) {
// Ops are rooted at the DTO's `/spec`, so patch `.data`, keep the envelope.
queryClient.setQueryData<GetDashboardV2200>(queryKey, {
...previous,
data: applyJsonPatch(previous.data, ops),
});
}
return { previous };
},
onError: (_error, _ops, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSettled: () => {
void queryClient.invalidateQueries(queryKey);
},
});
return {
patchAsync: mutation.mutateAsync,
isPatching: mutation.isLoading,
error: mutation.error ?? null,
};
}

View File

@@ -0,0 +1,138 @@
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { applyJsonPatch } from '../applyJsonPatch';
const { add, replace, remove, move, test: testOp } = DashboardtypesPatchOpDTO;
function op(
o: DashboardtypesPatchOpDTO,
path: string,
value?: unknown,
): DashboardtypesJSONPatchOperationDTO {
return { op: o, path, value };
}
// A trimmed dashboard-spec shape; the applier is structural, so this stands in
// for the full DTO.
function spec(): Record<string, unknown> {
return {
spec: {
display: { name: 'dash' },
panels: { p1: { spec: { display: { name: 'A' } } } },
layouts: [
{ spec: { display: { title: 'S1' }, items: [{ x: 0 }] } },
{ spec: { items: [] } },
],
variables: [{ name: 'env' }],
},
};
}
describe('applyJsonPatch', () => {
it('does not mutate the input document', () => {
const doc = spec();
const snapshot = JSON.stringify(doc);
applyJsonPatch(doc, [op(replace, '/spec/display/name', 'renamed')]);
expect(JSON.stringify(doc)).toBe(snapshot);
});
it('replaces a leaf string', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/layouts/0/spec/display/title', 'S1-renamed'),
]);
const layouts = (next.spec as any).layouts;
expect(layouts[0].spec.display.title).toBe('S1-renamed');
});
it('adds a new object member (panel by id)', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/panels/p2', { spec: { display: { name: 'B' } } }),
]);
expect((next.spec as any).panels.p2.spec.display.name).toBe('B');
// existing member untouched
expect((next.spec as any).panels.p1.spec.display.name).toBe('A');
});
it('appends to an array with the "-" token', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/-', { spec: { items: [] } }),
]);
expect((next.spec as any).layouts).toHaveLength(3);
});
it('appends an item into a nested section array', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/1/spec/items/-', { x: 5 }),
]);
expect((next.spec as any).layouts[1].spec.items).toStrictEqual([{ x: 5 }]);
});
it('replaces a whole array', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/variables', [{ name: 'region' }, { name: 'pod' }]),
]);
expect((next.spec as any).variables).toStrictEqual([
{ name: 'region' },
{ name: 'pod' },
]);
});
it('removes an array element by index (section)', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/layouts/0')]);
const layouts = (next.spec as any).layouts;
expect(layouts).toHaveLength(1);
expect(layouts[0].spec.items).toStrictEqual([]);
});
it('removes an object member (panel by id)', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/p1')]);
expect((next.spec as any).panels).toStrictEqual({});
});
it('adds a missing object parent for an add op (title untitled section)', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/1/spec/display', { title: 'S2' }),
]);
expect((next.spec as any).layouts[1].spec.display).toStrictEqual({
title: 'S2',
});
});
it('is lenient: remove on a missing path is a no-op', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/ghost')]);
expect((next.spec as any).panels.p1).toBeDefined();
});
it('is lenient: a path through a missing node is skipped', () => {
const next = applyJsonPatch(spec(), [op(replace, '/spec/nope/deep/leaf', 1)]);
expect(next).toStrictEqual(spec());
});
it('unescapes ~1 and ~0 in reference tokens', () => {
const doc = { spec: { m: { 'a/b': 1, 'c~d': 2 } } };
const next = applyJsonPatch(doc, [
op(replace, '/spec/m/a~1b', 9),
op(replace, '/spec/m/c~0d', 8),
]);
expect(next.spec.m).toStrictEqual({ 'a/b': 9, 'c~d': 8 });
});
it('applies multiple ops in order', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/panels/p2', { spec: {} }),
op(remove, '/spec/panels/p1'),
op(replace, '/spec/display/name', 'z'),
]);
expect(Object.keys((next.spec as any).panels)).toStrictEqual(['p2']);
expect((next.spec as any).display.name).toBe('z');
});
it('treats move/copy/test as no-ops', () => {
const next = applyJsonPatch(spec(), [
op(move, '/spec/display/name'),
op(testOp, '/spec/display/name', 'dash'),
]);
expect(next).toStrictEqual(spec());
});
});

View File

@@ -0,0 +1,146 @@
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
/**
* Applies the RFC-6902 ops our `patchOps` builders emit to a document, so a
* dashboard edit can be reflected in the react-query cache optimistically before
* the server responds. Pure: deep-clones and returns a new document, never
* mutating the input.
*
* Deliberately lenient — mirrors the backend's apply (a `remove`/`replace` on a
* missing path is a no-op, `add` creates missing object parents) rather than
* throwing as strict RFC-6902 would. This is safe because the mutation always
* refetches on settle, so any mis-applied edge op self-corrects; the applier only
* needs to be right for the common case to kill the perceived lag.
*
* Scope: `add` / `replace` / `remove` (the only ops the builders produce).
* `move` / `copy` / `test` are never emitted, so they are treated as no-ops.
*/
export function applyJsonPatch<T>(
doc: T,
ops: DashboardtypesJSONPatchOperationDTO[],
): T {
const next = cloneDeep(doc);
ops.forEach((op) => applyOperation(next as unknown, op));
return next;
}
type JsonRecord = Record<string, unknown>;
function isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Unescape one JSON-Pointer reference token (RFC-6901): `~1`→`/`, `~0`→`~`. */
function unescapeToken(token: string): string {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}
/** Parse a JSON Pointer into its reference tokens (`""`/`"/"` → root, `[]`). */
function parsePointer(path: string): string[] {
if (!path || path === '/') {
return [];
}
return path.slice(1).split('/').map(unescapeToken);
}
/**
* Walks to the container that holds the pointer's last token. Returns `undefined`
* when the path can't be resolved (lenient skip). For `add`, missing intermediate
* object nodes are created (backend parity); array steps are never auto-created.
*/
function navigateToParent(
root: unknown,
tokens: string[],
createMissing: boolean,
): unknown {
let current: unknown = root;
for (let i = 0; i < tokens.length - 1; i += 1) {
const token = tokens[i];
if (isArray(current)) {
const index = token === '-' ? current.length : Number(token);
current = current[index];
} else if (isRecord(current)) {
if (current[token] === undefined && createMissing) {
current[token] = {};
}
current = current[token];
} else {
return undefined;
}
if (current === undefined || current === null) {
return undefined;
}
}
return current;
}
/** `add`: array-insert (`-` = append) or object-set. */
function addAt(parent: unknown, key: string, value: unknown): void {
if (isArray(parent)) {
const index = key === '-' ? parent.length : Number(key);
parent.splice(index, 0, value);
} else if (isRecord(parent)) {
parent[key] = value;
}
}
/** `replace`: overwrite an in-range array index or an object key. */
function replaceAt(parent: unknown, key: string, value: unknown): void {
if (isArray(parent)) {
const index = Number(key);
if (index >= 0 && index < parent.length) {
parent[index] = value;
}
} else if (isRecord(parent)) {
parent[key] = value;
}
}
/** `remove`: splice an in-range array index or delete an object key (lenient). */
function removeAt(parent: unknown, key: string): void {
if (isArray(parent)) {
const index = Number(key);
if (index >= 0 && index < parent.length) {
parent.splice(index, 1);
}
} else if (isRecord(parent)) {
delete parent[key];
}
}
function applyOperation(
root: unknown,
op: DashboardtypesJSONPatchOperationDTO,
): void {
const tokens = parsePointer(op.path);
// Whole-document ops would need to reassign the root reference — our builders
// never target root, so skip rather than complicate the contract.
if (tokens.length === 0) {
return;
}
const parent = navigateToParent(
root,
tokens,
op.op === DashboardtypesPatchOpDTO.add,
);
if (parent === undefined || parent === null) {
return;
}
const key = tokens[tokens.length - 1];
// move / copy / test are never emitted by our builders → no-op (reconciled by refetch).
if (op.op === DashboardtypesPatchOpDTO.add) {
addAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.replace) {
replaceAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.remove) {
removeAt(parent, key);
}
}

View File

@@ -1,10 +1,13 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { fromPerses, toPerses } from '../persesQueryAdapters';
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
function bareQuery(
@@ -58,6 +61,26 @@ describe('persesQueryAdapters', () => {
});
});
describe('envelopesToQuery', () => {
it('returns the metrics default for an empty envelope list', () => {
expect(envelopesToQuery([], PANEL_TYPES.TIME_SERIES)).toStrictEqual(
initialQueriesMap[DataSource.METRICS],
);
});
it('maps a promql envelope to a PromQL query', () => {
const envelopes: Querybuildertypesv5QueryEnvelopeDTO[] = [
{
type: 'promql',
spec: { name: 'A', query: 'up', disabled: false },
} as unknown as Querybuildertypesv5QueryEnvelopeDTO,
];
expect(envelopesToQuery(envelopes, PANEL_TYPES.TIME_SERIES).queryType).toBe(
EQueryType.PROM,
);
});
});
describe('toPerses', () => {
it('wraps the query in a single signoz/CompositeQuery keyed to the panel request type', () => {
const result = toPerses(

View File

@@ -74,14 +74,14 @@ export function deriveQueryType(
}
/**
* Perses panel queries → V1 `Query` (to seed the query builder), via the V5 envelope
* list + `mapQueryDataFromApi`. An empty panel opens on a fresh metrics builder query.
* V5 query-envelope list → V1 `Query`, via `mapQueryDataFromApi`. An empty list opens
* on a fresh metrics builder query. Used by `fromPerses` and by the envelopes a
* `/substitute_vars` round-trip returns with dashboard variables resolved.
*/
export function fromPerses(
queries: DashboardtypesQueryDTO[],
export function envelopesToQuery(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
panelType: PANEL_TYPES,
): Query {
const envelopes = toQueryEnvelopes(queries);
if (envelopes.length === 0) {
return initialQueriesMap[DataSource.METRICS];
}
@@ -99,6 +99,17 @@ export function fromPerses(
return mapQueryDataFromApi(composite);
}
/**
* Perses panel queries → V1 `Query` (to seed the query builder), via the V5 envelope
* list + `mapQueryDataFromApi`. An empty panel opens on a fresh metrics builder query.
*/
export function fromPerses(
queries: DashboardtypesQueryDTO[],
panelType: PANEL_TYPES,
): Query {
return envelopesToQuery(toQueryEnvelopes(queries), panelType);
}
/**
* V1 `Query` → perses panel queries (to write the builder result back to the editor
* draft). Wrapped in a single `signoz/CompositeQuery` to satisfy the