mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 12:50:37 +01:00
Compare commits
4 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2f8bf35e | ||
|
|
21cce41efa | ||
|
|
114170b9cd | ||
|
|
b2eef28549 |
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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] ?? {
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user