Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
4b9c7b4003 fix(dashboards): guard against missing orderBy in useLogsData
Public dashboards redact the widget query (orderBy/filter/limit are
stripped), so a LIST query can reach useLogsData with no orderBy. The
optional chain guarded listQuery but not orderBy, so listQuery?.orderBy.find
threw once the public logs panel started rendering. Guard orderBy with ?..
2026-07-02 10:41:58 +05:30
Ashwin Bhatkal
55787df825 fix(dashboards): hide pagination controls on public list panels
Public widget data is fetched by index and the payload redacts each
widget's limit, so LogsPanelComponent/TracesTableComponent always showed a
pager that can't page (the endpoint takes no pagination params). Thread an
optional hidePagination flag from the public Panel through
WidgetGraphComponent -> PanelWrapper -> ListPanelWrapper to both list
components; default off, so authenticated dashboards are unchanged.
2026-07-02 09:23:36 +05:30
Ashwin Bhatkal
ab9f0ed67d fix(dashboards): render logs/traces list panels on public dashboards
Public list panels rendered nothing even though the query returned data:
ListPanelWrapper bails with an empty fragment when setRequestData is
undefined, and the public Panel never forwarded one.

Back requestData with state and forward the setter to WidgetGraphComponent,
mirroring the authenticated GridCard. The per-panel request builder is
extracted to utils.ts.

Fixes SigNoz/engineering-pod#3646
2026-07-02 08:40:00 +05:30
Ashwin Bhatkal
33fbc28908 fix(dashboards): stop query cache collisions on public dashboards
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 08:35:41 +05:30
39 changed files with 370 additions and 922 deletions

View File

