mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-25 02:21:12 +00:00
Compare commits
15 Commits
backup/thr
...
feat/anoma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44505f102b | ||
|
|
82944d93d5 | ||
|
|
c0259dca6f | ||
|
|
bf94fc507f | ||
|
|
7ac59c9681 | ||
|
|
037b40af2c | ||
|
|
b0008c4550 | ||
|
|
91148e678d | ||
|
|
e30211ffe9 | ||
|
|
75b1ced7bd | ||
|
|
7611b408c8 | ||
|
|
b98c33e44f | ||
|
|
cb93643b90 | ||
|
|
25ff5e7967 | ||
|
|
2298ec6243 |
@@ -7,11 +7,12 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
// The setup guide button only exists in the classic form.
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
|
||||
search: 'ruleType=anomaly_rule',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,7 +21,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useNavigationType: jest.fn(() => 'PUSH'),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/alerts/new',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
hash: '',
|
||||
state: null,
|
||||
})),
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
|
||||
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
|
||||
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'false';
|
||||
@@ -163,8 +163,9 @@ describe('CreateAlertRule', () => {
|
||||
return null;
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ function CreateRules(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isTypeSelectionMode) {
|
||||
logEvent('Alert: New alert data source selection page visited', {});
|
||||
void logEvent('Alert: New alert data source selection page visited', {});
|
||||
}
|
||||
}, [isTypeSelectionMode]);
|
||||
|
||||
@@ -100,10 +100,7 @@ function CreateRules(): JSX.Element {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
if (
|
||||
showClassicCreateAlertsPageFlag ||
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
if (showClassicCreateAlertsPageFlag) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useQuery } from 'react-query';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import classNames from 'classnames';
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { Activity, ChartLine } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
@@ -14,11 +16,14 @@ import Stepper from '../Stepper';
|
||||
import AlertThreshold from './AlertThreshold';
|
||||
import AnomalyThreshold from './AnomalyThreshold';
|
||||
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
|
||||
import { useAnomalyFunctionSync } from './useAnomalyFunctionSync';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
const { featureFlags } = useAppContext();
|
||||
useAnomalyFunctionSync();
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -30,9 +35,14 @@ function AlertCondition(): JSX.Element {
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
?.active || false;
|
||||
|
||||
// Anomaly rules stay editable even when the flag is off.
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -40,16 +50,15 @@ function AlertCondition(): JSX.Element {
|
||||
icon: <ChartLine size={14} data-testid="threshold-view" />,
|
||||
value: AlertTypes.METRICS_BASED_ALERT,
|
||||
},
|
||||
// Hide anomaly tab for now
|
||||
// ...(showMultipleTabs
|
||||
// ? [
|
||||
// {
|
||||
// label: 'Anomaly',
|
||||
// icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
// value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
...(showMultipleTabs
|
||||
? [
|
||||
{
|
||||
label: 'Anomaly',
|
||||
icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleAlertTypeChange = (value: AlertTypes): void => {
|
||||
|
||||
@@ -53,7 +53,7 @@ function AnomalyThreshold({
|
||||
const updateThreshold = (
|
||||
id: string,
|
||||
field: string,
|
||||
value: string | string[],
|
||||
value: number | string[],
|
||||
): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
@@ -112,7 +112,7 @@ function AnomalyThreshold({
|
||||
updateThreshold(
|
||||
thresholdState.thresholds[0].id,
|
||||
'thresholdValue',
|
||||
value.toString(),
|
||||
value,
|
||||
);
|
||||
}}
|
||||
options={deviationOptions}
|
||||
@@ -188,7 +188,7 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={ANOMALY_SEASONALITY_OPTIONS}
|
||||
/>
|
||||
{notificationSettings.routingPolicies ? (
|
||||
{!notificationSettings.routingPolicies ? (
|
||||
<>
|
||||
<Typography.Text
|
||||
data-testid="seasonality-text"
|
||||
@@ -198,12 +198,13 @@ function AnomalyThreshold({
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.thresholds[0].channels}
|
||||
data-testid="anomaly-notification-channel-select"
|
||||
onChange={(value): void =>
|
||||
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
|
||||
}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
value: channel.name,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
@@ -65,25 +67,23 @@ jest.mock('../AnomalyThreshold', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHandleSetQueryData = jest.fn();
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
const mockCurrentQuery = {
|
||||
dataSource: 'METRICS',
|
||||
queryName: 'A',
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'METRICS' }],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
currentQuery: {
|
||||
builder: { queryData: unknown[]; queryFormulas: unknown[] };
|
||||
dataSource: string;
|
||||
queryName: string;
|
||||
};
|
||||
redirectWithQueryBuilderData: () => void;
|
||||
} => ({
|
||||
currentQuery: {
|
||||
dataSource: 'METRICS',
|
||||
queryName: 'A',
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'METRICS' }],
|
||||
queryFormulas: [],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: mockCurrentQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -112,7 +112,14 @@ const renderAlertCondition = (
|
||||
);
|
||||
};
|
||||
|
||||
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
|
||||
|
||||
describe('AlertCondition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAppContextSpy.mockReturnValue(createAppContextMock('ADMIN'));
|
||||
});
|
||||
|
||||
it('renders the stepper with correct step number and label', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
|
||||
@@ -125,10 +132,9 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(
|
||||
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
// ).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify threshold tab is active by default
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -136,8 +142,7 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders threshold tab by default', () => {
|
||||
@@ -152,8 +157,7 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
it('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
@@ -167,8 +171,7 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Click on anomaly tab to switch to anomaly-based alert
|
||||
@@ -179,8 +182,7 @@ describe('AlertCondition', () => {
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('switches between threshold and anomaly tabs', () => {
|
||||
it('switches between threshold and anomaly tabs', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Initially shows threshold component
|
||||
@@ -205,8 +207,36 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('applies active tab styling correctly', () => {
|
||||
it('adds the anomaly function with the default z-score when switching to anomaly', () => {
|
||||
renderAlertCondition();
|
||||
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText(ANOMALY_TAB_TEXT));
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
functions: [
|
||||
{ name: 'anomaly', args: [], namedArgs: { z_score_threshold: 3 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the current query when switching between threshold and anomaly', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
fireEvent.click(screen.getByText(ANOMALY_TAB_TEXT));
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
mockCurrentQuery,
|
||||
{ alertType: AlertTypes.ANOMALY_BASED_ALERT, ruleType: 'anomaly_rule' },
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies active tab styling correctly', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -227,11 +257,20 @@ describe('AlertCondition', () => {
|
||||
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
|
||||
renderAlertCondition('METRIC_BASED_ALERT');
|
||||
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the anomaly tab for METRICS_BASED_ALERT when anomaly detection is off', () => {
|
||||
useAppContextSpy.mockReturnValue(
|
||||
createAppContextMock('ADMIN', { featureFlags: [] }),
|
||||
);
|
||||
renderAlertCondition('METRIC_BASED_ALERT');
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
|
||||
@@ -239,9 +278,8 @@ describe('AlertCondition', () => {
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import * as context from '../../context';
|
||||
import AnomalyThreshold from '../AnomalyThreshold';
|
||||
@@ -22,12 +23,11 @@ jest.mock('uplot', () => {
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
}),
|
||||
);
|
||||
const mockContextState = createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
});
|
||||
const useCreateAlertStateSpy = jest.spyOn(context, 'useCreateAlertState');
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@@ -52,10 +52,12 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const channels = [{ id: '1', name: 'slack-alerts' }] as Channels[];
|
||||
|
||||
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<AnomalyThreshold
|
||||
channels={[]}
|
||||
channels={channels}
|
||||
isLoadingChannels={false}
|
||||
isErrorChannels={false}
|
||||
refreshChannels={jest.fn()}
|
||||
@@ -65,8 +67,13 @@ const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
describe('AnomalyThreshold', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useCreateAlertStateSpy.mockReturnValue(mockContextState);
|
||||
});
|
||||
|
||||
const openSelect = (testId: string): void => {
|
||||
fireEvent.mouseDown(within(screen.getByTestId(testId)).getByRole('combobox'));
|
||||
};
|
||||
|
||||
it('renders the first condition sentence', () => {
|
||||
renderAnomalyThreshold();
|
||||
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
|
||||
@@ -92,4 +99,38 @@ describe('AnomalyThreshold', () => {
|
||||
expect(screen.getByTestId('seasonality-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stores the picked deviation as a number', () => {
|
||||
renderAnomalyThreshold();
|
||||
openSelect('threshold-value-select');
|
||||
fireEvent.click(screen.getByTitle('4'));
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ thresholdValue: 4 })],
|
||||
});
|
||||
});
|
||||
|
||||
it('picks channels by name when routing policies are off', () => {
|
||||
renderAnomalyThreshold();
|
||||
openSelect('anomaly-notification-channel-select');
|
||||
fireEvent.click(screen.getByTitle('slack-alerts'));
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ channels: ['slack-alerts'] })],
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the channel picker when routing policies are on', () => {
|
||||
useCreateAlertStateSpy.mockReturnValue({
|
||||
...mockContextState,
|
||||
notificationSettings: {
|
||||
...mockContextState.notificationSettings,
|
||||
routingPolicies: true,
|
||||
},
|
||||
});
|
||||
renderAnomalyThreshold();
|
||||
expect(
|
||||
screen.queryByTestId('anomaly-notification-channel-select'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
|
||||
import { syncAnomalyFunction } from '../utils';
|
||||
|
||||
const timeShift: QueryFunction = { name: 'timeShift', args: [{ value: 3600 }] };
|
||||
const anomaly = (zScoreThreshold: number): QueryFunction => ({
|
||||
name: 'anomaly',
|
||||
args: [],
|
||||
namedArgs: { z_score_threshold: zScoreThreshold },
|
||||
});
|
||||
|
||||
describe('syncAnomalyFunction', () => {
|
||||
it('adds the anomaly function after the existing functions', () => {
|
||||
expect(syncAnomalyFunction([timeShift], 3)).toStrictEqual([
|
||||
timeShift,
|
||||
anomaly(3),
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces a stale z-score and is idempotent', () => {
|
||||
const synced = syncAnomalyFunction([anomaly(3), timeShift], 5);
|
||||
expect(synced).toStrictEqual([timeShift, anomaly(5)]);
|
||||
expect(syncAnomalyFunction(synced, 5)).toStrictEqual(synced);
|
||||
});
|
||||
|
||||
it('removes the anomaly function when no z-score is given', () => {
|
||||
expect(syncAnomalyFunction([anomaly(3), timeShift], undefined)).toStrictEqual(
|
||||
[timeShift],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { syncAnomalyFunction } from './utils';
|
||||
|
||||
// The anomaly function's z-score only draws the preview bands; alerting uses the threshold target.
|
||||
export function useAnomalyFunctionSync(): void {
|
||||
const { alertType, thresholdState } = useCreateAlertState();
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
const zScoreThreshold =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? Number(thresholdState.thresholds[0]?.thresholdValue)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
currentQuery.builder.queryData.forEach((queryData, index) => {
|
||||
const currentFunctions = queryData.functions || [];
|
||||
const functions = syncAnomalyFunction(currentFunctions, zScoreThreshold);
|
||||
if (!isEqual(functions, currentFunctions)) {
|
||||
handleSetQueryData(index, { ...queryData, functions });
|
||||
}
|
||||
});
|
||||
}, [currentQuery.builder.queryData, zScoreThreshold, handleSetQueryData]);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -43,6 +44,25 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
return queryConfig[currentQuery.queryType]?.() || [];
|
||||
}
|
||||
|
||||
// Drops the anomaly function, or re-adds it last with the given z-score.
|
||||
export function syncAnomalyFunction(
|
||||
functions: QueryFunction[],
|
||||
zScoreThreshold: number | undefined,
|
||||
): QueryFunction[] {
|
||||
const otherFunctions = functions.filter(({ name }) => name !== 'anomaly');
|
||||
if (zScoreThreshold === undefined) {
|
||||
return otherFunctions;
|
||||
}
|
||||
return [
|
||||
...otherFunctions,
|
||||
{
|
||||
name: 'anomaly',
|
||||
args: [],
|
||||
namedArgs: { z_score_threshold: zScoreThreshold },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getCategoryByOptionId(id: string): string | undefined {
|
||||
const categories = getYAxisCategories(YAxisSource.ALERTS);
|
||||
return categories.find((category) =>
|
||||
|
||||
@@ -13,10 +13,7 @@ import APIError from 'types/api/error';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
validateCreateAlertState,
|
||||
} from './utils';
|
||||
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
import {
|
||||
@@ -84,7 +81,7 @@ function Footer(): JSX.Element {
|
||||
);
|
||||
|
||||
const handleTestNotification = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -117,11 +114,12 @@ function Footer(): JSX.Element {
|
||||
notificationSettings,
|
||||
currentQuery,
|
||||
testAlertRule,
|
||||
handleApiError,
|
||||
]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleSaveAlert = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -173,6 +171,7 @@ function Footer(): JSX.Element {
|
||||
createAlertRule,
|
||||
safeNavigate,
|
||||
handleApiError,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const isSavingAlertRule = isCreatingAlertRule || isUpdatingAlertRule;
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertThresholdOperator,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
TimeDuration,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -18,6 +21,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { BuildCreateAlertRulePayloadArgs } from '../types';
|
||||
import {
|
||||
buildCreateAlertRulePayload,
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
getAlertOnAbsentProps,
|
||||
getEnforceMinimumDatapointsProps,
|
||||
@@ -573,4 +577,65 @@ describe('Footer utils', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildCreateAlertRulePayload', () => {
|
||||
const mockCreateAlertContextState = createMockAlertContextState();
|
||||
const thresholdState = mockCreateAlertContextState.thresholdState;
|
||||
const anomalyArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
basicAlertState: mockCreateAlertContextState.alertState,
|
||||
thresholdState: {
|
||||
...thresholdState,
|
||||
evaluationWindow: TimeDuration.ONE_HOUR,
|
||||
seasonality: Seasonality.DAILY,
|
||||
thresholds: [
|
||||
{ ...thresholdState.thresholds[0], thresholdValue: 3, unit: 'ms' },
|
||||
],
|
||||
},
|
||||
advancedOptions: mockCreateAlertContextState.advancedOptions,
|
||||
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
|
||||
notificationSettings: mockCreateAlertContextState.notificationSettings,
|
||||
query: initialQueriesMap.metrics,
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
};
|
||||
|
||||
it('builds a v2alpha1 anomaly rule with a rolling window', () => {
|
||||
const props = buildCreateAlertRulePayload(anomalyArgs);
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
expect(props.schemaVersion).toBe('v2alpha1');
|
||||
expect(props.condition.algorithm).toBe('standard');
|
||||
expect(props.condition.seasonality).toBe('daily');
|
||||
expect(props.condition.thresholds?.spec[0]).toMatchObject({
|
||||
target: 3,
|
||||
targetUnit: '',
|
||||
});
|
||||
expect(props.evaluation).toStrictEqual({
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[AlertThresholdOperator.IS_BELOW, -3],
|
||||
['2', -3],
|
||||
[AlertThresholdOperator.IS_ABOVE, 3],
|
||||
[AlertThresholdOperator.ABOVE_BELOW, 3],
|
||||
])('signs the z-score target for operator %s', (operator, target) => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
thresholdState: { ...anomalyArgs.thresholdState, operator },
|
||||
});
|
||||
expect(props.condition.thresholds?.spec[0].target).toBe(target);
|
||||
expect(props.condition.thresholds?.spec[0].op).toBe(operator);
|
||||
});
|
||||
|
||||
it('builds a threshold rule for other alert types', () => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
});
|
||||
expect(props.ruleType).toBe('threshold_rule');
|
||||
expect(props.condition.seasonality).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
BasicThreshold,
|
||||
PostableAlertRuleV2,
|
||||
@@ -9,8 +10,10 @@ import {
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelope';
|
||||
|
||||
import { normalizeOperator } from '../context/conditionNormalizers';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertThresholdOperator,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from '../context/types';
|
||||
@@ -289,56 +292,51 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
}
|
||||
|
||||
// Build Create Anomaly Alert Rule Payload
|
||||
// TODO: Update this function before enabling anomaly alert rule creation
|
||||
export function buildCreateAnomalyAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
const {
|
||||
alertType,
|
||||
basicAlertState,
|
||||
query,
|
||||
notificationSettings,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
} = args;
|
||||
const { thresholdState, evaluationWindow, advancedOptions } = args;
|
||||
const payload = buildCreateThresholdAlertRulePayload(args);
|
||||
|
||||
const compositeQuery = compositeQueryToQueryEnvelope({
|
||||
builderQueries: {
|
||||
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
|
||||
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
|
||||
},
|
||||
promQueries: mapQueryDataToApi(query.promql, 'name').data,
|
||||
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
|
||||
queryType: query.queryType,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
unit: basicAlertState.yAxisUnit,
|
||||
});
|
||||
|
||||
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
|
||||
const enforceMinimumDatapointsProps =
|
||||
getEnforceMinimumDatapointsProps(advancedOptions);
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
const notificationSettingsProps =
|
||||
getNotificationSettingsProps(notificationSettings);
|
||||
// Targets are literal z-scores: "below N deviations" fires under -N.
|
||||
// A target unit would rescale the z-score, so it is always cleared.
|
||||
const sign =
|
||||
normalizeOperator(thresholdState.operator) === AlertThresholdOperator.IS_BELOW
|
||||
? -1
|
||||
: 1;
|
||||
const thresholds: BasicThreshold[] = (
|
||||
payload.condition.thresholds?.spec || []
|
||||
).map((threshold) => ({
|
||||
...threshold,
|
||||
target: sign * Math.abs(threshold.target),
|
||||
targetUnit: '',
|
||||
}));
|
||||
|
||||
return {
|
||||
alert: basicAlertState.name,
|
||||
...payload,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
alertType,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
condition: {
|
||||
compositeQuery,
|
||||
...alertOnAbsentProps,
|
||||
...enforceMinimumDatapointsProps,
|
||||
...payload.condition,
|
||||
thresholds: { kind: 'basic', spec: thresholds },
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
},
|
||||
labels: basicAlertState.labels,
|
||||
annotations: {
|
||||
description: notificationSettings.description,
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
source: window?.location.toString(),
|
||||
evaluation: getEvaluationProps(
|
||||
{
|
||||
...evaluationWindow,
|
||||
windowType: 'rolling',
|
||||
timeframe: thresholdState.evaluationWindow,
|
||||
},
|
||||
advancedOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
return args.alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? buildCreateAnomalyAlertRulePayload(args)
|
||||
: buildCreateThresholdAlertRulePayload(args);
|
||||
}
|
||||
|
||||
@@ -81,13 +81,15 @@ function QuerySection(): JSX.Element {
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
void queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const runQueryHandler = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
void queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
|
||||
]);
|
||||
// Reset the source param when the query is changed
|
||||
// Then manually run the query
|
||||
if (source === YAxisSource.DASHBOARDS && didQueryChange) {
|
||||
@@ -129,6 +131,11 @@ function QuerySection(): JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
const isAnomalyDetection = alertType === AlertTypes.ANOMALY_BASED_ALERT;
|
||||
const activeTab = isAnomalyDetection
|
||||
? AlertTypes.METRICS_BASED_ALERT
|
||||
: alertType;
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<Stepper stepNumber={1} label="Define the query" />
|
||||
@@ -144,7 +151,7 @@ function QuerySection(): JSX.Element {
|
||||
<Button
|
||||
key={tab.value}
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': alertType === tab.value,
|
||||
'active-tab': activeTab === tab.value,
|
||||
})}
|
||||
onClick={(): void => {
|
||||
setAlertType(tab.value as AlertTypes);
|
||||
@@ -165,6 +172,7 @@ function QuerySection(): JSX.Element {
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
isAnomalyDetection={isAnomalyDetection}
|
||||
key={currentQuery.queryType}
|
||||
ruleId=""
|
||||
hideTitle
|
||||
|
||||
@@ -61,10 +61,12 @@ jest.mock(
|
||||
alertType,
|
||||
panelType,
|
||||
setQueryCategory,
|
||||
isAnomalyDetection,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<div data-testid="query-section-component">
|
||||
<div data-testid="query-category">{queryCategory}</div>
|
||||
<div data-testid="is-anomaly-detection">{String(isAnomalyDetection)}</div>
|
||||
<div data-testid="alert-type">{alertType}</div>
|
||||
<div data-testid="panel-type">{panelType}</div>
|
||||
<button
|
||||
@@ -127,11 +129,11 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
},
|
||||
});
|
||||
const renderQuerySection = (): ReturnType<typeof render> =>
|
||||
const renderQuerySection = (search = ''): ReturnType<typeof render> =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<QuerySection />
|
||||
</CreateAlertProvider>
|
||||
@@ -201,6 +203,15 @@ describe('QuerySection', () => {
|
||||
expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS);
|
||||
});
|
||||
|
||||
it('keeps Metrics active and restricts query types for anomaly alerts', () => {
|
||||
renderQuerySection(`?${QueryParams.ruleType}=anomaly_rule`);
|
||||
|
||||
expect(screen.getByText(METRICS_TEXT).closest('button')).toHaveClass(
|
||||
ACTIVE_TAB_CLASS,
|
||||
);
|
||||
expect(screen.getByTestId('is-anomaly-detection')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('handles alert type change when clicking on different tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderQuerySection();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import { defaultPostableAlertRuleV2 } from '../constants';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import {
|
||||
getAdvancedOptionsStateFromAlertDef,
|
||||
getColorForThreshold,
|
||||
getAlertTypeFromAlertDef,
|
||||
getCreateAlertLocalStateFromAlertDef,
|
||||
getEvaluationWindowStateFromAlertDef,
|
||||
getNotificationSettingsStateFromAlertDef,
|
||||
@@ -271,7 +273,7 @@ describe('CreateAlertV2 utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef', () => {
|
||||
it('getThresholdStateFromAlertDef maps thresholds, query, operator and match type', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
annotations: {
|
||||
@@ -316,6 +318,76 @@ describe('CreateAlertV2 utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
|
||||
const anomalyDef: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
ruleType: 'anomaly_rule',
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target: -3,
|
||||
targetUnit: '',
|
||||
channels: ['email'],
|
||||
matchType: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
op: AlertThresholdOperator.IS_BELOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m' },
|
||||
},
|
||||
};
|
||||
|
||||
it('shows deviations unsigned and restores the anomaly settings', () => {
|
||||
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [{ thresholdValue: 3 }],
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
evaluationWindow: '1h0m0s',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps signed targets and the default window for threshold rules', () => {
|
||||
const state = getThresholdStateFromAlertDef({
|
||||
...anomalyDef,
|
||||
ruleType: 'threshold_rule',
|
||||
});
|
||||
expect(state.thresholds[0].thresholdValue).toBe(-3);
|
||||
expect(state.evaluationWindow).toBe('5m0s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertTypeFromAlertDef', () => {
|
||||
it('maps anomaly rules to the anomaly alert type', () => {
|
||||
expect(
|
||||
getAlertTypeFromAlertDef({
|
||||
...defaultPostableAlertRuleV2,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
ruleType: 'anomaly_rule',
|
||||
}),
|
||||
).toBe(AlertTypes.ANOMALY_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('keeps the stored alert type for other rules', () => {
|
||||
expect(
|
||||
getAlertTypeFromAlertDef({
|
||||
...defaultPostableAlertRuleV2,
|
||||
alertType: AlertTypes.LOGS_BASED_ALERT,
|
||||
ruleType: 'threshold_rule',
|
||||
}),
|
||||
).toBe(AlertTypes.LOGS_BASED_ALERT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef null channels', () => {
|
||||
it('falls back to an empty array so downstream consumers never see null', () => {
|
||||
const def: PostableAlertRuleV2 = {
|
||||
|
||||
@@ -97,6 +97,13 @@ describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('starts new anomaly alerts at 3 deviations', () => {
|
||||
renderWithSearch(`?ruleType=anomaly_rule`);
|
||||
|
||||
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('applies the meter evaluation window when the preset is declared', () => {
|
||||
// Ingestion-style prefill: explicit in_total + meter preset.
|
||||
renderWithSearch(
|
||||
|
||||
@@ -35,6 +35,11 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||
color: Color.BG_SAKURA_500,
|
||||
};
|
||||
|
||||
export const INITIAL_ANOMALY_THRESHOLD: Threshold = {
|
||||
...INITIAL_CRITICAL_THRESHOLD,
|
||||
thresholdValue: 3,
|
||||
};
|
||||
|
||||
export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'warning',
|
||||
|
||||
@@ -22,7 +22,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { INITIAL_CREATE_ALERT_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ANOMALY_THRESHOLD,
|
||||
INITIAL_CREATE_ALERT_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
EvaluationWindowPreset,
|
||||
resolveUrlAlertPrefill,
|
||||
@@ -143,12 +146,20 @@ export function CreateAlertProvider(
|
||||
|
||||
const handleAlertTypeChange = useCallback(
|
||||
(value: AlertTypes): void => {
|
||||
const queryToRedirect = buildInitialAlertDef(value);
|
||||
const currentQueryToRedirect = mapQueryDataFromApi(
|
||||
queryToRedirect.condition.compositeQuery,
|
||||
);
|
||||
// Threshold and anomaly alerts share the metric query, so switching keeps it.
|
||||
const metricAlertTypes = [
|
||||
AlertTypes.METRICS_BASED_ALERT,
|
||||
AlertTypes.ANOMALY_BASED_ALERT,
|
||||
];
|
||||
const keepsQuery =
|
||||
value !== alertType &&
|
||||
metricAlertTypes.includes(value) &&
|
||||
metricAlertTypes.includes(alertType);
|
||||
const queryToRedirect = keepsQuery
|
||||
? currentQuery
|
||||
: mapQueryDataFromApi(buildInitialAlertDef(value).condition.compositeQuery);
|
||||
redirectWithQueryBuilderData(
|
||||
currentQueryToRedirect,
|
||||
queryToRedirect,
|
||||
{
|
||||
[QueryParams.alertType]: value,
|
||||
[QueryParams.ruleType]:
|
||||
@@ -161,7 +172,7 @@ export function CreateAlertProvider(
|
||||
);
|
||||
setAlertType(value);
|
||||
},
|
||||
[redirectWithQueryBuilderData],
|
||||
[alertType, currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const ruleNameAppliedRef = useRef(false);
|
||||
@@ -183,6 +194,16 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
|
||||
if (alertType === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [INITIAL_ANOMALY_THRESHOLD],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (urlPrefill.thresholds) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
|
||||
@@ -124,6 +124,12 @@ export const alertThresholdReducer = (
|
||||
return { ...state, operator: action.payload };
|
||||
case 'SET_MATCH_TYPE':
|
||||
return { ...state, matchType: action.payload };
|
||||
case 'SET_EVALUATION_WINDOW':
|
||||
return { ...state, evaluationWindow: action.payload };
|
||||
case 'SET_ALGORITHM':
|
||||
return { ...state, algorithm: action.payload };
|
||||
case 'SET_SEASONALITY':
|
||||
return { ...state, seasonality: action.payload };
|
||||
case 'SET_THRESHOLDS':
|
||||
return { ...state, thresholds: action.payload };
|
||||
case 'RESET':
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Spin } from 'antd';
|
||||
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { getRandomColor } from 'container/ExplorerOptions/utils';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -248,13 +250,18 @@ export {
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
const isAnomalyRule =
|
||||
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
return {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds:
|
||||
alertDef.condition.thresholds?.spec.map((threshold) => ({
|
||||
id: v4(),
|
||||
label: threshold.name,
|
||||
thresholdValue: threshold.target,
|
||||
// anomaly targets are signed z-scores; the operator carries the direction
|
||||
thresholdValue: isAnomalyRule
|
||||
? Math.abs(threshold.target)
|
||||
: threshold.target,
|
||||
recoveryThresholdValue: null,
|
||||
unit: threshold.targetUnit,
|
||||
color: getColorForThreshold(threshold.name),
|
||||
@@ -268,9 +275,26 @@ export function getThresholdStateFromAlertDef(
|
||||
matchType:
|
||||
alertDef.condition.thresholds?.spec[0].matchType ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
algorithm:
|
||||
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
|
||||
seasonality:
|
||||
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
|
||||
evaluationWindow:
|
||||
(isAnomalyRule && alertDef.evaluation?.spec?.evalWindow) ||
|
||||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
|
||||
};
|
||||
}
|
||||
|
||||
// Anomaly rules are stored as metric alerts; only the rule type tells them apart.
|
||||
export function getAlertTypeFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): AlertTypes {
|
||||
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
return alertDef?.alertType as AlertTypes;
|
||||
}
|
||||
|
||||
export function getCreateAlertLocalStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): CreateAlertState {
|
||||
|
||||
@@ -10,11 +10,13 @@ import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
|
||||
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
|
||||
import {
|
||||
getAlertTypeFromAlertDef,
|
||||
getCreateAlertLocalStateFromAlertDef,
|
||||
} from 'container/CreateAlertV2/utils';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
@@ -74,7 +76,7 @@ function AlertDetails(): JSX.Element {
|
||||
|
||||
const handleTabChange = (route: string): void => {
|
||||
if (route === ROUTES.ALERT_HISTORY) {
|
||||
logEvent('Alert History tab: Visited', { ruleId });
|
||||
void logEvent('Alert History tab: Visited', { ruleId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,7 +91,7 @@ function AlertDetails(): JSX.Element {
|
||||
<CreateAlertProvider
|
||||
ruleId={ruleId || ''}
|
||||
isEditMode
|
||||
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
|
||||
initialAlertType={getAlertTypeFromAlertDef(alertRuleDetails)}
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PostableAlertRuleV2 {
|
||||
absentFor?: number;
|
||||
requireMinPoints?: boolean;
|
||||
requiredNumPoints?: number;
|
||||
algorithm?: string;
|
||||
seasonality?: string;
|
||||
};
|
||||
evaluation?: {
|
||||
kind?: 'rolling' | 'cumulative';
|
||||
|
||||
Reference in New Issue
Block a user