Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
368c6cd724 refactor: rename selectedDashboard to dashboardData
Renames the `selectedDashboard` store field (and related setters/getters
`setSelectedDashboard`, `getSelectedDashboard`) to `dashboardData` across
the frontend. Also renames incidental locals (`updatedSelectedDashboard`,
`mockSelectedDashboard`, and the ExportPanel's local `selectedDashboardId`).
2026-04-21 19:50:08 +05:30
Ashwin Bhatkal
7917540662 fix: alerts creation for query types other than builder (#11030)
* fix: alerts creation for query types other than builder

* chore: add tests
2026-04-21 11:33:32 +00:00
Vinicius Lourenço
addb234c8c test(infra-monitoring): fix flaky test (#11021) 2026-04-21 11:06:49 +00:00
Prakhar Dewan
be6a663e4b refactor: migrate CreateFunnel input to @signozhq/ui (#10996)
Refs SigNoz#10615
2026-04-21 11:04:32 +00:00
57 changed files with 529 additions and 4254 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4774,7 +4774,7 @@ export interface RuletypesPostableRuleDTO {
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
@@ -4899,7 +4899,7 @@ export interface RuletypesRuleDTO {
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
@@ -4984,8 +4984,8 @@ export interface RuletypesRuleConditionDTO {
*/
algorithm?: string;
compositeQuery: RuletypesAlertCompositeQueryDTO;
matchType?: RuletypesMatchTypeDTO;
op?: RuletypesCompareOperatorDTO;
matchType: RuletypesMatchTypeDTO;
op: RuletypesCompareOperatorDTO;
/**
* @type boolean
*/

View File

@@ -79,7 +79,7 @@ export function useNavigateToExplorer(): (
);
const { getUpdatedQuery } = useUpdatedQuery();
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(
@@ -111,7 +111,7 @@ export function useNavigateToExplorer(): (
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
selectedDashboard,
dashboardData,
})
.then((query) => {
preparedQuery = query;
@@ -140,7 +140,7 @@ export function useNavigateToExplorer(): (
minTime,
maxTime,
getUpdatedQuery,
selectedDashboard,
dashboardData,
notifications,
],
);

View File

@@ -87,8 +87,8 @@ jest.mock('hooks/useDarkMode', () => ({
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));

View File

@@ -196,12 +196,12 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation);
useDashboardStore.setState({
selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
dashboardData: (getDashboardById.data as unknown) as Dashboard,
layouts: [],
panelMap: {},
setPanelMap: jest.fn(),
setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(),
setDashboardData: jest.fn(),
columnWidths: {},
});

View File

@@ -78,12 +78,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const {
selectedDashboard,
dashboardData,
panelMap,
setPanelMap,
layouts,
setLayouts,
setSelectedDashboard,
setDashboardData,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
@@ -98,10 +98,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const selectedData = selectedDashboard
const selectedData = dashboardData
? {
...selectedDashboard.data,
uuid: selectedDashboard.id,
...dashboardData.data,
uuid: dashboardData.id,
}
: ({} as DashboardData);
const { dashboardVariables } = useDashboardVariables();
@@ -133,8 +133,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
let isAuthor = false;
if (selectedDashboard && user && user.email) {
isAuthor = selectedDashboard?.createdBy === user?.email;
if (dashboardData && user && user.email) {
isAuthor = dashboardData?.createdBy === user?.email;
}
let permissions: ComponentTypes[] = ['add_panel'];
@@ -146,7 +146,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { notifications } = useNotifications();
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -155,9 +155,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length,
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);
@@ -168,14 +168,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
};
const onNameChangeHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const updatedDashboard: Props = {
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
title: updatedTitle,
},
};
@@ -186,7 +186,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
});
setIsRenameDashboardOpen(false);
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
}
},
onError: () => {
@@ -203,10 +203,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
// the context value is sometimes not available during the initial render
// due to which the updatedTitle is set to some previous value
useEffect(() => {
if (selectedDashboard) {
setUpdatedTitle(selectedDashboard.data.title);
if (dashboardData) {
setUpdatedTitle(dashboardData.data.title);
}
}, [selectedDashboard]);
}, [dashboardData]);
useEffect(() => {
if (state.error) {
@@ -227,7 +227,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}, [state.error, state.value, t, notifications]);
function handleAddRow(): void {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const id = uuid();
@@ -246,10 +246,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}
const updatedDashboard: Props = {
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
layout: [
{
i: id,
@@ -265,7 +265,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
],
panelMap: { ...panelMap, [id]: newRowWidgetMap },
widgets: [
...(selectedDashboard.data.widgets || []),
...(dashboardData.data.widgets || []),
{
id,
title: sectionName,
@@ -282,7 +282,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
@@ -299,8 +299,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
error: errorPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardMeta(
selectedDashboard?.id || '',
!!selectedDashboard?.id && isPublicDashboardEnabled,
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
useEffect(() => {
@@ -378,14 +378,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
selectedDashboard?.createdBy === 'integration' &&
dashboardData?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={selectedDashboard?.createdBy === 'integration'}
disabled={dashboardData?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
@@ -457,9 +457,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={selectedDashboard?.createdBy || ''}
name={selectedDashboard?.data.title || ''}
id={String(selectedDashboard?.id) || ''}
createdBy={dashboardData?.createdBy || ''}
name={dashboardData?.data.title || ''}
id={String(dashboardData?.id) || ''}
isLocked={isDashboardLocked}
routeToListPage
/>

View File

@@ -239,7 +239,7 @@ function VariableItem({
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {
@@ -248,7 +248,7 @@ function VariableItem({
} else if (dynamicVariablesSelectedValue?.name) {
const widgets = getWidgetsHavingDynamicVariableAttribute(
dynamicVariablesSelectedValue?.name,
(selectedDashboard?.data?.widgets?.filter(
(dashboardData?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || []) as Widgets[],
variableData.name,
@@ -257,7 +257,7 @@ function VariableItem({
}
}, [
dynamicVariablesSelectedValue?.name,
selectedDashboard,
dashboardData,
variableData.id,
variableData.name,
widgetsByDynamicVariableId,

View File

@@ -12,17 +12,17 @@ export function WidgetSelector({
selectedWidgets: string[];
setSelectedWidgets: (widgets: string[]) => void;
}): JSX.Element {
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
// Get layout IDs for cross-referencing
const layoutIds = new Set(
(selectedDashboard?.data?.layout || []).map((item) => item.i),
(dashboardData?.data?.layout || []).map((item) => item.i),
);
// Filter and deduplicate widgets by ID, keeping only those with layout entries
// and excluding row widgets since they are not panels that can have variables
const widgets = Object.values(
(selectedDashboard?.data?.widgets || []).reduce(
(dashboardData?.data?.widgets || []).reduce(
(acc: Record<string, WidgetRow | Widgets>, widget: WidgetRow | Widgets) => {
if (
widget.id &&

View File

@@ -87,7 +87,7 @@ function VariablesSettings({
const { t } = useTranslation(['dashboard']);
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const { dashboardData, setDashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const { notifications } = useNotifications();
@@ -173,7 +173,7 @@ function VariablesSettings({
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
@@ -181,16 +181,16 @@ function VariablesSettings({
(currentRequestedId &&
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
addDynamicVariableToPanels(
selectedDashboard,
dashboardData,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
selectedDashboard;
dashboardData;
updateMutation.mutateAsync(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...newDashboard.data,
@@ -200,7 +200,7 @@ function VariablesSettings({
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
notifications.success({
message: t('variable_updated_successfully'),
});

View File

@@ -15,11 +15,11 @@ import './GeneralSettings.styles.scss';
const { Option } = Select;
function GeneralDashboardSettings(): JSX.Element {
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const { dashboardData, setDashboardData } = useDashboardStore();
const updateDashboardMutation = useUpdateDashboard();
const selectedData = selectedDashboard?.data;
const selectedData = dashboardData?.data;
const { title = '', tags = [], description = '', image = Base64Icons[0] } =
selectedData || {};
@@ -37,15 +37,15 @@ function GeneralDashboardSettings(): JSX.Element {
const { t } = useTranslation('common');
const onSaveHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
updateDashboardMutation.mutate(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
description: updatedDescription,
tags: updatedTags,
title: updatedTitle,
@@ -55,7 +55,7 @@ function GeneralDashboardSettings(): JSX.Element {
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
}
},
onError: () => {},

View File

@@ -41,7 +41,7 @@ const DASHBOARD_VARIABLES_WARNING =
// Use wildcard pattern to match both relative and absolute URLs in MSW
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
const mockSelectedDashboard = {
const mockDashboardData = {
id: MOCK_DASHBOARD_ID,
data: {
title: 'Test Dashboard',
@@ -70,7 +70,7 @@ beforeEach(() => {
// Mock useDashboardStore
mockUseDashboard.mockReturnValue(({
selectedDashboard: mockSelectedDashboard,
dashboardData: mockDashboardData,
} as unknown) as ReturnType<typeof useDashboardStore>);
// Mock useCopyToClipboard

View File

@@ -60,7 +60,7 @@ function PublicDashboardSetting(): JSX.Element {
const [defaultTimeRange, setDefaultTimeRange] = useState(DEFAULT_TIME_RANGE);
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
@@ -85,8 +85,8 @@ function PublicDashboardSetting(): JSX.Element {
refetch: refetchPublicDashboard,
error: errorPublicDashboard,
} = useGetPublicDashboardMeta(
selectedDashboard?.id || '',
!!selectedDashboard?.id && isPublicDashboardEnabled,
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
const isPublicDashboard = !!publicDashboardData?.publicPath;
@@ -155,36 +155,36 @@ function PublicDashboardSetting(): JSX.Element {
});
const handleCreatePublicDashboard = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
createPublicDashboard({
dashboardId: selectedDashboard.id,
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleUpdatePublicDashboard = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
updatePublicDashboard({
dashboardId: selectedDashboard.id,
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleRevokePublicDashboardAccess = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
revokePublicDashboardAccess({
id: selectedDashboard.id,
id: dashboardData.id,
});
};

View File

@@ -26,10 +26,10 @@ import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const { dashboardId, setSelectedDashboard } = useDashboardStore(
const { dashboardId, setDashboardData } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.selectedDashboard?.id ?? '',
setSelectedDashboard: s.setSelectedDashboard,
dashboardId: s.dashboardData?.id ?? '',
setDashboardData: s.setDashboardData,
})),
);
@@ -99,7 +99,7 @@ function DashboardVariableSelection(): JSX.Element | null {
// Synchronously update the external store with the new variable value so that
// child variables see the updated parent value when they refetch, rather than
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
// waiting for setDashboardData → useEffect → updateDashboardVariablesStore.
const updatedVariables = { ...dashboardVariables };
if (updatedVariables[id]) {
updatedVariables[id] = {
@@ -119,7 +119,7 @@ function DashboardVariableSelection(): JSX.Element | null {
}
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
setSelectedDashboard((prev) => {
setDashboardData((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
// this is added to handle case where we have two different
@@ -157,7 +157,7 @@ function DashboardVariableSelection(): JSX.Element | null {
// Safe to call synchronously now that the store already has the updated value.
enqueueDescendantsOfVariable(name);
},
[dashboardId, dashboardVariables, updateUrlVariable, setSelectedDashboard],
[dashboardId, dashboardVariables, updateUrlVariable, setDashboardData],
);
return (

View File

@@ -30,11 +30,11 @@ const mockVariableItemCallbacks: {
} = {};
// Mock providers/Dashboard/Dashboard
const mockSetSelectedDashboard = jest.fn();
const mockSetDashboardData = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
interface MockDashboardStoreState {
selectedDashboard?: { id: string };
setSelectedDashboard: typeof mockSetSelectedDashboard;
dashboardData?: { id: string };
setDashboardData: typeof mockSetDashboardData;
updateLocalStorageDashboardVariables: typeof mockUpdateLocalStorageDashboardVariables;
}
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
@@ -42,8 +42,8 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
selector?: (s: Record<string, unknown>) => MockDashboardStoreState,
): MockDashboardStoreState => {
const state = {
selectedDashboard: { id: 'dash-1' },
setSelectedDashboard: mockSetSelectedDashboard,
dashboardData: { id: 'dash-1' },
setDashboardData: mockSetDashboardData,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
};
return selector ? selector(state) : state;

View File

@@ -38,15 +38,11 @@ interface UseDashboardVariableUpdateReturn {
}
export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn => {
const {
dashboardId,
selectedDashboard,
setSelectedDashboard,
} = useDashboardStore(
const { dashboardId, dashboardData, setDashboardData } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.selectedDashboard?.id ?? '',
selectedDashboard: s.selectedDashboard,
setSelectedDashboard: s.setSelectedDashboard,
dashboardId: s.dashboardData?.id ?? '',
dashboardData: s.dashboardData,
setDashboardData: s.setDashboardData,
})),
);
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
@@ -74,8 +70,8 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
isDynamic,
);
if (selectedDashboard) {
setSelectedDashboard((prev) => {
if (dashboardData) {
setDashboardData((prev) => {
if (prev) {
const oldVariables = prev?.data.variables;
// this is added to handle case where we have two different
@@ -110,7 +106,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
}
}
},
[dashboardId, selectedDashboard, setSelectedDashboard],
[dashboardId, dashboardData, setDashboardData],
);
const updateVariables = useCallback(
@@ -120,23 +116,23 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const newDashboard =
(currentRequestedId &&
addDynamicVariableToPanels(
selectedDashboard,
dashboardData,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
selectedDashboard;
dashboardData;
updateMutation.mutateAsync(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...newDashboard.data,
@@ -146,7 +142,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
// notifications.success({
// message: t('variable_updated_successfully'),
// });
@@ -155,12 +151,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
},
);
},
[
selectedDashboard,
addDynamicVariableToPanels,
updateMutation,
setSelectedDashboard,
],
[dashboardData, addDynamicVariableToPanels, updateMutation, setDashboardData],
);
const createVariable = useCallback(
@@ -172,13 +163,13 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
source: 'logs' | 'traces' | 'metrics' | 'all sources' = 'all sources',
// widgetId?: string,
): void => {
if (!selectedDashboard) {
if (!dashboardData) {
console.warn('No dashboard selected for variable creation');
return;
}
// Get current dashboard variables
const currentVariables = selectedDashboard.data.variables || {};
const currentVariables = dashboardData.data.variables || {};
// Create tableRowData like Dashboard Settings does
const tableRowData = [];
@@ -234,7 +225,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
const updatedVariables = convertVariablesToDbFormat(tableRowData);
updateVariables(updatedVariables, newVariable.id, [], false);
},
[selectedDashboard, updateVariables],
[dashboardData, updateVariables],
);
return {

View File

@@ -47,12 +47,12 @@ const mockDashboard = {
};
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetSelectedDashboard = jest.fn();
const mockSetDashboardData = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
dashboardData: mockDashboard,
setDashboardData: mockSetDashboardData,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
}),
}));

View File

@@ -58,7 +58,7 @@ const mockDashboard = {
// Mock dependencies
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: mockDashboard,
dashboardData: mockDashboard,
}),
}));
@@ -154,7 +154,7 @@ describe('Panel Management Tests', () => {
// Temporarily mock the dashboard
jest.doMock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: modifiedDashboard,
dashboardData: modifiedDashboard,
}),
}));

View File

@@ -13,13 +13,13 @@ import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedDashboard } = useDashboardStore();
const updatedAtRef = useRef(selectedDashboard?.updatedAt);
const { dashboardData } = useDashboardStore();
const updatedAtRef = useRef(dashboardData?.updatedAt);
const selectedData = selectedDashboard
const selectedData = dashboardData
? {
...selectedDashboard.data,
uuid: selectedDashboard.id,
...dashboardData.data,
uuid: dashboardData.id,
}
: ({} as DashboardData);
@@ -31,7 +31,7 @@ function DashboardBreadcrumbs(): JSX.Element {
);
const hasDashboardBeenUpdated =
selectedDashboard?.updatedAt !== updatedAtRef.current;
dashboardData?.updatedAt !== updatedAtRef.current;
if (!hasDashboardBeenUpdated && dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
@@ -40,7 +40,7 @@ function DashboardBreadcrumbs(): JSX.Element {
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate, selectedDashboard?.updatedAt]);
}, [safeNavigate, dashboardData?.updatedAt]);
return (
<div className="dashboard-breadcrumbs">

View File

@@ -35,15 +35,15 @@ jest.mock('lib/uPlotV2/hooks/useLegendsSync', () => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (
selector?: (s: {
selectedDashboard: { locked: boolean } | undefined;
}) => { selectedDashboard: { locked: boolean } },
): { selectedDashboard: { locked: boolean } } => {
const mockState = { selectedDashboard: { locked: false } };
dashboardData: { locked: boolean } | undefined;
}) => { dashboardData: { locked: boolean } },
): { dashboardData: { locked: boolean } } => {
const mockState = { dashboardData: { locked: false } };
return selector ? selector(mockState) : mockState;
},
selectIsDashboardLocked: (s: {
selectedDashboard: { locked: boolean } | undefined;
}): boolean => s.selectedDashboard?.locked ?? false,
dashboardData: { locked: boolean } | undefined;
}): boolean => s.dashboardData?.locked ?? false,
}));
jest.mock('hooks/useNotifications', () => ({

View File

@@ -24,9 +24,7 @@ function ExportPanelContainer({
}: ExportPanelProps): JSX.Element {
const { t } = useTranslation(['dashboard']);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
null,
);
const [dashboardId, setDashboardId] = useState<string | null>(null);
const {
data,
@@ -55,17 +53,17 @@ function ExportPanelContainer({
const handleExportClick = useCallback((): void => {
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === selectedDashboardId,
({ id }) => id === dashboardId,
);
onExport(currentSelectedDashboard || null, false);
}, [data, selectedDashboardId, onExport]);
}, [data, dashboardId, onExport]);
const handleSelect = useCallback(
(selectedDashboardValue: string): void => {
setSelectedDashboardId(selectedDashboardValue);
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
},
[setSelectedDashboardId],
[setDashboardId],
);
const handleNewDashboard = useCallback(async () => {
@@ -85,10 +83,7 @@ function ExportPanelContainer({
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading ||
!options?.length ||
!selectedDashboardId ||
isLoading;
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
return (
<Wrapper direction="vertical">
@@ -101,7 +96,7 @@ function ExportPanelContainer({
showSearch
loading={isDashboardLoading}
disabled={isDashboardLoading}
value={selectedDashboardId}
value={dashboardId}
onSelect={handleSelect}
filterOption={filterOptions}
/>

View File

@@ -27,7 +27,7 @@ export default function DashboardEmptyState(): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
@@ -43,7 +43,7 @@ export default function DashboardEmptyState(): JSX.Element {
}
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -52,9 +52,9 @@ export default function DashboardEmptyState(): JSX.Element {
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length,
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);

View File

@@ -91,7 +91,7 @@ function FullView({
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const { selectedDashboard, setColumnWidths } = useDashboardStore();
const { dashboardData, setColumnWidths } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const onColumnWidthsChange = useCallback(
@@ -166,7 +166,7 @@ function FullView({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
dashboardData,
selectedPanelType,
});
@@ -344,7 +344,7 @@ function FullView({
<>
<QueryBuilderV2
panelType={selectedPanelType}
version={selectedDashboard?.data?.version || 'v3'}
version={dashboardData?.data?.version || 'v3'}
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
// filterConfigs={filterConfigs}

View File

@@ -19,7 +19,7 @@ export interface DrilldownQueryProps {
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
enableDrillDown: boolean;
selectedDashboard: Dashboard | undefined;
dashboardData: Dashboard | undefined;
selectedPanelType: PANEL_TYPES;
}
@@ -34,7 +34,7 @@ const useDrilldown = ({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
dashboardData,
selectedPanelType,
}: DrilldownQueryProps): UseDrilldownReturn => {
const isMounted = useRef(false);
@@ -60,11 +60,11 @@ const useDrilldown = ({
isMounted.current = true;
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
const dashboardEditView = selectedDashboard?.id
const dashboardEditView = dashboardData?.id
? generateExportToDashboardLink({
query: currentQuery,
panelType: selectedPanelType,
dashboardId: selectedDashboard?.id || '',
dashboardId: dashboardData?.id || '',
widgetId: widget.id,
})
: '';

View File

@@ -163,13 +163,13 @@ const mockProps: WidgetGraphComponentProps = {
// Mock useDashabord hook
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: {
dashboardData: {
data: {
variables: [],
},
},
setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(),
setDashboardData: jest.fn(),
setColumnWidths: jest.fn(),
}),
}));

View File

@@ -103,8 +103,8 @@ function WidgetGraphComponent({
const {
setLayouts,
selectedDashboard,
setSelectedDashboard,
dashboardData,
setDashboardData,
setColumnWidths,
} = useDashboardStore();
@@ -125,33 +125,33 @@ function WidgetGraphComponent({
const updateDashboardMutation = useUpdateDashboard();
const onDeleteHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
const updatedWidgets = dashboardData?.data?.widgets?.filter(
(e) => e.id !== widget.id,
);
const updatedLayout =
selectedDashboard.data.layout?.filter((e) => e.i !== widget.id) || [];
dashboardData.data.layout?.filter((e) => e.i !== widget.id) || [];
const updatedSelectedDashboard: Props = {
const updatedDashboardData: Props = {
data: {
...selectedDashboard.data,
...dashboardData.data,
widgets: updatedWidgets,
layout: updatedLayout,
},
id: selectedDashboard.id,
id: dashboardData.id,
};
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
updateDashboardMutation.mutateAsync(updatedDashboardData, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
setDeleteModal(false);
},
@@ -159,35 +159,35 @@ function WidgetGraphComponent({
};
const onCloneHandler = async (): Promise<void> => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const uuid = v4();
// this is added to make sure the cloned panel is of the same dimensions as the original one
const originalPanelLayout = selectedDashboard.data.layout?.find(
const originalPanelLayout = dashboardData.data.layout?.find(
(l) => l.i === widget.id,
);
const newLayoutItem = placeWidgetAtBottom(
uuid,
selectedDashboard?.data.layout || [],
dashboardData?.data.layout || [],
originalPanelLayout?.w || 6,
originalPanelLayout?.h || 6,
);
const layout = [...(selectedDashboard.data.layout || []), newLayoutItem];
const layout = [...(dashboardData.data.layout || []), newLayoutItem];
updateDashboardMutation.mutateAsync(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
layout,
widgets: [
...(selectedDashboard.data.widgets || []),
...(dashboardData.data.widgets || []),
{
...{
...widget,
@@ -202,8 +202,8 @@ function WidgetGraphComponent({
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
notifications.success({
message: 'Panel cloned successfully, redirecting to new copy.',

View File

@@ -70,16 +70,16 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
useIsFetching([REACT_QUERY_KEY.DASHBOARD_BY_ID]) > 0;
const {
selectedDashboard,
dashboardData,
layouts,
setLayouts,
panelMap,
setPanelMap,
setSelectedDashboard,
setDashboardData,
columnWidths,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const { data } = selectedDashboard || {};
const { data } = dashboardData || {};
const { pathname } = useLocation();
const dispatch = useDispatch();
@@ -124,7 +124,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -146,27 +146,27 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data)) {
logEvent('Dashboard Detail: Opened', {
dashboardId: selectedDashboard?.id,
dashboardId: dashboardData?.id,
dashboardName: data.title,
numberOfPanels: data.widgets?.length,
numberOfVariables: Object.keys(dashboardVariables).length || 0,
});
logEventCalledRef.current = true;
}
}, [dashboardVariables, data, selectedDashboard?.id]);
}, [dashboardVariables, data, dashboardData?.id]);
const onSaveHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const updatedDashboard: Props = {
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
panelMap: { ...currentPanelMap },
layout: dashboardLayout.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET),
widgets: selectedDashboard?.data?.widgets?.map((widget) => {
widgets: dashboardData?.data?.widgets?.map((widget) => {
if (columnWidths?.[widget.id]) {
return {
...widget,
@@ -184,7 +184,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
},
@@ -243,7 +243,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
dashboardLayout &&
Array.isArray(dashboardLayout) &&
dashboardLayout.length > 0 &&
hasColumnWidthsChanged(columnWidths, selectedDashboard);
hasColumnWidthsChanged(columnWidths, dashboardData);
if (shouldSaveLayout || shouldSaveColumnWidths) {
onSaveHandler();
@@ -253,7 +253,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
const onSettingsModalSubmit = (): void => {
const newTitle = form.getFieldValue('title');
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
@@ -261,7 +261,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const currentWidget = selectedDashboard?.data?.widgets?.find(
const currentWidget = dashboardData?.data?.widgets?.find(
(e) => e.id === currentSelectRowId,
);
@@ -269,25 +269,25 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
const updatedWidgets = dashboardData?.data?.widgets?.map((e) =>
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
);
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
const updatedDashboardData: Props = {
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
widgets: updatedWidgets,
},
};
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
updateDashboardMutation.mutateAsync(updatedDashboardData, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
if (setPanelMap) {
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
@@ -311,7 +311,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}, [currentSelectRowId, form, widgets]);
const handleRowCollapse = (id: string): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
@@ -343,7 +343,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
};
const handleRowDelete = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
@@ -351,34 +351,33 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
const updatedWidgets = dashboardData?.data?.widgets?.filter(
(e) => e.id !== currentSelectRowId,
);
const updatedLayout =
selectedDashboard.data.layout?.filter((e) => e.i !== currentSelectRowId) ||
[];
dashboardData.data.layout?.filter((e) => e.i !== currentSelectRowId) || [];
const updatedPanelMap = { ...currentPanelMap };
delete updatedPanelMap[currentSelectRowId];
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
const updatedDashboardData: Props = {
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
widgets: updatedWidgets,
layout: updatedLayout,
panelMap: updatedPanelMap,
},
};
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
updateDashboardMutation.mutateAsync(updatedDashboardData, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
if (setPanelMap) {
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
@@ -390,10 +389,8 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
};
const isDashboardEmpty = useMemo(
() =>
selectedDashboard?.data.layout
? selectedDashboard?.data.layout?.length === 0
: true,
[selectedDashboard],
dashboardData?.data.layout ? dashboardData?.data.layout?.length === 0 : true,
[dashboardData],
);
let isDataAvailableInAnyWidget = false;
@@ -512,7 +509,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
headerMenuList={widgetActions}
variables={dashboardVariables}
// version={selectedDashboard?.data?.version}
// version={dashboardData?.data?.version}
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}

View File

@@ -42,14 +42,14 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const permissions: ComponentTypes[] = ['add_panel'];
const { user } = useAppContext();
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
const [addPanelPermission] = useComponentPermission(permissions, userRole);
@@ -87,11 +87,11 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
icon={<Plus size={14} />}
onClick={(): void => {
// TODO: @AshwinBhatkal Simplify this check in cleanup of https://github.com/SigNoz/engineering-pod/issues/3953
if (!selectedDashboard?.id) {
if (!dashboardData?.id) {
return;
}
setSelectedRowWidgetId(selectedDashboard.id, id);
setSelectedRowWidgetId(dashboardData.id, id);
setIsPanelTypeSelectionModalOpen(true);
}}
>

View File

@@ -121,7 +121,7 @@ function useNavigateToExplorerPages(): (
) => Promise<{
[queryName: string]: { filters: TagFilterItem[]; dataSource?: string };
}> {
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(
@@ -143,7 +143,7 @@ function useNavigateToExplorerPages(): (
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedDashboard, notifications],
[dashboardData, notifications],
);
}

View File

@@ -20,7 +20,7 @@ interface UseUpdatedQueryOptions {
panelTypes: PANEL_TYPES;
timePreferance: timePreferenceType;
};
selectedDashboard?: any;
dashboardData?: any;
}
interface UseUpdatedQueryResult {
@@ -44,7 +44,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const getUpdatedQuery = useCallback(
async ({
widgetConfig,
selectedDashboard,
dashboardData,
}: UseUpdatedQueryOptions): Promise<Query> => {
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayloadV5({
@@ -52,7 +52,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables: getDashboardVariables(selectedDashboard?.data?.variables),
variables: getDashboardVariables(dashboardData?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});

View File

@@ -149,16 +149,16 @@ export function extractQueryNamesFromExpression(expression: string): string[] {
export const hasColumnWidthsChanged = (
columnWidths: Record<string, Record<string, number>>,
selectedDashboard?: Dashboard,
dashboardData?: Dashboard,
): boolean => {
// If no column widths stored, no changes
if (isEmpty(columnWidths) || !selectedDashboard) {
if (isEmpty(columnWidths) || !dashboardData) {
return false;
}
// Check each widget's column widths
return Object.keys(columnWidths).some((widgetId) => {
const dashboardWidget = selectedDashboard?.data?.widgets?.find(
const dashboardWidget = dashboardData?.data?.widgets?.find(
(widget) => widget.id === widgetId,
) as Widgets;

View File

@@ -37,6 +37,10 @@ jest.mock('utils/navigation', () => ({
const openInNewTabMock = openInNewTab as jest.Mock;
// Mock Date.now to prevent flaky tests due to time-dependent values
const MOCK_NOW = 1700000000000; // Fixed timestamp
jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW);
// Mock DrawerWrapper to avoid CSS issues with jsdom
// SyntaxError: 'div#radix-:rbv,,._dialog__content_qf8bf_22 :focus' is not a valid selector
jest.mock('@signozhq/ui', () => ({
@@ -188,6 +192,7 @@ describe('K8sBaseList', () => {
await waitFor(() => {
const selectedItem = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('selectedItem'))
.filter(Boolean)
.pop();
expect(selectedItem).toBe(`PodId:${itemId}`);
});

View File

@@ -93,8 +93,8 @@ jest.mock('hooks/useDarkMode', () => ({
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));

View File

@@ -106,7 +106,7 @@ describe('LogsPanelComponent', () => {
<PreferenceContextProvider>
<NewWidget
dashboardId=""
selectedDashboard={undefined}
dashboardData={undefined}
selectedGraph={PANEL_TYPES.LIST}
/>
</PreferenceContextProvider>

View File

@@ -30,7 +30,7 @@ function LeftContainer({
setRequestData,
setQueryResponse,
enableDrillDown = false,
selectedDashboard,
dashboardData,
isNewPanel = false,
}: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
@@ -79,8 +79,8 @@ function LeftContainer({
isLoadingQueries={queryResponse.isFetching}
selectedWidget={selectedWidget}
dashboardVersion={ENTITY_VERSION_V5}
dashboardId={selectedDashboard?.id}
dashboardName={selectedDashboard?.data.title}
dashboardId={dashboardData?.id}
dashboardName={dashboardData?.data.title}
isNewPanel={isNewPanel}
/>
{selectedGraph === PANEL_TYPES.LIST && (

View File

@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
<PreferenceContextProvider>
<NewWidget
dashboardId=""
selectedDashboard={undefined}
dashboardData={undefined}
selectedGraph={PANEL_TYPES.BAR}
/>
</PreferenceContextProvider>
@@ -378,7 +378,7 @@ describe('when switching to BAR panel type', () => {
<DashboardBootstrapWrapper dashboardId="">
<NewWidget
dashboardId=""
selectedDashboard={undefined}
dashboardData={undefined}
selectedGraph={PANEL_TYPES.BAR}
/>
</DashboardBootstrapWrapper>,

View File

@@ -86,7 +86,7 @@ import {
import './NewWidget.styles.scss';
function NewWidget({
selectedDashboard,
dashboardData,
dashboardId,
selectedGraph,
enableDrillDown = false,
@@ -135,7 +135,7 @@ function NewWidget({
[selectedGraph, globalSelectedInterval, isLogsQuery],
);
const { widgets = [] } = selectedDashboard?.data || {};
const { widgets = [] } = dashboardData?.data || {};
const query = useUrlQuery();
@@ -154,9 +154,9 @@ function NewWidget({
if (!logEventCalledRef.current) {
logEvent('Panel Edit: Page visited', {
panelType: selectedWidget?.panelTypes,
dashboardId: selectedDashboard?.id,
dashboardId: dashboardData?.id,
widgetId: selectedWidget?.id,
dashboardName: selectedDashboard?.data.title,
dashboardName: dashboardData?.data.title,
isNewPanel: !!isWidgetNotPresent,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
});
@@ -362,7 +362,7 @@ function NewWidget({
const updateDashboardMutation = useUpdateDashboard();
const { afterWidgets, preWidgets } = useMemo(() => {
if (!selectedDashboard) {
if (!dashboardData) {
return {
selectedWidget: {} as Widgets,
preWidgets: [],
@@ -372,21 +372,18 @@ function NewWidget({
const widgetId = query.get('widgetId');
const selectedWidgetIndex = getSelectedWidgetIndex(
selectedDashboard,
widgetId,
);
const selectedWidgetIndex = getSelectedWidgetIndex(dashboardData, widgetId);
const preWidgets = getPreviousWidgets(selectedDashboard, selectedWidgetIndex);
const preWidgets = getPreviousWidgets(dashboardData, selectedWidgetIndex);
const afterWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
const afterWidgets = getNextWidgets(dashboardData, selectedWidgetIndex);
const selectedWidget = (selectedDashboard.data.widgets || [])[
const selectedWidget = (dashboardData.data.widgets || [])[
selectedWidgetIndex || 0
];
return { selectedWidget, preWidgets, afterWidgets };
}, [selectedDashboard, query]);
}, [dashboardData, query]);
// this loading state is to take care of mismatch in the responses for table and other panels
// hence while changing the query contains the older value and the processing logic fails
@@ -483,12 +480,12 @@ function NewWidget({
}, [dashboardId, query, safeNavigate]);
const onClickSaveHandler = useCallback(() => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const widgetId = query.get('widgetId') || '';
let updatedLayout = selectedDashboard.data.layout || [];
let updatedLayout = dashboardData.data.layout || [];
const selectedRowWidgetId = getSelectedRowWidgetId(dashboardId);
@@ -522,10 +519,10 @@ function NewWidget({
const adjustedQueryForV5 = adjustQueryForV5(currentQuery);
const dashboard: Props = {
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
widgets: isNewDashboard
? [
...afterWidgets,
@@ -603,7 +600,7 @@ function NewWidget({
},
});
}, [
selectedDashboard,
dashboardData,
query,
isNewDashboard,
afterWidgets,
@@ -672,9 +669,9 @@ function NewWidget({
const onSaveDashboard = useCallback((): void => {
logEvent('Panel Edit: Save changes', {
panelType: selectedWidget.panelTypes,
dashboardId: selectedDashboard?.id,
dashboardId: dashboardData?.id,
widgetId: selectedWidget.id,
dashboardName: selectedDashboard?.data.title,
dashboardName: dashboardData?.data.title,
queryType: currentQuery.queryType,
isNewPanel,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
@@ -870,7 +867,7 @@ function NewWidget({
<OverlayScrollbar>
{selectedWidget && (
<LeftContainer
selectedDashboard={selectedDashboard}
dashboardData={dashboardData}
selectedGraph={graphType}
selectedLogFields={selectedLogFields}
setSelectedLogFields={setSelectedLogFields}

View File

@@ -10,7 +10,7 @@ import { timePreferance } from './RightContainer/timeItems';
export interface NewWidgetProps {
dashboardId: string;
selectedDashboard: Dashboard | undefined;
dashboardData: Dashboard | undefined;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;
}
@@ -34,7 +34,7 @@ export interface WidgetGraphProps {
>
>;
enableDrillDown?: boolean;
selectedDashboard: Dashboard | undefined;
dashboardData: Dashboard | undefined;
isNewPanel?: boolean;
}

View File

@@ -66,7 +66,7 @@ const useBaseAggregateOptions = ({
getUpdatedQuery,
isLoading: isResolveQueryLoading,
} = useUpdatedQuery();
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
useEffect(() => {
if (!aggregateData) {
@@ -79,7 +79,7 @@ const useBaseAggregateOptions = ({
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
selectedDashboard,
dashboardData,
});
setResolvedQuery(updatedQuery);
};

View File

@@ -14,7 +14,7 @@ jest.mock('react-router-dom', () => ({
// Mock useDashabord hook
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: {
dashboardData: {
data: {
variables: [],
},

View File

@@ -48,7 +48,7 @@ export function useDashboardBootstrap(
);
const {
setSelectedDashboard,
setDashboardData,
setLayouts,
setPanelMap,
resetDashboardStore,
@@ -65,7 +65,7 @@ export function useDashboardBootstrap(
transformDashboardVariables,
} = useTransformDashboardVariables(dashboardId);
// Keep the external variables store in sync with selectedDashboard
// Keep the external variables store in sync with dashboardData
useDashboardVariablesSync(dashboardId);
const dashboardQuery = useDashboardQuery(dashboardId);
@@ -88,7 +88,7 @@ export function useDashboardBootstrap(
if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
}
setSelectedDashboard(updatedDashboardData);
setDashboardData(updatedDashboardData);
dashboardRef.current = updatedDashboardData;
setLayouts(sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)));
setPanelMap(defaultTo(updatedDashboardData?.data?.panelMap, {}));
@@ -107,7 +107,7 @@ export function useDashboardBootstrap(
title: t('dashboard_has_been_updated'),
content: t('do_you_want_to_refresh_the_dashboard'),
onOk() {
setSelectedDashboard(updatedDashboardData);
setDashboardData(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,

View File

@@ -13,21 +13,21 @@ import { useDashboardVariablesSelector } from './useDashboardVariables';
/**
* Keeps the external variables store in sync with the zustand dashboard store.
* When selectedDashboard changes, propagates variable updates to the variables store.
* When dashboardData changes, propagates variable updates to the variables store.
*/
export function useDashboardVariablesSync(dashboardId: string): void {
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
const selectedDashboard = useDashboardStore(
(s: DashboardStore) => s.selectedDashboard,
const dashboardData = useDashboardStore(
(s: DashboardStore) => s.dashboardData,
);
useEffect(() => {
const updatedVariables = selectedDashboard?.data.variables || {};
const updatedVariables = dashboardData?.data.variables || {};
if (savedDashboardId !== dashboardId) {
setDashboardVariablesStore({ dashboardId, variables: updatedVariables });
} else if (!isEqual(dashboardVariables, updatedVariables)) {
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
}
}, [selectedDashboard]); // eslint-disable-line react-hooks/exhaustive-deps
}, [dashboardData]); // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -1,7 +1,7 @@
import { useMutation } from 'react-query';
import locked from 'api/v1/dashboards/id/lock';
import {
getSelectedDashboard,
getDashboardData,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -13,13 +13,11 @@ import APIError from 'types/api/error';
*/
export function useLockDashboard(): (value: boolean) => Promise<void> {
const { showErrorModal } = useErrorModal();
const { setSelectedDashboard } = useDashboardStore();
const { setDashboardData } = useDashboardStore();
const { mutate: lockDashboard } = useMutation(locked, {
onSuccess: (_, props) => {
setSelectedDashboard((prev) =>
prev ? { ...prev, locked: props.lock } : prev,
);
setDashboardData((prev) => (prev ? { ...prev, locked: props.lock } : prev));
},
onError: (error) => {
showErrorModal(error as APIError);
@@ -27,11 +25,11 @@ export function useLockDashboard(): (value: boolean) => Promise<void> {
});
return async (value: boolean): Promise<void> => {
const selectedDashboard = getSelectedDashboard();
if (selectedDashboard) {
const dashboardData = getDashboardData();
if (dashboardData) {
try {
await lockDashboard({
id: selectedDashboard.id,
id: dashboardData.id,
lock: value,
});
} catch (error) {

View File

@@ -12,11 +12,11 @@ import { useDashboardVariablesByType } from './useDashboardVariablesByType';
*/
export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
const dynamicVariables = useDashboardVariablesByType('DYNAMIC', 'values');
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
return useMemo(() => {
const widgets =
selectedDashboard?.data?.widgets?.filter(
dashboardData?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || [];
@@ -24,5 +24,5 @@ export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
dynamicVariables,
widgets as Widgets[],
);
}, [selectedDashboard, dynamicVariables]);
}, [dashboardData, dynamicVariables]);
}

View File

@@ -0,0 +1,197 @@
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
jest.mock('react-query', () => ({
useMutation: jest.fn(),
}));
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/dashboard/substitute_vars', () => ({
getSubstituteVars: jest.fn(),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: jest.fn().mockReturnValue({ queryPayload: {} }),
}));
jest.mock(
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
() => ({
mapQueryDataFromApi: jest.fn(),
}),
);
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: (): unknown => ({ dashboardVariables: {} }),
}));
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown => ({}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): unknown => ({
notifications: { error: jest.fn() },
}),
}));
jest.mock('lib/dashboardVariables/getDashboardVariables', () => ({
getDashboardVariables: (): unknown => ({}),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
}));
jest.mock('utils/getGraphType', () => ({
getGraphType: jest.fn().mockReturnValue('time_series'),
}));
const mockMapQueryDataFromApi = mapQueryDataFromApi as jest.MockedFunction<
typeof mapQueryDataFromApi
>;
const mockUseMutation = useMutation as jest.MockedFunction<typeof useMutation>;
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
const buildWidget = (queryType: EQueryType | undefined): Widgets =>
(({
id: 'widget-1',
panelTypes: 'graph',
timePreferance: 'GLOBAL_TIME',
yAxisUnit: '',
query: {
queryType,
builder: { queryData: [], queryFormulas: [] },
clickhouse_sql: [],
promql: [],
id: 'q-1',
},
} as unknown) as Widgets);
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
throw new Error('compositeQuery not found in URL');
}
return JSON.parse(decodeURIComponent(raw));
};
describe('useCreateAlerts', () => {
let capturedOnSuccess:
| ((data: { data: { compositeQuery: unknown } }) => void)
| null = null;
beforeEach(() => {
jest.clearAllMocks();
capturedOnSuccess = null;
mockUseSelector.mockReturnValue({ selectedTime: '1h' });
mockUseMutation.mockReturnValue(({
mutate: jest.fn((_payload, opts) => {
capturedOnSuccess = opts?.onSuccess ?? null;
}),
} as unknown) as ReturnType<typeof useMutation>);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).open = jest.fn();
});
it('preserves widget queryType when the API response maps to a different queryType', () => {
mockMapQueryDataFromApi.mockReturnValue({
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [], queryFormulas: [] },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const widget = buildWidget(EQueryType.CLICKHOUSE);
const { result } = renderHook(() => useCreateAlerts(widget));
act(() => {
result.current();
});
expect(capturedOnSuccess).not.toBeNull();
act(() => {
capturedOnSuccess?.({ data: { compositeQuery: {} } });
});
const composite = getCompositeQueryFromLastOpen();
expect(composite.queryType).toBe(EQueryType.CLICKHOUSE);
});
it('preserves promql queryType through the alert navigation URL', () => {
mockMapQueryDataFromApi.mockReturnValue({
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [], queryFormulas: [] },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const widget = buildWidget(EQueryType.PROM);
const { result } = renderHook(() => useCreateAlerts(widget));
act(() => {
result.current();
});
act(() => {
capturedOnSuccess?.({ data: { compositeQuery: {} } });
});
const composite = getCompositeQueryFromLastOpen();
expect(composite.queryType).toBe(EQueryType.PROM);
});
it('falls back to the mapped queryType when widget has no queryType', () => {
mockMapQueryDataFromApi.mockReturnValue({
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [], queryFormulas: [] },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const widget = buildWidget(undefined);
const { result } = renderHook(() => useCreateAlerts(widget));
act(() => {
result.current();
});
act(() => {
capturedOnSuccess?.({ data: { compositeQuery: {} } });
});
const composite = getCompositeQueryFromLastOpen();
// No override, so the mapped value wins.
expect(composite.queryType).toBe(EQueryType.QUERY_BUILDER);
});
it('does nothing when widget is undefined', () => {
const { result } = renderHook(() => useCreateAlerts(undefined));
act(() => {
result.current();
});
const mutateCalls = (mockUseMutation.mock.results[0].value as ReturnType<
typeof useMutation
>).mutate as jest.Mock;
expect(mutateCalls).not.toHaveBeenCalled();
});
});

View File

@@ -33,7 +33,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const { notifications } = useNotifications();
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const dashboardDynamicVariables = useDashboardVariablesByType(
@@ -49,8 +49,8 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
if (caller === 'panelView') {
logEvent('Panel Edit: Create alert', {
panelType: widget.panelTypes,
dashboardName: selectedDashboard?.data?.title,
dashboardId: selectedDashboard?.id,
dashboardName: dashboardData?.data?.title,
dashboardId: dashboardData?.id,
widgetId: widget.id,
queryType: widget.query.queryType,
});
@@ -58,8 +58,8 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
logEvent('Dashboard Detail: Panel action', {
action: MenuItemKeys.CreateAlerts,
panelType: widget.panelTypes,
dashboardName: selectedDashboard?.data?.title,
dashboardId: selectedDashboard?.id,
dashboardName: dashboardData?.data?.title,
dashboardId: dashboardData?.id,
widgetId: widget.id,
queryType: widget.query.queryType,
});
@@ -76,6 +76,9 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
if (widget.query.queryType) {
updatedQuery.queryType = widget.query.queryType;
}
// If widget has a y-axis unit, set it to the updated query if it is not already set
if (widget.yAxisUnit && !isEmpty(widget.yAxisUnit)) {
updatedQuery.unit = widget.yAxisUnit;

View File

@@ -21,9 +21,7 @@ function DashboardPage(): JSX.Element {
error,
} = useDashboardBootstrap(dashboardId, { confirm: onModal.confirm });
const dashboardTitle = useDashboardStore(
(s) => s.selectedDashboard?.data.title,
);
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
useEffect(() => {
document.title = dashboardTitle || document.title;

View File

@@ -62,9 +62,9 @@ function DashboardWidgetInternal({
widgetId: string;
graphType: PANEL_TYPES;
}): JSX.Element | null {
const [selectedDashboard, setSelectedDashboard] = useState<
Dashboard | undefined
>(undefined);
const [dashboardData, setDashboardData] = useState<Dashboard | undefined>(
undefined,
);
const { transformDashboardVariables } = useTransformDashboardVariables(
dashboardId,
@@ -83,7 +83,7 @@ function DashboardWidgetInternal({
cacheTime: DASHBOARD_CACHE_TIME,
onSuccess: (response) => {
const updatedDashboardData = transformDashboardVariables(response.data);
setSelectedDashboard(updatedDashboardData);
setDashboardData(updatedDashboardData);
setDashboardVariablesStore({
dashboardId,
variables: updatedDashboardData.data.variables,
@@ -108,7 +108,7 @@ function DashboardWidgetInternal({
dashboardId={dashboardId}
selectedGraph={graphType}
enableDrillDown={isDrilldownEnabled()}
selectedDashboard={selectedDashboard}
dashboardData={dashboardData}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQueryClient } from 'react-query';
import { generatePath, matchPath, useLocation } from 'react-router-dom';
import { Input } from 'antd';
import { Input } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import axios from 'axios';
import SignozModal from 'components/SignozModal/SignozModal';
@@ -132,7 +132,6 @@ function CreateFunnel({
onChange={handleInputChange}
placeholder="Eg. checkout dropoff funnel"
autoFocus
status={inputError && 'error'}
/>
{inputError && (
<span className="funnel-modal-content__error">{inputError}</span>

View File

@@ -69,17 +69,17 @@ jest.mock('react-redux', () => ({
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
function TestComponent(): JSX.Element {
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
return (
<div>
<div data-testid="dashboard-id">{selectedDashboard?.id}</div>
<div data-testid="dashboard-id">{dashboardData?.id}</div>
<div data-testid="dashboard-variables">
{dashboardVariables ? JSON.stringify(dashboardVariables) : 'null'}
</div>
<div data-testid="dashboard-data">
{selectedDashboard?.data?.title || 'No Title'}
{dashboardData?.data?.title || 'No Title'}
</div>
</div>
);

View File

@@ -9,8 +9,8 @@ export type WidgetColumnWidths = {
export interface DashboardUISlice {
//
selectedDashboard: Dashboard | undefined;
setSelectedDashboard: (
dashboardData: Dashboard | undefined;
setDashboardData: (
updater:
| Dashboard
| undefined
@@ -26,7 +26,7 @@ export interface DashboardUISlice {
}
export const initialDashboardUIState = {
selectedDashboard: undefined as Dashboard | undefined,
dashboardData: undefined as Dashboard | undefined,
columnWidths: {} as WidgetColumnWidths,
};
@@ -38,10 +38,10 @@ export const createDashboardUISlice: StateCreator<
> = (set) => ({
...initialDashboardUIState,
setSelectedDashboard: (updater): void =>
setDashboardData: (updater): void =>
set((state: DashboardUISlice): void => {
state.selectedDashboard =
typeof updater === 'function' ? updater(state.selectedDashboard) : updater;
state.dashboardData =
typeof updater === 'function' ? updater(state.dashboardData) : updater;
}),
setColumnWidths: (updater): void =>

View File

@@ -25,7 +25,7 @@ export type DashboardStore = DashboardUISlice &
* In this case, we are selecting the locked state of the selected dashboard.
* */
export const selectIsDashboardLocked = (s: DashboardStore): boolean =>
s.selectedDashboard?.locked ?? false;
s.dashboardData?.locked ?? false;
export const useDashboardStore = create<DashboardStore>()(
immer((set, get, api) => ({
@@ -40,8 +40,8 @@ export const useDashboardStore = create<DashboardStore>()(
);
// Standalone imperative accessors — use these instead of calling useDashboardStore.getState() at call sites.
export const getSelectedDashboard = (): Dashboard | undefined =>
useDashboardStore.getState().selectedDashboard;
export const getDashboardData = (): Dashboard | undefined =>
useDashboardStore.getState().dashboardData;
export const getDashboardLayouts = (): Layout[] =>
useDashboardStore.getState().layouts;

View File

@@ -2,28 +2,28 @@ import { Layout } from 'react-grid-layout';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
export const getPreviousWidgets = (
selectedDashboard: Dashboard,
dashboardData: Dashboard,
selectedWidgetIndex: number,
): Widgets[] =>
(selectedDashboard.data.widgets?.slice(
(dashboardData.data.widgets?.slice(
0,
selectedWidgetIndex || 0,
) as Widgets[]) || [];
export const getNextWidgets = (
selectedDashboard: Dashboard,
dashboardData: Dashboard,
selectedWidgetIndex: number,
): Widgets[] =>
(selectedDashboard.data.widgets?.slice(
(dashboardData.data.widgets?.slice(
(selectedWidgetIndex || 0) + 1, // this is never undefined
selectedDashboard.data.widgets?.length,
dashboardData.data.widgets?.length,
) as Widgets[]) || [];
export const getSelectedWidgetIndex = (
selectedDashboard: Dashboard,
dashboardData: Dashboard,
widgetId: string | null,
): number =>
selectedDashboard.data.widgets?.findIndex((e) => e.id === widgetId) || 0;
dashboardData.data.widgets?.findIndex((e) => e.id === widgetId) || 0;
export const sortLayout = (layout: Layout[]): Layout[] =>
[...layout].sort((a, b) => {

View File

@@ -44,7 +44,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -55,28 +54,27 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -88,7 +86,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -105,7 +102,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -160,27 +156,27 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -1,733 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// postableRuleExamples returns example payloads attached to every rule-write
// endpoint. They cover each alert type, rule type, and composite-query shape.
func postableRuleExamples() []handler.OpenAPIExample {
rolling := func(evalWindow, frequency string) map[string]any {
return map[string]any{
"kind": "rolling",
"spec": map[string]any{"evalWindow": evalWindow, "frequency": frequency},
}
}
renotify := func(interval string, states ...string) map[string]any {
s := make([]any, 0, len(states))
for _, v := range states {
s = append(s, v)
}
return map[string]any{
"enabled": true,
"interval": interval,
"alertStates": s,
}
}
return []handler.OpenAPIExample{
{
Name: "metric_threshold_single",
Summary: "Metric threshold single builder query",
Description: "Fires when a pod consumes more than 80% of its requested CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.",
Value: map[string]any{
"alert": "Pod CPU above 80% of request",
"alertType": "METRIC_BASED_ALERT",
"description": "CPU usage for api-service pods exceeds 80% of the requested CPU",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percentunit",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"aggregations": []any{map[string]any{"metricName": "k8s.pod.cpu_request_utilization", "timeAggregation": "avg", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "k8s.deployment.name = 'api-service'"},
"groupBy": []any{
map[string]any{"name": "k8s.pod.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
"legend": "{{k8s.pod.name}} ({{deployment.environment}})",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "all_the_times",
"target": 0.8,
"channels": []any{"slack-platform", "pagerduty-oncall"},
},
},
},
},
"evaluation": rolling("15m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"k8s.pod.name", "deployment.environment"},
"renotify": renotify("4h", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "platform"},
"annotations": map[string]any{
"description": "Pod {{$k8s.pod.name}} CPU is at {{$value}} of request in {{$deployment.environment}}.",
"summary": "Pod CPU above {{$threshold}} of request",
},
},
},
{
Name: "metric_threshold_formula",
Summary: "Metric threshold multi-query formula",
Description: "Computes disk utilization as (1 - available/capacity) * 100 by combining two disabled base queries with a builder_formula. The formula emits 0100, so compositeQuery.unit is set to \"percent\" and the target is a bare number.",
Value: map[string]any{
"alert": "PersistentVolume above 80% utilization",
"alertType": "METRIC_BASED_ALERT",
"description": "Disk utilization for a persistent volume is above 80%",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "k8s.volume.available", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "k8s.volume.type = 'persistentVolumeClaim'"},
"groupBy": []any{
map[string]any{"name": "k8s.persistentvolumeclaim.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "k8s.namespace.name", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "k8s.volume.capacity", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "k8s.volume.type = 'persistentVolumeClaim'"},
"groupBy": []any{
map[string]any{"name": "k8s.persistentvolumeclaim.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "k8s.namespace.name", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(1 - A/B) * 100",
"legend": "{{k8s.persistentvolumeclaim.name}} ({{k8s.namespace.name}})",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 80,
"channels": []any{"slack-storage"},
},
},
},
},
"evaluation": rolling("30m", "5m"),
"notificationSettings": map[string]any{
"groupBy": []any{"k8s.namespace.name", "k8s.persistentvolumeclaim.name"},
"renotify": renotify("2h", "firing"),
},
"labels": map[string]any{"severity": "critical"},
"annotations": map[string]any{
"description": "Volume {{$k8s.persistentvolumeclaim.name}} in {{$k8s.namespace.name}} is {{$value}}% full.",
"summary": "Disk utilization above {{$threshold}}%",
},
},
},
{
Name: "metric_promql",
Summary: "Metric threshold PromQL rule",
Description: "PromQL expression instead of the builder. Dotted OTEL resource attributes are quoted (\"deployment.environment\"). Useful for queries that combine series with group_right or other Prom operators.",
Value: map[string]any{
"alert": "Kafka consumer group lag above 1000",
"alertType": "METRIC_BASED_ALERT",
"description": "Consumer group lag computed via PromQL",
"ruleType": "promql_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "promql",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "promql",
"spec": map[string]any{
"name": "A",
"query": "(max by(topic, partition, \"deployment.environment\")(kafka_log_end_offset) - on(topic, partition, \"deployment.environment\") group_right max by(group, topic, partition, \"deployment.environment\")(kafka_consumer_committed_offset)) > 0",
"legend": "{{topic}}/{{partition}} ({{group}})",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "all_the_times",
"target": 1000,
"channels": []any{"slack-data-platform", "pagerduty-data"},
},
},
},
},
"evaluation": rolling("10m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"group", "topic"},
"renotify": renotify("1h", "firing"),
},
"labels": map[string]any{"severity": "critical"},
"annotations": map[string]any{
"description": "Consumer group {{$group}} is {{$value}} messages behind on {{$topic}}/{{$partition}}.",
"summary": "Kafka consumer lag high",
},
},
},
{
Name: "metric_anomaly",
Summary: "Metric anomaly rule (v1 only)",
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Value: map[string]any{
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"evalWindow": "24h",
"frequency": "3h",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 21600,
"aggregations": []any{map[string]any{"metricName": "otelcol_receiver_accepted_spans", "timeAggregation": "rate", "spaceAggregation": "sum"}},
"filter": map[string]any{"expression": "tenant_tier = 'premium'"},
"groupBy": []any{map[string]any{"name": "tenant_id", "fieldContext": "attribute", "fieldDataType": "string"}},
"functions": []any{
map[string]any{
"name": "anomaly",
"args": []any{map[string]any{"name": "z_score_threshold", "value": 2}},
},
},
"legend": "{{tenant_id}}",
},
},
},
},
"op": "below",
"matchType": "all_the_times",
"target": 2,
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,
"requiredNumPoints": 3,
},
"labels": map[string]any{"severity": "warning"},
"preferredChannels": []any{"slack-ingestion"},
"annotations": map[string]any{
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
"summary": "Span ingestion anomaly",
},
},
},
{
Name: "logs_threshold",
Summary: "Logs threshold count() over filter",
Description: "Counts matching log records (ERROR severity + body contains) over a rolling window. Fires at least once per evaluation when the count exceeds zero.",
Value: map[string]any{
"alert": "Payments service panic logs",
"alertType": "LOGS_BASED_ALERT",
"description": "Any panic log line emitted by the payments service",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"stepInterval": 60,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'payments-api' AND severity_text = 'ERROR' AND body CONTAINS 'panic'"},
"groupBy": []any{
map[string]any{"name": "k8s.pod.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
"legend": "{{k8s.pod.name}} ({{deployment.environment}})",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 0,
"channels": []any{"slack-payments", "pagerduty-payments"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"k8s.pod.name", "deployment.environment"},
"renotify": renotify("15m", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "payments"},
"annotations": map[string]any{
"description": "{{$k8s.pod.name}} emitted {{$value}} panic log(s) in {{$deployment.environment}}.",
"summary": "Payments service panic",
},
},
},
{
Name: "logs_error_rate_formula",
Summary: "Logs error rate error count / total count × 100",
Description: "Two disabled log count queries (A = errors, B = total) combined via a builder_formula into a percentage. Classic service-level error-rate alert pattern for log-based signals.",
Value: map[string]any{
"alert": "Payments-api error log rate above 1%",
"alertType": "LOGS_BASED_ALERT",
"description": "Error log ratio as a percentage of total logs for payments-api",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'payments-api' AND severity_text IN ['ERROR', 'FATAL']"},
"groupBy": []any{map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"}},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "logs",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'payments-api'"},
"groupBy": []any{map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"}},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(A / B) * 100",
"legend": "{{deployment.environment}}",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 1,
"channels": []any{"slack-payments"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"deployment.environment"},
"renotify": renotify("30m", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "payments"},
"annotations": map[string]any{
"description": "Error log rate in {{$deployment.environment}} is {{$value}}%",
"summary": "Payments-api error rate above {{$threshold}}%",
},
},
},
{
Name: "traces_threshold_latency",
Summary: "Traces threshold p99 latency (ns → s conversion)",
Description: "Builder query against the traces signal with p99(duration_nano). The series unit is ns (compositeQuery.unit), the target is in seconds (threshold.targetUnit) SigNoz converts before comparing. Canonical shape when series and target live in different units.",
Value: map[string]any{
"alert": "Search API p99 latency above 5s",
"alertType": "TRACES_BASED_ALERT",
"description": "p99 duration of the search endpoint exceeds 5s",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "ns",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"stepInterval": 60,
"aggregations": []any{map[string]any{"expression": "p99(duration_nano)"}},
"filter": map[string]any{"expression": "service.name = 'search-api' AND name = 'GET /api/v1/search'"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
},
"legend": "{{service.name}} {{http.route}}",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "above",
"matchType": "at_least_once",
"target": 5,
"targetUnit": "s",
"channels": []any{"slack-search"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"service.name", "http.route"},
"renotify": renotify("30m", "firing"),
},
"labels": map[string]any{"severity": "warning", "team": "search"},
"annotations": map[string]any{
"description": "p99 latency for {{$service.name}} on {{$http.route}} crossed {{$threshold}}s.",
"summary": "Search-api latency degraded",
},
},
},
{
Name: "traces_error_rate_formula",
Summary: "Traces error rate error spans / total spans × 100",
Description: "Two disabled trace count queries (A = error spans where hasError=true, B = total spans) combined via a builder_formula into a percentage. Mirrors the common request-error-rate dashboard shape.",
Value: map[string]any{
"alert": "Search-api error rate above 5%",
"alertType": "TRACES_BASED_ALERT",
"description": "Request error rate for search-api, grouped by route",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'search-api' AND hasError = true"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'search-api'"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(A / B) * 100",
"legend": "{{service.name}} {{http.route}}",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 5,
"channels": []any{"slack-search", "pagerduty-search"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"service.name", "http.route"},
"renotify": renotify("15m", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "search"},
"annotations": map[string]any{
"description": "Error rate on {{$service.name}} {{$http.route}} is {{$value}}%",
"summary": "Search-api error rate above {{$threshold}}%",
},
},
},
{
Name: "tiered_thresholds",
Summary: "Tiered thresholds with per-tier channels",
Description: "Two tiers (warning and critical) in a single rule, each with its own target, op, matchType, and channels so warnings and pages route to different receivers. `alertOnAbsent` + `absentFor` fires a no-data alert when the query returns no series for 15 consecutive evaluations.",
Value: map[string]any{
"alert": "Kafka consumer lag warn / critical",
"alertType": "METRIC_BASED_ALERT",
"description": "Warn at lag ≥ 50 and page at ≥ 200, tiered via thresholds.spec.",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "kafka_log_end_offset", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "topic != '__consumer_offsets'"},
"groupBy": []any{
map[string]any{"name": "topic", "fieldContext": "attribute", "fieldDataType": "string"},
map[string]any{"name": "partition", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "kafka_consumer_committed_offset", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "topic != '__consumer_offsets'"},
"groupBy": []any{
map[string]any{"name": "topic", "fieldContext": "attribute", "fieldDataType": "string"},
map[string]any{"name": "partition", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "A - B",
"legend": "{{topic}}/{{partition}}",
},
},
},
},
"alertOnAbsent": true,
"absentFor": 15,
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "above",
"matchType": "all_the_times",
"target": 50,
"channels": []any{"slack-kafka-info"},
},
map[string]any{
"name": "critical",
"op": "above",
"matchType": "all_the_times",
"target": 200,
"channels": []any{"slack-kafka-alerts", "pagerduty-kafka"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"topic"},
"renotify": renotify("15m", "firing"),
},
"labels": map[string]any{"team": "data-platform"},
"annotations": map[string]any{
"description": "Consumer lag for {{$topic}} partition {{$partition}} is {{$value}}.",
"summary": "Kafka consumer lag",
},
},
},
{
Name: "notification_settings",
Summary: "Full notification settings (grouping, nodata renotify, grace period)",
Description: "Demonstrates the full notificationSettings surface: `groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay` gives newly-appearing series a grace period before firing, `renotify` re-alerts every 30m while firing OR while the alert is in nodata (missing data is treated as actionable), and `usePolicy: false` means channels come from the threshold entries rather than global routing policies. Set `usePolicy: true` to skip per-threshold channels and route via the org-level notification policy instead.",
Value: map[string]any{
"alert": "API 5xx error rate above 1%",
"alertType": "TRACES_BASED_ALERT",
"description": "Noise-controlled 5xx error rate alert with renotify on gaps",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name CONTAINS 'api' AND http.status_code >= 500"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name CONTAINS 'api'"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(A / B) * 100",
"legend": "{{service.name}} ({{deployment.environment}})",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 1,
"channels": []any{"slack-api-alerts", "pagerduty-oncall"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"service.name", "deployment.environment"},
"newGroupEvalDelay": "2m",
"usePolicy": false,
"renotify": renotify("30m", "firing", "nodata"),
},
"labels": map[string]any{"team": "platform"},
"annotations": map[string]any{
"description": "{{$service.name}} 5xx rate in {{$deployment.environment}} is {{$value}}%.",
"summary": "API service error rate elevated",
},
},
},
}
}

View File

@@ -1,32 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// TestPostableRuleExamplesValidate verifies every example payload returned by
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
// passes Validate(). If an example drifts from the runtime contract this
// breaks loudly so the spec doesn't ship invalid payloads to users.
func TestPostableRuleExamplesValidate(t *testing.T) {
for _, example := range postableRuleExamples() {
t.Run(example.Name, func(t *testing.T) {
raw, err := json.Marshal(example.Value)
if err != nil {
t.Fatalf("marshal example: %v", err)
}
var rule ruletypes.PostableRule
if err := json.Unmarshal(raw, &rule); err != nil {
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
}
if err := rule.Validate(); err != nil {
t.Fatalf("Validate: %v\npayload: %s", err, raw)
}
})
}
}

View File

@@ -114,11 +114,11 @@ type AlertCompositeQuery struct {
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true"`
CompareOperator CompareOperator `json:"op,omitzero"`
CompareOperator CompareOperator `json:"op" required:"true"`
Target *float64 `json:"target,omitempty"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor uint64 `json:"absentFor,omitempty"`
MatchType MatchType `json:"matchType,omitzero"`
MatchType MatchType `json:"matchType" required:"true"`
TargetUnit string `json:"targetUnit,omitempty"`
Algorithm string `json:"algorithm,omitempty"`
Seasonality Seasonality `json:"seasonality,omitzero"`

View File

@@ -50,13 +50,13 @@ const (
// PostableRule is used to create alerting rule from HTTP api.
type PostableRule struct {
AlertName string `json:"alert" required:"true"`
AlertType AlertType `json:"alertType" required:"true"`
AlertType AlertType `json:"alertType,omitempty"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType" required:"true"`
RuleType RuleType `json:"ruleType,omitzero" required:"true"`
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
Frequency valuer.TextDuration `json:"frequency,omitzero"`
RuleCondition *RuleCondition `json:"condition" required:"true"`
RuleCondition *RuleCondition `json:"condition,omitempty" required:"true"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
@@ -67,9 +67,9 @@ type PostableRule struct {
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version string `json:"version"`
Version string `json:"version,omitempty"`
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty"`
Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`