@@ -328,11 +328,6 @@
{
"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

@@ -66,6 +66,7 @@ function WidgetGraphComponent({
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
hidePagination,
}: WidgetGraphComponentProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [deleteModal, setDeleteModal] = useState(false);
@@ -430,6 +431,7 @@ function WidgetGraphComponent({
customSeries={customSeries}
customOnRowClick={customOnRowClick}
enableDrillDown={enableDrillDown}
hidePagination={hidePagination}
onColumnWidthsChange={onColumnWidthsChange}
/>
</div>

View File

@@ -42,6 +42,8 @@ export interface WidgetGraphComponentProps {
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
/** Hide list-panel pagination controls (e.g. public dashboards, where paging isn't supported). */
hidePagination?: boolean;
}
export interface GridCardGraphProps {

View File

@@ -34,6 +34,7 @@ function LogsPanelComponent({
setRequestData,
queryResponse,
onColumnWidthsChange,
hidePagination,
}: LogsPanelComponentProps): JSX.Element {
const [pageSize, setPageSize] = useState<number>(10);
const [offset, setOffset] = useState<number>(0);
@@ -158,7 +159,7 @@ function LogsPanelComponent({
/>
</OverlayScrollbar>
</div>
{!widget.query.builder.queryData[0].limit && (
{!hidePagination && !widget.query.builder.queryData[0].limit && (
<div className="controller">
<Controls
totalCount={totalCount}
@@ -198,6 +199,7 @@ export type LogsPanelComponentProps = {
>;
widget: Widgets;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
hidePagination?: boolean;
};
export default LogsPanelComponent;

View File

@@ -9,6 +9,7 @@ function ListPanelWrapper({
queryResponse,
setRequestData,
onColumnWidthsChange,
hidePagination,
}: PanelWrapperProps): JSX.Element {
const dataSource = widget.query.builder?.queryData[0]?.dataSource;
@@ -23,6 +24,7 @@ function ListPanelWrapper({
queryResponse={queryResponse}
setRequestData={setRequestData}
onColumnWidthsChange={onColumnWidthsChange}
hidePagination={hidePagination}
/>
);
}
@@ -32,6 +34,7 @@ function ListPanelWrapper({
queryResponse={queryResponse}
setRequestData={setRequestData}
onColumnWidthsChange={onColumnWidthsChange}
hidePagination={hidePagination}
/>
);
}

View File

@@ -26,6 +26,7 @@ function PanelWrapper({
panelMode,
enableDrillDown = false,
onColumnWidthsChange,
hidePagination,
}: PanelWrapperProps): JSX.Element {
const Component = PanelTypeVsPanelWrapper[
selectedGraph || widget.panelTypes
@@ -76,6 +77,7 @@ function PanelWrapper({
enableDrillDown={enableDrillDown}
onColumnWidthsChange={onColumnWidthsChange}
groupByPerQuery={groupByPerQuery}
hidePagination={hidePagination}
/>
);
}

View File

@@ -32,6 +32,7 @@ export type PanelWrapperProps = {
panelMode: PanelMode;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
hidePagination?: boolean;
};
export type TooltipData = {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useRef } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyWidget from 'container/GridCardLayout/EmptyWidget';
@@ -6,11 +6,12 @@ import WidgetGraphComponent from 'container/GridCardLayout/GridCard/WidgetGraphC
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { isEqual } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
import { DataSource } from 'types/common/queryBuilder';
import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getPublicPanelRequestData } from './utils';
function Panel({
widget,
index,
@@ -27,54 +28,27 @@ function Panel({
const graphRef = useRef<HTMLDivElement>(null);
const updatedQuery = widget?.query;
const requestData: GetQueryResultsProps = useMemo(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
return {
selectedTime: widget?.timePreferance,
graphType: getGraphType(widget.panelTypes),
// State (not memo) so LIST panels get a setRequestData — ListPanelWrapper
// renders nothing without one.
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() =>
getPublicPanelRequestData({ widget, startTime, endTime }),
);
useEffect(() => {
if (!isEqual(updatedQuery, requestData.query)) {
setRequestData((prev) => ({
...prev,
query: updatedQuery,
variables: {}, // we are not supporting variables in public dashboards
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: startTime,
end: endTime,
originalGraphType: widget.panelTypes,
};
}));
}
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
const updatedQueryForList = {
...updatedQuery,
builder: {
...updatedQuery.builder,
queryData: updatedQuery.builder.queryData.map((qd, i) =>
i === 0 ? { ...qd, pageSize: 10 } : qd,
),
},
};
return {
query: updatedQueryForList,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {
pagination: {
offset: 0,
limit: updatedQuery.builder.queryData[0].limit || 0,
},
// we do not need select columns in case of logs
selectColumns:
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
},
fillGaps: widget.fillSpans,
start: startTime,
end: endTime,
};
}, [widget, updatedQuery, startTime, endTime]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updatedQuery]);
const queryResponse = useGetQueryRange(
{
...requestData,
start: startTime,
end: endTime,
originalGraphType: widget?.panelTypes,
},
ENTITY_VERSION_V5,
@@ -140,6 +114,8 @@ function Panel({
headerMenuList={[]}
isWarning={false}
isFetchingResponse={queryResponse.isFetching || queryResponse.isLoading}
setRequestData={setRequestData}
hidePagination
onDragSelect={onDragSelect}
/>
)}

View File

@@ -19,9 +19,14 @@ jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
},
}));
const widgetGraphProps = jest.fn();
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="widget-graph" />,
default: (props: { setRequestData?: unknown }): JSX.Element => {
widgetGraphProps(props);
return <div data-testid="widget-graph" />;
},
}));
const buildWidget = (id: string): Widgets =>
@@ -39,6 +44,22 @@ const buildWidget = (id: string): Widgets =>
describe('Public dashboard Panel', () => {
beforeEach(() => {
useGetQueryRangeMock.mockClear();
widgetGraphProps.mockClear();
});
it('forwards a setRequestData setter so LIST panels render (bug 3646)', () => {
render(
<Panel
widget={buildWidget('widget-a')}
index={0}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>,
);
const props = widgetGraphProps.mock.calls[0][0];
expect(typeof props.setRequestData).toBe('function');
});
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {

View File

@@ -0,0 +1,53 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { Widgets } from 'types/api/dashboard/getAll';
import { DataSource } from 'types/common/queryBuilder';
import { getGraphType } from 'utils/getGraphType';
// Builds the useGetQueryRange payload for a public-dashboard panel, mirroring the
// authenticated GridCard.
export const getPublicPanelRequestData = ({
widget,
startTime,
endTime,
}: {
widget: Widgets;
startTime: number;
endTime: number;
}): GetQueryResultsProps => {
const updatedQuery = widget?.query;
if (widget.panelTypes !== PANEL_TYPES.LIST) {
return {
selectedTime: widget?.timePreferance,
graphType: getGraphType(widget.panelTypes),
query: updatedQuery,
variables: {}, // we are not supporting variables in public dashboards
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: startTime,
end: endTime,
originalGraphType: widget.panelTypes,
};
}
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
return {
query: updatedQuery,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {
pagination: {
offset: 0,
limit: updatedQuery.builder.queryData[0].limit || 0,
},
// we do not need select columns in case of logs
selectColumns:
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
},
fillGaps: widget.fillSpans,
start: startTime,
end: endTime,
};
};

View File

@@ -37,6 +37,7 @@ function TracesTableComponent({
queryResponse,
setRequestData,
onColumnWidthsChange,
hidePagination,
}: TracesTableComponentProps): JSX.Element {
const [pagination, setPagination] = useState<Pagination>({
offset: 0,
@@ -139,34 +140,36 @@ function TracesTableComponent({
/>
</OverlayScrollbar>
</div>
<div className="controller">
<Controls
totalCount={totalCount}
perPageOptions={PER_PAGE_OPTIONS}
isLoading={queryResponse.isFetching}
offset={pagination.offset}
countPerPage={pagination.limit}
handleNavigatePrevious={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset - pagination.limit,
});
}}
handleNavigateNext={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset + pagination.limit,
});
}}
handleCountItemsPerPageChange={(value): void => {
handlePaginationChange({
...pagination,
limit: value,
offset: 0,
});
}}
/>
</div>
{!hidePagination && (
<div className="controller">
<Controls
totalCount={totalCount}
perPageOptions={PER_PAGE_OPTIONS}
isLoading={queryResponse.isFetching}
offset={pagination.offset}
countPerPage={pagination.limit}
handleNavigatePrevious={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset - pagination.limit,
});
}}
handleNavigateNext={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset + pagination.limit,
});
}}
handleCountItemsPerPageChange={(value): void => {
handlePaginationChange({
...pagination,
limit: value,
offset: 0,
});
}}
/>
</div>
)}
</div>
);
}
@@ -178,6 +181,7 @@ export type TracesTableComponentProps = {
>;
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
hidePagination?: boolean;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
};

View File

@@ -0,0 +1,30 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useLogsData } from '../useLogsData';
describe('useLogsData', () => {
// Public dashboards redact the widget query (orderBy/filter/limit stripped),
// so a LIST query can arrive with no orderBy — the hook must not crash on it.
it('does not crash when the query has no orderBy', () => {
const stagedQuery = {
builder: {
queryData: [{ dataSource: 'logs', queryName: 'A', disabled: false }],
},
} as unknown as Query;
const { result } = renderHook(
() =>
useLogsData({
result: undefined,
panelType: PANEL_TYPES.LIST,
stagedQuery,
}),
{ wrapper: AllTheProviders },
);
expect(result.current.logs).toStrictEqual([]);
});
});

View File

@@ -71,7 +71,7 @@ export const useLogsData = ({
}, [logs.length, listQuery]);
const orderByTimestamp: OrderByPayload | null = useMemo(() => {
const timestampOrderBy = listQuery?.orderBy.find(
const timestampOrderBy = listQuery?.orderBy?.find(
(item) => item.columnName === 'timestamp',
);

View File

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

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -8,7 +9,7 @@ import { isEqual } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
@@ -22,7 +23,7 @@ interface OverviewProps {
function Overview({ dashboard }: OverviewProps): JSX.Element {
const id = dashboard.id;
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -95,14 +96,15 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [buildPatch, patchAsync, showErrorModal]);
}, [id, buildPatch, refetch, 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,9 +14,14 @@ 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
@@ -28,8 +33,9 @@ export function useSaveVariables(): UseSaveVariables {
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchAsync(buildVariablesPatch(dtos));
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -38,7 +44,7 @@ export function useSaveVariables(): UseSaveVariables {
setIsSaving(false);
}
},
[dashboardId, patchAsync, showErrorModal],
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };

View File

@@ -1,36 +1,40 @@
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 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.
const mockInvalidateQueries = jest.fn();
jest.mock('react-query', () => ({
useQueryClient: (): { getQueryData: jest.Mock } => ({
getQueryData: jest.fn(),
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
invalidateQueries: mockInvalidateQueries,
}),
}));
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();
mockIsPatching = false;
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
});
it('optimistically patches an add replacing the whole panel spec', async () => {
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
@@ -46,17 +50,28 @@ describe('usePanelEditorSave', () => {
await result.current.save(spec);
expect(mockPatchAsync).toHaveBeenCalledWith([
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: 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',
]);
});
it('surfaces the patch in-flight state as isSaving', () => {
mockIsPatching = true;
it('surfaces the mutation loading state as isSaving', () => {
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: true,
error: null,
});
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Color } from '@signozhq/design-tokens';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
@@ -17,30 +17,29 @@ function PanelTypeSelectionModal({
onSelect,
}: PanelTypeSelectionModalProps): JSX.Element {
return (
<DialogWrapper
<Modal
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"
className={styles.panelTypeCard}
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
<Icon size={14} />
{label}
</button>
</Button>
))}
</div>
</DialogWrapper>
</Modal>
);
}

View File

@@ -1,15 +1,12 @@
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';
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
jest.mock('../../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
patchAsync: mockPatchAsync,
isPatching: false,
}),
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
}));
const mockToastPromise = jest.fn();
@@ -19,6 +16,8 @@ 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: {
@@ -46,7 +45,7 @@ function sections(): DashboardSection[] {
describe('useClonePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
});
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
@@ -54,7 +53,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockPatchAsync).toHaveBeenCalledWith([
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
{
op: 'add',
path: '/spec/panels/cloned-id',
@@ -93,7 +92,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatchAsync.mock.calls[0][0];
const ops = mockPatch.mock.calls[0][1];
// 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 });
});
@@ -103,7 +102,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatchAsync.mock.calls[0][0];
const ops = mockPatch.mock.calls[0][1];
expect(ops[0].value).toStrictEqual(sourcePanel);
expect(ops[0].value).not.toBe(sourcePanel);
});
@@ -113,7 +112,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'missing', layoutIndex: 0 });
expect(mockPatchAsync).not.toHaveBeenCalled();
expect(mockPatch).not.toHaveBeenCalled();
expect(mockToastPromise).not.toHaveBeenCalled();
});
@@ -133,7 +132,7 @@ describe('useClonePanel', () => {
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
mockPatch.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await expect(

View File

@@ -1,7 +1,6 @@
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';
@@ -19,55 +18,12 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
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.
// 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=sync',
buildAlertUrl: (): string => '/alerts/new?composite=substituted',
readPanelUnit: (): string | undefined => undefined,
buildCreateAlertUrl: (): string => '/alerts/new?composite=1',
}));
// 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 = {
@@ -82,7 +38,17 @@ const panel = {
describe('useCreateAlertFromPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1', resolvedVariables: {} });
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,
});
});
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
@@ -100,80 +66,4 @@ 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,7 +3,8 @@ import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import {
addPanelToSectionOps,
findFreeSlot,
@@ -31,7 +32,7 @@ export function useClonePanel({
sections,
}: Params): (args: ClonePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
return useCallback(
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
@@ -44,7 +45,8 @@ export function useClonePanel({
const newPanelId = uuid();
const { x, y } = findFreeSlot(section.items, source.width);
const clone = patchAsync(
const clone = patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId: newPanelId,
panel: cloneDeep(source.panel),
@@ -66,14 +68,15 @@ export function useClonePanel({
position: 'top-center',
});
// toast.promise owns the error UX; swallow here to avoid an unhandled
// rejection (the optimistic cache write + settle refetch handle state).
// Refetch only on success; toast.promise owns the error UX, so swallow
// the rejection to avoid an unhandled rejection.
try {
await clone;
refetch();
} catch {
// no-op
}
},
[sections, dashboardId, patchAsync],
[sections, dashboardId, refetch],
);
}

View File

@@ -1,32 +1,18 @@
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 {
buildAlertUrl,
buildCreateAlertUrl,
readPanelUnit,
} from '../utils/buildCreateAlertUrl';
import { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
/**
* 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).
* 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).
*/
export function useCreateAlertFromPanel(): (
panel: DashboardtypesPanelDTO,
@@ -34,61 +20,18 @@ 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,
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
});
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',
});
},
},
);
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
},
[dashboardId, variables, minTime, maxTime, substituteVars, safeNavigate],
[dashboardId, 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -40,14 +40,15 @@ export function useDeletePanel({
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchAsync([
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -60,7 +60,8 @@ export function useMovePanelToSection({
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchAsync(
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
@@ -68,10 +69,11 @@ export function useMovePanelToSection({
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -5,14 +5,11 @@ 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';
/** The panel's configured y-axis unit, for the kinds that carry one. */
export function readPanelUnit(
function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
): string | undefined {
switch (plugin.kind) {
@@ -27,17 +24,20 @@ export function readPanelUnit(
}
/**
* 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.
* 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.
*/
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
): string {
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);
if (unit) {
// eslint-disable-next-line no-param-reassign
query.unit = unit;
}
@@ -52,15 +52,3 @@ export function buildAlertUrl(
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 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.
* 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.
*/
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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -66,7 +66,8 @@ export function useAddSection({ layouts }: Params): Result {
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchAsync([op]);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
@@ -74,7 +75,7 @@ export function useAddSection({ layouts }: Params): Result {
setIsSaving(false);
}
},
[layouts, dashboardId, patchAsync, showErrorModal],
[layouts, dashboardId, refetch, 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -38,13 +38,14 @@ export function useDeleteSection({ section }: Params): Result {
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, patchAsync, showErrorModal]);
}, [section, dashboardId, refetch, 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -49,14 +49,15 @@ export function useFirstSectionMigration({ sections }: Params): Result {
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -80,14 +80,17 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
}
try {
setIsSaving(true);
await patchAsync([replaceSectionItemsOp(layoutIndex, nextItems)]);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, patchAsync, showErrorModal],
[dashboardId, items, layoutIndex, refetch, 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -31,7 +31,10 @@ export function useRenameSection({ layoutIndex }: Params): Result {
}
try {
setIsSaving(true);
await patchAsync([renameSectionOp(layoutIndex, trimmed)]);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -40,7 +43,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, patchAsync, showErrorModal],
[dashboardId, layoutIndex, refetch, 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 { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
@@ -99,13 +99,14 @@ export function useSectionDragReorder({ sections, layouts }: Params): Result {
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchAsync([reorderLayoutsOp(newLayouts)]);
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
refetch();
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, patchAsync, showErrorModal],
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
);
const activeSection = useMemo(

View File

@@ -1,107 +0,0 @@
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

@@ -1,73 +0,0 @@
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

@@ -1,138 +0,0 @@
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

@@ -1,146 +0,0 @@
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,13 +1,10 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesQueryDTO } 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 { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
import { fromPerses, toPerses } from '../persesQueryAdapters';
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
function bareQuery(
@@ -61,26 +58,6 @@ 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(
}
/**
* 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.
* 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 envelopesToQuery(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
export function fromPerses(
queries: DashboardtypesQueryDTO[],
panelType: PANEL_TYPES,
): Query {
const envelopes = toQueryEnvelopes(queries);
if (envelopes.length === 0) {
return initialQueriesMap[DataSource.METRICS];
}
@@ -99,17 +99,6 @@ export function envelopesToQuery(
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