Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
2b2f8bf35e feat(dashboard-v2): surface dashboard variables in query builder suggestions
Publish the dashboard's variables into the shared store the query builder autocomplete reads, so $variable suggestions appear in the dashboards-page builder and the panel editor.
2026-07-02 13:37:41 +05:30
Ashwin Bhatkal
21cce41efa feat(dashboard-v2): scope panel refetch to referenced variables
A panel now refetches only when a variable its queries reference changes (the cache key is scoped to referenced variables; the full payload is still substituted), and stays in its loading state on first load until those variables resolve.
2026-07-02 13:37:40 +05:30
Ashwin Bhatkal
114170b9cd feat(dashboard-v2): drive variable selectors off the fetch engine
Gate the Query/Dynamic option fetches on the engine's per-variable state and key them by cycle id (so a parent change refetches only its dependents, in order, with no duplicate calls), and report completion/failure back. Wire the selection lifecycle: full fetch cycle on load/time change, descendant cascade on value change.
2026-07-02 13:37:40 +05:30
Ashwin Bhatkal
b2eef28549 feat(dashboard-v2): add runtime variable fetch-state engine
Port V1's variable fetch orchestration natively onto the dashboard store: a fetch-state slice (idle/loading/revalidating/waiting/error + per-variable cycle ids) plus the dependency context derivation (query/dynamic order) needed to schedule fetches. Query variables load in dependency order; dynamics wait for query values. enqueueFetchAll drives first load/time change, enqueueDescendants drives a single value change.
2026-07-02 13:37:40 +05:30
47 changed files with 1046 additions and 1247 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

@@ -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

@@ -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

@@ -18,8 +18,6 @@ interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
@@ -30,7 +28,6 @@ interface VariableSelectorProps {
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
@@ -61,7 +58,6 @@ function VariableSelector({
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}

View File

@@ -23,8 +23,7 @@ interface VariablesBarProps {
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
const { variables, selection, setSelection } = useVariableSelection(dashboard);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
@@ -57,7 +56,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
<VariableSelector
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -10,12 +11,14 @@ import {
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
@@ -30,7 +33,9 @@ interface DynamicSelectorProps {
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
* the runtime fetch engine: dynamics fetch together once the query variables have
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
*/
function DynamicSelector({
variable,
@@ -48,14 +53,51 @@ function DynamicSelector({
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: signalForApi(variable.dynamicSignal),
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching } = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
existingQuery,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
const payload = data?.data;
@@ -71,7 +113,7 @@ function DynamicSelector({
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
loading={isFetching || isVariableWaiting}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -8,18 +8,18 @@ import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
@@ -27,14 +27,15 @@ interface QuerySelectorProps {
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
* per-variable `cycleId` keys the request — so a parent's value change refetches
* only the dependent variables, in dependency order. The current selections feed
* the request payload but are deliberately NOT in the key (V1 parity).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
@@ -43,23 +44,43 @@ function QuerySelector({
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
@@ -79,7 +100,7 @@ function QuerySelector({
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
loading={isFetching || isVariableWaiting}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -0,0 +1,44 @@
import {
selectVariableCycleId,
selectVariableFetchedOnce,
selectVariableFetchState,
type VariableFetchState,
} from '../store/slices/variableFetchSlice';
import { useDashboardStore } from '../store/useDashboardStore';
export interface VariableFetchStateResult {
variableFetchState: VariableFetchState;
/** Include in the selector's react-query key to auto-cancel stale requests. */
variableFetchCycleId: number;
/** Actively fetching (first load or revalidating). */
isVariableFetching: boolean;
/** Stable — the fetch completed (or errored). */
isVariableSettled: boolean;
/** Blocked on parent dependencies (query order) or query variables (dynamics). */
isVariableWaiting: boolean;
/** Completed at least one fetch — keeps the query subscribed so a cycle bump refetches. */
hasVariableFetchedOnce: boolean;
}
/**
* Per-variable view of the runtime fetch engine (`variableFetchSlice`), consumed
* by the Query/Dynamic selectors to gate their fetch and key it by cycle id.
* V2-native equivalent of V1's `useVariableFetchState`.
*/
export function useVariableFetchState(name: string): VariableFetchStateResult {
const variableFetchState = useDashboardStore(selectVariableFetchState(name));
const variableFetchCycleId = useDashboardStore(selectVariableCycleId(name));
const hasVariableFetchedOnce = useDashboardStore(
selectVariableFetchedOnce(name),
);
return {
variableFetchState,
variableFetchCycleId,
isVariableFetching:
variableFetchState === 'loading' || variableFetchState === 'revalidating',
isVariableSettled: variableFetchState === 'idle',
isVariableWaiting: variableFetchState === 'waiting',
hasVariableFetchedOnce,
};
}

View File

@@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
import { useSelector } from 'react-redux';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
@@ -12,8 +16,8 @@ import type {
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
deriveFetchContext,
doAllQueryVariablesHaveValues,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
@@ -45,7 +49,6 @@ function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
@@ -64,27 +67,34 @@ export function useVariableSelection(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Latest selection, read by the fetch-cycle effect without subscribing to it
// (so a value change doesn't re-trigger a full fetch cycle).
const selectionRef = useRef(selection);
selectionRef.current = selection;
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
// Seed selections: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
@@ -101,16 +111,37 @@ export function useVariableSelection(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
// Start a full fetch cycle on load / dependency-order / time change. Runs after
// the seeding effect above, so it reads the seeded selection from the store; a
// value change instead goes through `enqueueDescendants`, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
const names = variables
.map((v) => v.name)
.filter((name): name is string => !!name);
initVariableFetch(names, fetchContext);
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, orderKey, minTime, maxTime]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
[dashboardId, setVariableValue, enqueueDescendants, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
return { variables, selection, setSelection };
}

View File

@@ -1,6 +1,11 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from './selectionTypes';
import { isResolved } from './selectionUtils';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
@@ -197,3 +202,57 @@ export function computeVariableDependencies(
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}
/**
* Static context the runtime fetch engine (`variableFetchSlice`) needs to order
* fetches: the dependency graph plus the per-name type index and the QUERY /
* DYNAMIC fetch orders. Derived from the variable definitions; stable until the
* spec's variables change. Mirrors V1's `getVariableDependencyContext`.
*/
export interface VariableFetchContext {
dependencyData: VariableDependencyData;
/** variable name → its type. */
variableTypes: Record<string, VariableType>;
/** QUERY variables in topological (parent-before-child) order. */
queryVariableOrder: string[];
/** DYNAMIC variable names (they implicitly depend on all QUERY values). */
dynamicVariableOrder: string[];
}
export function deriveFetchContext(
variables: VariableFormModel[],
): VariableFetchContext {
const dependencyData = computeVariableDependencies(variables);
const variableTypes: Record<string, VariableType> = {};
variables.forEach((v) => {
if (v.name) {
variableTypes[v.name] = v.type;
}
});
const queryVariableOrder = dependencyData.order.filter(
(name) => variableTypes[name] === 'QUERY',
);
const dynamicVariableOrder = variables
.filter((v) => v.type === 'DYNAMIC' && !!v.name)
.map((v) => v.name);
return {
dependencyData,
variableTypes,
queryVariableOrder,
dynamicVariableOrder,
};
}
/**
* Whether every QUERY variable already has a usable selection — decides at load
* time whether dynamic variables may fetch immediately or must wait for the
* query variables to settle first (V1 parity).
*/
export function doAllQueryVariablesHaveValues(
variables: VariableFormModel[],
selection: VariableSelectionMap,
): boolean {
return variables
.filter((v) => v.type === 'QUERY')
.every((v) => isResolved(selection[v.name]));
}

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

@@ -0,0 +1,23 @@
import { isResolved } from '../VariablesBar/selectionUtils';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* True while a panel should stay in its loading state because a variable it
* references is still loading/waiting and has no usable value yet — i.e. the
* first load. Once the variable has a value, a later change no longer blocks the
* panel (it refetches over stale data instead). V1 parity with
* `useIsPanelWaitingOnVariable`.
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const states = useDashboardStore((s) => s.variableFetchStates);
const selection = useDashboardStore(selectVariableValues(dashboardId));
return names.some((name) => {
const state = states[name];
const inFlight =
state === 'loading' || state === 'revalidating' || state === 'waiting';
return isResolved(selection[name]) ? false : inFlight;
});
}

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

@@ -15,12 +15,14 @@ import {
} from '../queryV5/buildQueryRangeRequest';
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
import { useIsPanelWaitingOnVariable } from './useIsPanelWaitingOnVariable';
// V1 parity: PER_PAGE_OPTIONS + default page size from V1's list views.
const LIST_PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
@@ -109,9 +111,34 @@ export function usePanelQuery({
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
// Substituted into the request and keyed into the cache so a selection change refetches.
// The full set is substituted into every request, but only the values this panel
// *references* key the cache — so a variable change refetches only the panels that
// use it (V1 parity). Names come from the fetch context (all variables, even
// unresolved ones); null before the variable bar initializes it.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
const referencedVariableNames = useMemo(() => {
const allNames = fetchContext ? Object.keys(fetchContext.variableTypes) : [];
return getReferencedVariables(queries, allNames);
}, [queries, fetchContext]);
const scopedVariables = useMemo(() => {
const scoped: typeof variables = {};
referencedVariableNames.forEach((name) => {
if (variables[name] !== undefined) {
scoped[name] = variables[name];
}
});
return scoped;
}, [variables, referencedVariableNames]);
// First-load gate: hold the panel in its loading state until every referenced
// variable has resolved a value.
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
referencedVariableNames,
);
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
@@ -186,8 +213,9 @@ export function usePanelQuery({
// Each page is its own cache entry (0/default for non-paged kinds).
offset,
pageSize,
// Variable selection changes the request, so it must re-key the cache (refetch).
variables,
// Only the variables this panel references re-key the cache, so an unrelated
// variable change doesn't refetch it.
scopedVariables,
],
[
panelId,
@@ -203,14 +231,14 @@ export function usePanelQuery({
queries,
offset,
pageSize,
variables,
scopedVariables,
],
);
const response = useGetQueryRangeV5({
requestPayload,
queryKey,
enabled: enabled && runnable,
enabled: enabled && runnable && !isWaitingOnVariable,
});
const queryClient = useQueryClient();

View File

@@ -0,0 +1,75 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import type {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/getAll';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
type VariableFormModel,
type VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
const TYPE_TO_V1: Record<VariableType, TVariableQueryType> = {
QUERY: 'QUERY',
CUSTOM: 'CUSTOM',
TEXT: 'TEXTBOX',
DYNAMIC: 'DYNAMIC',
};
/** Minimal V1-shaped variable — only the fields the shared query builder reads. */
function toV1Variable(model: VariableFormModel): IDashboardVariable {
return {
id: model.name,
name: model.name,
description: model.description,
type: TYPE_TO_V1[model.type],
queryValue: model.queryValue,
customValue: model.customValue,
textboxValue: model.textValue,
sort: 'DISABLED',
multiSelect: model.multiSelect,
showALLOption: model.showAllOption,
dynamicVariablesAttribute: model.dynamicAttribute,
dynamicVariablesSource:
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
? 'all sources'
: model.dynamicSignal,
};
}
/**
* Publishes the V2 dashboard's variables into the shared `dashboardVariablesStore`
* that the query builder's autocomplete (`QuerySearch`) reads, so `$variable`
* suggestions show up in the panel editor and the dashboards-page query builder.
* Suggestion-only — the runtime engine lives in the V2 store. Clears on unmount so
* the shared store doesn't leak into other pages.
*/
export function useSyncVariablesForSuggestions(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): void {
const dashboardId = dashboard?.id ?? '';
const specVariables = dashboard?.spec?.variables;
const variables = useMemo(
() => (specVariables ?? []).map(dtoToFormModel),
[specVariables],
);
useEffect(() => {
if (!dashboardId) {
return undefined;
}
const record: Record<string, IDashboardVariable> = {};
variables.forEach((model) => {
if (model.name) {
record[model.name] = toV1Variable(model);
}
});
setDashboardVariablesStore({ dashboardId, variables: record });
return (): void =>
setDashboardVariablesStore({ dashboardId: '', variables: {} });
}, [dashboardId, variables]);
}

View File

@@ -8,6 +8,7 @@ import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
@@ -55,6 +56,10 @@ function DashboardContainer({
// the store, so each panel's query substitutes the bar's selected values.
useResolvedVariables(dashboard);
// Publish variables to the shared store so the query builder autocomplete
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;

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

@@ -0,0 +1,47 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
// Envelope spec fields that can carry a variable reference: a builder query's
// filter expression, or a PromQL/ClickHouse query string.
interface ReferenceableSpec {
query?: string;
filter?: { expression?: string };
}
/** Every text string in a panel's queries that could reference a variable. */
function extractQueryTexts(queries: DashboardtypesQueryDTO[]): string[] {
const texts: string[] = [];
toQueryEnvelopes(queries).forEach((envelope) => {
const spec = envelope.spec as ReferenceableSpec | undefined;
if (typeof spec?.query === 'string') {
texts.push(spec.query);
}
if (typeof spec?.filter?.expression === 'string') {
texts.push(spec.filter.expression);
}
});
return texts;
}
/**
* The subset of `variableNames` a panel's queries reference (`$name`, `{{.name}}`,
* `[[name]]`), so a variable change only refetches the panels that actually use it.
* Reuses the shared text-based reference detector over the panel's filter/query text.
*/
export function getReferencedVariables(
queries: DashboardtypesQueryDTO[],
variableNames: string[],
): string[] {
if (queries.length === 0 || variableNames.length === 0) {
return [];
}
const texts = extractQueryTexts(queries);
if (texts.length === 0) {
return [];
}
return variableNames.filter((name) =>
texts.some((text) => textContainsVariableReference(text, name)),
);
}

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

View File

@@ -0,0 +1,82 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../../DashboardSettings/Variables/variableFormModel';
import { deriveFetchContext } from '../../../VariablesBar/variableDependencies';
import { useDashboardStore } from '../../useDashboardStore';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// q1 (root query) → q2 (query referencing $q1) ; d1 (dynamic).
const q1 = model({ name: 'q1', type: 'QUERY', queryValue: 'SELECT 1' });
const q2 = model({ name: 'q2', type: 'QUERY', queryValue: 'SELECT $q1' });
const d1 = model({ name: 'd1', type: 'DYNAMIC', dynamicAttribute: 'pod' });
const context = deriveFetchContext([q1, q2, d1]);
function store(): ReturnType<typeof useDashboardStore.getState> {
return useDashboardStore.getState();
}
function states(): Record<string, string> {
return store().variableFetchStates;
}
beforeEach(() => {
useDashboardStore.setState({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableFetchContext: null,
});
store().initVariableFetch(['q1', 'q2', 'd1'], context);
});
describe('variableFetchSlice', () => {
it('initializes every variable to idle', () => {
expect(states()).toStrictEqual({ q1: 'idle', q2: 'idle', d1: 'idle' });
});
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
store().enqueueFetchAll(false);
expect(states()).toStrictEqual({
q1: 'loading',
q2: 'waiting',
d1: 'waiting',
});
expect(store().variableCycleIds).toStrictEqual({ q1: 1, q2: 1, d1: 1 });
});
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
store().enqueueFetchAll(true);
expect(states().d1).toBe('loading');
});
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
store().onVariableFetchComplete('q2');
expect(states()).toMatchObject({ q1: 'idle', q2: 'idle', d1: 'loading' });
});
it('enqueueDescendants revalidates only descendants + dynamics', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
store().onVariableFetchComplete('q2');
store().onVariableFetchComplete('d1');
store().enqueueDescendants('q1');
// q2 depends on q1 (settled) → revalidates; d1 waits (q2 no longer settled).
expect(states().q2).toBe('revalidating');
expect(states().d1).toBe('waiting');
});
it('a failed parent idles its query descendants', () => {
store().enqueueFetchAll(false);
store().onVariableFetchFailure('q1');
expect(states().q1).toBe('error');
expect(states().q2).toBe('idle');
});
});

View File

@@ -0,0 +1,244 @@
import type { StateCreator } from 'zustand';
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
import type { DashboardStore } from '../useDashboardStore';
import {
areAllQueryVariablesSettled,
type FetchMaps,
isSettled,
resolveFetchState,
unlockWaitingDynamicVariables,
type VariableFetchState,
} from './variableFetchSlice.utils';
export type { VariableFetchState } from './variableFetchSlice.utils';
/**
* Runtime fetch orchestration for dashboard variables — native port of V1's
* `variableFetchStore`. Decides WHEN each variable's options fetch: query
* variables in dependency order, dynamics together once query values exist,
* text/custom never. `cycleIds` is a per-variable request nonce keyed into each
* selector's react-query key (bump = fresh fetch, auto-cancel stale). Transient.
* `enqueueFetchAll` = load/time change; `enqueueDescendants` = one value changed.
*/
export interface VariableFetchSlice {
variableFetchStates: Record<string, VariableFetchState>;
variableLastUpdated: Record<string, number>;
variableCycleIds: Record<string, number>;
/** Static dependency context, set by `initVariableFetch` (null before init). */
variableFetchContext: VariableFetchContext | null;
/** Seed state entries for the current variable set and store the context. */
initVariableFetch: (names: string[], context: VariableFetchContext) => void;
/** Start a full fetch cycle for every fetchable variable (load / time change). */
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => void;
/** Mark a variable's fetch as done; unblock its waiting children / dynamics. */
onVariableFetchComplete: (name: string) => void;
/** Mark a variable's fetch as failed; idle its query descendants. */
onVariableFetchFailure: (name: string) => void;
/** Cascade a value change to a variable's query descendants + the dynamics. */
enqueueDescendants: (name: string) => void;
}
/** Snapshot the three fetch maps into mutable clones for a single action. */
function cloneMaps(state: DashboardStore): FetchMaps {
return {
states: { ...state.variableFetchStates },
lastUpdated: { ...state.variableLastUpdated },
cycleIds: { ...state.variableCycleIds },
};
}
export const createVariableFetchSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
VariableFetchSlice
> = (set, get) => ({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableFetchContext: null,
initVariableFetch: (names, context): void => {
const maps = cloneMaps(get());
// Initialize new variables to idle, preserving existing states.
names.forEach((name) => {
if (!maps.states[name]) {
maps.states[name] = 'idle';
}
});
// Drop entries for variables that no longer exist.
const nameSet = new Set(names);
Object.keys(maps.states).forEach((name) => {
if (!nameSet.has(name)) {
delete maps.states[name];
delete maps.lastUpdated[name];
delete maps.cycleIds[name];
}
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableFetchContext: context,
});
},
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
const { variableFetchContext } = get();
if (!variableFetchContext) {
return;
}
const {
dependencyData,
variableTypes,
queryVariableOrder,
dynamicVariableOrder,
} = variableFetchContext;
const maps = cloneMaps(get());
// Query variables: roots start immediately, dependents wait for parents.
queryVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
const parents = dependencyData.parentGraph[name] || [];
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
maps.states[name] = hasQueryParents
? 'waiting'
: resolveFetchState(maps, name);
});
// Dynamic variables: start now if query variables already have values,
// otherwise wait until the query variables settle.
dynamicVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = doAllQueryVariablesHaveValuesSelected
? resolveFetchState(maps, name)
: 'waiting';
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
onVariableFetchComplete: (name): void => {
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = 'idle';
maps.lastUpdated[name] = Date.now();
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Unblock waiting query-type children.
(dependencyData.graph[name] || []).forEach((child) => {
if (variableTypes[child] === 'QUERY' && maps.states[child] === 'waiting') {
maps.states[child] = resolveFetchState(maps, child);
}
});
// Once all query variables settle, unlock any waiting dynamics.
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
onVariableFetchFailure: (name): void => {
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = 'error';
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Query descendants can't proceed without this parent — idle them.
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
if (variableTypes[desc] === 'QUERY') {
maps.states[desc] = 'idle';
}
});
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
enqueueDescendants: (name): void => {
const { variableFetchContext } = get();
if (!variableFetchContext) {
return;
}
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
const maps = cloneMaps(get());
// Query descendants: refetch when all their parents are settled, else wait.
(dependencyData.transitiveDescendants[name] || [])
.filter((desc) => variableTypes[desc] === 'QUERY')
.forEach((desc) => {
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
const parents = dependencyData.parentGraph[desc] || [];
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
maps.states[desc] = allParentsSettled
? resolveFetchState(maps, desc)
: 'waiting';
});
// Dynamics implicitly depend on all query values: refetch now if the query
// variables are settled, otherwise wait for them.
dynamicVariableOrder.forEach((dynName) => {
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = areAllQueryVariablesSettled(
maps.states,
variableTypes,
)
? resolveFetchState(maps, dynName)
: 'waiting';
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
});
/** Selector: the fetch state for a single variable (defaults to idle). */
export const selectVariableFetchState =
(name: string) =>
(state: DashboardStore): VariableFetchState =>
state.variableFetchStates[name] ?? 'idle';
/** Selector: the current fetch cycle id for a single variable (defaults to 0). */
export const selectVariableCycleId =
(name: string) =>
(state: DashboardStore): number =>
state.variableCycleIds[name] ?? 0;
/** Selector: whether a variable has completed at least one fetch. */
export const selectVariableFetchedOnce =
(name: string) =>
(state: DashboardStore): boolean =>
(state.variableLastUpdated[name] ?? 0) > 0;

View File

@@ -0,0 +1,51 @@
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
export type VariableFetchState =
| 'idle'
| 'loading'
| 'revalidating'
| 'waiting'
| 'error';
/** Mutable clones a fetch action works over before committing back in one `set`. */
export interface FetchMaps {
states: Record<string, VariableFetchState>;
lastUpdated: Record<string, number>;
cycleIds: Record<string, number>;
}
/** Settled = can make no further progress (idle or error). */
export function isSettled(state: VariableFetchState | undefined): boolean {
return state === 'idle' || state === 'error';
}
/** Fetch-start state: `revalidating` if fetched before, else `loading`. */
export function resolveFetchState(
maps: FetchMaps,
name: string,
): VariableFetchState {
return (maps.lastUpdated[name] || 0) > 0 ? 'revalidating' : 'loading';
}
/** True once every QUERY variable is settled. */
export function areAllQueryVariablesSettled(
states: Record<string, VariableFetchState>,
variableTypes: Record<string, VariableType>,
): boolean {
return Object.entries(variableTypes)
.filter(([, type]) => type === 'QUERY')
.every(([name]) => isSettled(states[name]));
}
/** Move any `waiting` dynamic variables into loading/revalidating. */
export function unlockWaitingDynamicVariables(
maps: FetchMaps,
dynamicVariableOrder: string[],
): void {
dynamicVariableOrder.forEach((dynName) => {
if (maps.states[dynName] === 'waiting') {
maps.states[dynName] = resolveFetchState(maps, dynName);
}
});
}

View File

@@ -13,10 +13,15 @@ import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
import {
createVariableFetchSlice,
type VariableFetchSlice,
} from './slices/variableFetchSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
VariableSelectionSlice &
VariableFetchSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
@@ -31,6 +36,7 @@ export const useDashboardStore = create<DashboardStore>()(
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
...createVariableFetchSlice(...a),
}),
{
name: '@signoz/dashboard-v2',

View File

@@ -20,6 +20,7 @@ import {
parseNewPanelKind,
parseNewPanelLayoutIndex,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import styles from './PanelEditorPage.module.scss';
@@ -40,6 +41,9 @@ function PanelEditorPage(): JSX.Element {
});
const dashboard = data?.data;
// Feed variables to the query builder autocomplete inside the editor.
useSyncVariablesForSuggestions(dashboard);
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
// kind rather than looking one up. Persisted (with a real id) only on save.
const newKind = parseNewPanelKind(panelId, search);

View File

@@ -138,33 +138,14 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
return nil
}
const maxLayoutsPerDashboard = 500
// validateLayouts validates the dashboard's layouts: bounded section count,
// per-item geometry, resolvable panel references, and no panel placed twice.
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
// runs here so its errors can name the layout by index.
// validateLayouts rejects grid items referencing a panel that doesn't exist.
func (d *DashboardSpec) validateLayouts() error {
if len(d.Layouts) > maxLayoutsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
}
// Could enforce this but skipping for now: panels in no grid item (orphans)
// are allowed.
// The frontend keys each grid item by its panel id, so placing one panel in
// two grid items collides; reject duplicate references dashboard-wide. Maps
// each referenced panel key to the path of the item that first placed it.
referencedPanels := make(map[string]string, len(d.Panels))
for li, layout := range d.Layouts {
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
if !ok {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if err := validateGridLayoutGeometry(grid, li); err != nil {
return err
}
for ii, item := range grid.Items {
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
if item.Content == nil {
@@ -177,10 +158,6 @@ func (d *DashboardSpec) validateLayouts() error {
if _, ok := d.Panels[key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
}
if firstPath, dup := referencedPanels[key]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: panel %q is already placed by %s", path, key, firstPath)
}
referencedPanels[key] = path
}
}
return nil

View File

@@ -299,22 +299,19 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout y coordinate", func(t *testing.T) {
// p2 fills the right half of row 0, so p1 can only move to a fresh row
// without tripping overlap validation.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/y", "value": 6}]`).Apply(base)
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at y=0, now lives at y=6.
assert.Contains(t, raw, `"x":0,"y":6,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
// p2 sits at x=6, so p1 (at x=0) can only shrink; widening it would overlap.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 3}]`).Apply(base)
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":3`)
assert.Contains(t, raw, `"width":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
@@ -324,12 +321,11 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
})
t.Run("append layout item", func(t *testing.T) {
// Appending needs a not-yet-placed panel, so add one in the same patch;
// re-placing p1 or p2 would be a duplicate reference.
out, err := decode(t, `[
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
out, err := decode(t, `[{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)

View File

@@ -8,7 +8,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
@@ -26,19 +25,19 @@ func TestValidateBigExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
assert.NoError(t, err, "expected valid dashboard")
require.NoError(t, err, "expected valid dashboard")
}
func TestValidateDashboardWithSections(t *testing.T) {
data, err := os.ReadFile("testdata/perses_with_sections.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
assert.NoError(t, err, "expected valid dashboard")
require.NoError(t, err, "expected valid dashboard")
}
func TestInvalidateNotAJSON(t *testing.T) {
_, err := unmarshalDashboard([]byte("not json"))
assert.Error(t, err, "expected error for invalid JSON")
require.Error(t, err, "expected error for invalid JSON")
}
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
@@ -61,11 +60,11 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown panel plugin kind",
require.Contains(t, err.Error(), "unknown panel plugin kind",
"outer wrap should not smother the inner UnmarshalJSON message")
assert.Contains(t, err.Error(), `"NonExistentPanel"`,
require.Contains(t, err.Error(), `"NonExistentPanel"`,
"the offending value should still appear in the error")
assert.Contains(t, err.Error(), "allowed values:",
require.Contains(t, err.Error(), "allowed values:",
"the allowed-values hint should still appear in the error")
assert.True(t, errors.Ast(err, errors.TypeInvalidInput),
@@ -78,7 +77,7 @@ func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
require.NoError(t, err, "expected valid")
}
func TestValidateOnlyVariables(t *testing.T) {
@@ -110,7 +109,7 @@ func TestValidateOnlyVariables(t *testing.T) {
"layouts": []
}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
require.NoError(t, err, "expected valid")
}
func TestInvalidateDuplicateVariableNames(t *testing.T) {
@@ -137,7 +136,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for duplicate variable name")
assert.Contains(t, err.Error(), `duplicate variable name "env"`)
require.Contains(t, err.Error(), `duplicate variable name "env"`)
}
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
@@ -164,19 +163,19 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.Error(t, err, "expected error for invalid variable name %q", name)
assert.Contains(t, err.Error(), "is not a correct name")
require.Contains(t, err.Error(), "is not a correct name")
})
}
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
assert.NoError(t, err, "expected valid variable name %q", name)
require.NoError(t, err, "expected valid variable name %q", name)
})
}
t.Run("digits only", func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName("123"))
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot contain only digits")
require.Contains(t, err.Error(), "cannot contain only digits")
})
}
@@ -200,7 +199,7 @@ func TestInvalidatePanelKey(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel key")
assert.Contains(t, err.Error(), "is not a correct name")
require.Contains(t, err.Error(), "is not a correct name")
}
func TestInvalidateListVariableCrossFields(t *testing.T) {
@@ -226,30 +225,30 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
require.Error(t, err)
assert.Contains(t, err.Error(), "customAllValue cannot be set")
require.Contains(t, err.Error(), "customAllValue cannot be set")
})
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowMultiple")
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowMultiple")
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("valid sort is accepted", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
assert.NoError(t, err)
require.NoError(t, err)
})
t.Run("unknown sort is rejected", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown sort")
require.Contains(t, err.Error(), "unknown sort")
})
}
@@ -276,7 +275,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for empty variable name")
assert.Contains(t, err.Error(), "name cannot be empty")
require.Contains(t, err.Error(), "name cannot be empty")
})
}
}
@@ -415,7 +414,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -436,7 +435,7 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel plugin kind")
assert.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
func TestInvalidateLayoutPanelReferences(t *testing.T) {
@@ -489,11 +488,11 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard(tt.data)
if tt.wantContain == "" {
assert.NoError(t, err)
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain)
})
}
}
@@ -571,7 +570,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error for unknown field")
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -650,7 +649,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected validation error")
if tt.wantContain != "" {
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
}
})
}
@@ -875,7 +874,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -908,7 +907,7 @@ func TestThresholdLabelOptional(t *testing.T) {
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Len(t, spec.Thresholds, 1)
assert.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
})
}
}
@@ -925,7 +924,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel-without-queries to be rejected")
assert.Contains(t, err.Error(), "panel must have one query")
require.Contains(t, err.Error(), "panel must have one query")
}
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
@@ -943,7 +942,7 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel with explicit empty queries array to be rejected")
assert.Contains(t, err.Error(), "panel must have one query")
require.Contains(t, err.Error(), "panel must have one query")
}
// Rendering multiple data sources in a single panel is supported via
@@ -966,7 +965,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel with two top-level queries to be rejected")
assert.Contains(t, err.Error(), "panel must have one query")
require.Contains(t, err.Error(), "panel must have one query")
}
func TestValidateRequiredFields(t *testing.T) {
@@ -1054,7 +1053,7 @@ func TestValidateRequiredFields(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -1082,14 +1081,14 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
require.IsType(t, &TimeSeriesPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
assert.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
assert.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
assert.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
assert.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
// and verify the output contains the default values.
@@ -1132,8 +1131,8 @@ func TestNumberPanelDefaults(t *testing.T) {
spec := d.Panels["p1"].Spec.Plugin.Spec.(*NumberPanelSpec)
require.Len(t, spec.Thresholds, 1, "expected 1 threshold")
assert.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
assert.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
require.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
require.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
// Marshal back and verify defaults in JSON output.
output, err := json.Marshal(d)
@@ -1164,7 +1163,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
require.NoError(t, err, "map → JSON (read-back shape)")
var roundtripped DashboardSpec
assert.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
}
// TestStorageRoundTrip simulates the future DB store/load cycle:
@@ -1330,9 +1329,9 @@ func TestGenerateDashboardName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.scenario, func(t *testing.T) {
got := generateDashboardName(tt.input)
assert.NotEmpty(t, got)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
require.NotEmpty(t, got)
require.LessOrEqual(t, len(got), 63)
require.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
if tt.wantPrefix == "" {
assert.Len(t, got, dashboardNameSuffixLen, "expected the bare random suffix")
@@ -1347,8 +1346,8 @@ func TestGenerateDashboardName(t *testing.T) {
t.Run("prefix is truncated to leave room for the suffix", func(t *testing.T) {
input := strings.Repeat("a", 100)
got := generateDashboardName(input)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got))
require.LessOrEqual(t, len(got), 63)
require.Empty(t, validation.IsDNS1123Label(got))
assert.Equal(t, len(got), 63, "expected the result to be padded to the max DNS-1123 length")
})
@@ -1436,130 +1435,10 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
_, err := unmarshalDashboard(tc.data)
if tc.wantErr {
assert.Error(t, err)
require.Error(t, err)
} else {
assert.NoError(t, err)
require.NoError(t, err)
}
})
}
}
func TestValidateGridGeometry(t *testing.T) {
tests := []struct {
scenario string
items []dashboard.GridItem
expectErrContain string
}{
{
scenario: "valid side-by-side items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 6, Y: 0, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "valid full-width item",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 12, Height: 6}},
expectErrContain: "",
},
{
scenario: "stacked items do not overlap",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 0, Y: 6, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "zero width",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 0, Height: 6}},
expectErrContain: "width must be at least 1",
},
{
scenario: "zero height",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 0}},
expectErrContain: "height must be at least 1",
},
{
scenario: "negative x",
items: []dashboard.GridItem{{X: -1, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x must not be negative",
},
{
scenario: "negative y",
items: []dashboard.GridItem{{X: 0, Y: -1, Width: 6, Height: 6}},
expectErrContain: "y must not be negative",
},
{
scenario: "width wider than grid",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 13, Height: 6}},
expectErrContain: "width (13) exceeds grid width 12",
},
{
scenario: "x at grid width",
items: []dashboard.GridItem{{X: 12, Y: 0, Width: 1, Height: 6}},
expectErrContain: "x (12) must be less than grid width 12",
},
{
scenario: "x plus width overflows grid",
items: []dashboard.GridItem{{X: 8, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x (8) + width (6) exceeds grid width 12",
},
{
scenario: "overlapping items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 3, Y: 3, Width: 6, Height: 6}},
expectErrContain: "items[0] and items[1] overlap",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: test.items}, 0)
if test.expectErrContain == "" {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectErrContain)
})
}
}
func TestValidateGridItemLimit(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, maxItemsPerGridLayout+1)}, 0)
require.Error(t, err)
assert.Contains(t, err.Error(), "maximum is")
}
// Both panel refs are valid, so this errors only if geometry validation runs on
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "overlap")
}
// The frontend keys each grid item by its panel id, so the same panel placed by
// two grid items crashes the section; the backend rejects it dashboard-wide. The
// two items are side by side so they clear the overlap check first.
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "already placed")
// Both offending grid items are named.
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}

View File

@@ -322,55 +322,6 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
return nil
}
const (
gridColumnCount = 12
maxItemsPerGridLayout = 100
)
// validateGridLayoutGeometry checks a single grid layout's item geometry (size,
// position, and intra-section overlap), which Perses does not. It reads only the
// layout's own items; layoutIndex is supplied by the caller (validateLayouts)
// solely to name the layout in error paths.
func validateGridLayoutGeometry(spec *dashboard.GridLayoutSpec, layoutIndex int) error {
if len(spec.Items) > maxItemsPerGridLayout {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items: has %d items; maximum is %d", layoutIndex, len(spec.Items), maxItemsPerGridLayout)
}
for i, item := range spec.Items {
// The width/x bounds keep x+width small enough not to overflow.
switch {
case item.Width < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width must be at least 1, got %d", layoutIndex, i, item.Width)
case item.Height < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: height must be at least 1, got %d", layoutIndex, i, item.Height)
case item.X < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x must not be negative, got %d", layoutIndex, i, item.X)
case item.Y < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: y must not be negative, got %d", layoutIndex, i, item.Y)
case item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width (%d) exceeds grid width %d", layoutIndex, i, item.Width, gridColumnCount)
case item.X >= gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) must be less than grid width %d", layoutIndex, i, item.X, gridColumnCount)
case item.X+item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) + width (%d) exceeds grid width %d", layoutIndex, i, item.X, item.Width, gridColumnCount)
}
// Could cap y/height but skipping for now: the grid grows vertically
// without limit (frontend autoSize), so "too big" has no natural bound.
}
// Two items overlap iff their rectangles intersect on both axes.
overlap := func(a, b dashboard.GridItem) bool {
return a.X < b.X+b.Width && b.X < a.X+a.Width &&
a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
}
for i := 0; i < len(spec.Items); i++ {
for j := i + 1; j < len(spec.Items); j++ {
if overlap(spec.Items[i], spec.Items[j]) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d] and items[%d] overlap", layoutIndex, i, j)
}
}
}
return nil
}
func (Layout) JSONSchemaOneOf() []any {
return []any{
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},

View File

@@ -173,125 +173,6 @@ def test_create_rejects_too_many_tags(
assert response.json()["error"]["code"] == "dashboard_invalid_input"
def test_create_rejects_invalid_grid_layout(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def panel(name: str) -> dict:
return {
"kind": "Panel",
"spec": {
"display": {"name": name},
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
},
}
},
}
],
},
}
# Two grid items reference valid, distinct panels but share cells, so the
# overlap is the only violation.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-overlap",
"spec": {
"display": {"name": "Rejects Overlap"},
"panels": {"p1": panel("P1"), "p2": panel("P2")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "overlap" in response.json()["error"]["message"]
# One panel placed by two grid items (side by side, so they clear the overlap
# check first). The frontend keys grid items by panel id, so this is rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-multiref",
"spec": {
"display": {"name": "Rejects Multiref"},
"panels": {"p1": panel("P1")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "already placed" in response.json()["error"]["message"]
# More grid items than allowed. The item-count check runs before the
# panel-ref check, so content-less items suffice here.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-too-many-items",
"spec": {
"display": {"name": "Rejects Too Many"},
"layouts": [
{
"kind": "Grid",
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "maximum" in response.json()["error"]["message"]
@pytest.mark.parametrize(
"params",
[