diff --git a/frontend/src/api/alerts/createAlertRule.ts b/frontend/src/api/alerts/createAlertRule.ts new file mode 100644 index 000000000000..f993a244cfed --- /dev/null +++ b/frontend/src/api/alerts/createAlertRule.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + AlertRuleV2, + PostableAlertRuleV2, +} from 'types/api/alerts/alertTypesV2'; + +export interface CreateAlertRuleResponse { + data: AlertRuleV2; + status: string; +} + +const createAlertRule = async ( + props: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.post(`/rules`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default createAlertRule; diff --git a/frontend/src/api/alerts/testAlertRule.ts b/frontend/src/api/alerts/testAlertRule.ts new file mode 100644 index 000000000000..6b2502f325cc --- /dev/null +++ b/frontend/src/api/alerts/testAlertRule.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; + +export interface TestAlertRuleResponse { + data: { + alertCount: number; + message: string; + }; + status: string; +} + +const testAlertRule = async ( + props: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.post(`/testRule`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default testAlertRule; diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx index 79919950a847..89fc094584d9 100644 --- a/frontend/src/container/CreateAlertRule/index.tsx +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -2,6 +2,8 @@ import { Form, Row } from 'antd'; import logEvent from 'api/common/logEvent'; import { ENTITY_VERSION_V5 } from 'constants/app'; import { QueryParams } from 'constants/query'; +import CreateAlertV2 from 'container/CreateAlertV2'; +import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils'; import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; @@ -125,6 +127,15 @@ function CreateRules(): JSX.Element { ); } + const showNewCreateAlertsPageFlag = showNewCreateAlertsPage(); + + if ( + showNewCreateAlertsPageFlag && + alertType !== AlertTypes.ANOMALY_BASED_ALERT + ) { + return ; + } + return ( , APIError>(['getChannels'], { + queryFn: () => getAllChannels(), + }); + const channels = data?.data || []; + const showMultipleTabs = alertType === AlertTypes.ANOMALY_BASED_ALERT || alertType === AlertTypes.METRICS_BASED_ALERT; @@ -27,15 +42,16 @@ function AlertCondition(): JSX.Element { icon: , value: AlertTypes.METRICS_BASED_ALERT, }, - ...(showMultipleTabs - ? [ - { - label: 'Anomaly', - icon: , - value: AlertTypes.ANOMALY_BASED_ALERT, - }, - ] - : []), + // Hide anomaly tab for now + // ...(showMultipleTabs + // ? [ + // { + // label: 'Anomaly', + // icon: , + // value: AlertTypes.ANOMALY_BASED_ALERT, + // }, + // ] + // : []), ]; const handleAlertTypeChange = (value: AlertTypes): void => { @@ -76,8 +92,22 @@ function AlertCondition(): JSX.Element { ))} - {alertType !== AlertTypes.ANOMALY_BASED_ALERT && } - {alertType === AlertTypes.ANOMALY_BASED_ALERT && } + {alertType !== AlertTypes.ANOMALY_BASED_ALERT && ( + + )} + {alertType === AlertTypes.ANOMALY_BASED_ALERT && ( + + )} {showCondensedLayoutFlag ? (
diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx index ca1934a774a6..c2d57e4c3829 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx @@ -1,14 +1,10 @@ import './styles.scss'; +import '../EvaluationSettings/styles.scss'; -import { Button, Select, Typography } from 'antd'; -import getAllChannels from 'api/channels/getAll'; +import { Button, Select, Tooltip, Typography } from 'antd'; import classNames from 'classnames'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { Plus } from 'lucide-react'; -import { useQuery } from 'react-query'; -import { SuccessResponseV2 } from 'types/api'; -import { Channels } from 'types/api/channels/getAll'; -import APIError from 'types/api/error'; import { useCreateAlertState } from '../context'; import { @@ -21,27 +17,30 @@ import { import EvaluationSettings from '../EvaluationSettings/EvaluationSettings'; import { showCondensedLayout } from '../utils'; import ThresholdItem from './ThresholdItem'; -import { UpdateThreshold } from './types'; +import { AnomalyAndThresholdProps, UpdateThreshold } from './types'; import { getCategoryByOptionId, getCategorySelectOptionByName, + getMatchTypeTooltip, getQueryNames, + RoutingPolicyBanner, } from './utils'; -function AlertThreshold(): JSX.Element { +function AlertThreshold({ + channels, + isLoadingChannels, + isErrorChannels, + refreshChannels, +}: AnomalyAndThresholdProps): JSX.Element { const { alertState, thresholdState, setThresholdState, + notificationSettings, + setNotificationSettings, } = useCreateAlertState(); - const { data, isLoading: isLoadingChannels } = useQuery< - SuccessResponseV2, - APIError - >(['getChannels'], { - queryFn: () => getAllChannels(), - }); + const showCondensedLayoutFlag = showCondensedLayout(); - const channels = data?.data || []; const { currentQuery } = useQueryBuilder(); @@ -85,6 +84,65 @@ function AlertThreshold(): JSX.Element { }); }; + const onTooltipOpenChange = (open: boolean): void => { + // Stop propagation of click events on tooltip text to dropdown + if (open) { + setTimeout(() => { + const tooltipElement = document.querySelector( + '.copyable-tooltip .ant-tooltip-inner', + ); + if (tooltipElement) { + tooltipElement.addEventListener( + 'click', + (e) => { + e.stopPropagation(); + e.preventDefault(); + }, + true, + ); + tooltipElement.addEventListener( + 'mousedown', + (e) => { + e.stopPropagation(); + e.preventDefault(); + }, + true, + ); + } + }, 0); + } + }; + + const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map( + (option) => ({ + ...option, + label: ( + + {option.label} + + ), + }), + ); + const evaluationWindowContext = showCondensedLayoutFlag ? ( ) : ( @@ -114,8 +172,7 @@ function AlertThreshold(): JSX.Element { style={{ width: 80 }} options={queryNames} /> -
-
+ is + updateThreshold(thresholdState.thresholds[0].id, 'channels', value) + } + style={{ width: 350 }} + options={channels.map((channel) => ({ + value: channel.id, + label: channel.name, + }))} + mode="multiple" + placeholder="Select notification channels" + showSearch + maxTagCount={2} + maxTagPlaceholder={(omittedValues): string => + `+${omittedValues.length} more` + } + maxTagTextLength={10} + filterOption={(input, option): boolean => + option?.label?.toLowerCase().includes(input.toLowerCase()) || false + } + status={isErrorChannels ? 'error' : undefined} + disabled={isLoadingChannels} + notFoundContent={ + + } + /> + + ) : ( + + seasonality + + )}
+ ); } diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx index 1d59fddbeb02..836c4761d822 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx @@ -1,8 +1,12 @@ -import { Button, Input, Select, Space, Tooltip, Typography } from 'antd'; -import { ChartLine, CircleX } from 'lucide-react'; +import { Button, Input, Select, Tooltip, Typography } from 'antd'; +import { CircleX, Trash } from 'lucide-react'; +import { useAppContext } from 'providers/App/App'; import { useMemo, useState } from 'react'; +import { useCreateAlertState } from '../context'; +import { AlertThresholdOperator } from '../context/types'; import { ThresholdItemProps } from './types'; +import { NotificationChannelsNotFoundContent } from './utils'; function ThresholdItem({ threshold, @@ -11,7 +15,12 @@ function ThresholdItem({ showRemoveButton, channels, units, + isErrorChannels, + refreshChannels, + isLoadingChannels, }: ThresholdItemProps): JSX.Element { + const { user } = useAppContext(); + const { thresholdState, notificationSettings } = useCreateAlertState(); const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false); const yAxisUnitSelect = useMemo(() => { @@ -45,6 +54,31 @@ function ThresholdItem({ return component; }, [units, threshold.unit, updateThreshold, threshold.id]); + const getOperatorSymbol = (): string => { + switch (thresholdState.operator) { + case AlertThresholdOperator.IS_ABOVE: + return '>'; + case AlertThresholdOperator.IS_BELOW: + return '<'; + case AlertThresholdOperator.IS_EQUAL_TO: + return '='; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return '!='; + default: + return ''; + } + }; + + // const addRecoveryThreshold = (): void => { + // setShowRecoveryThreshold(true); + // updateThreshold(threshold.id, 'recoveryThresholdValue', 0); + // }; + + const removeRecoveryThreshold = (): void => { + setShowRecoveryThreshold(false); + updateThreshold(threshold.id, 'recoveryThresholdValue', null); + }; + return (
@@ -54,80 +88,111 @@ function ThresholdItem({ style={{ backgroundColor: threshold.color }} />
- -
- - - updateThreshold(threshold.id, 'label', e.target.value) - } - style={{ width: 260 }} - /> - - updateThreshold(threshold.id, 'thresholdValue', e.target.value) - } - style={{ width: 210 }} - /> - {yAxisUnitSelect} - -
- to - + updateThreshold(threshold.id, 'label', e.target.value) } - style={{ width: 260 }} - options={channels.map((channel) => ({ - value: channel.id, - label: channel.name, - }))} - mode="multiple" - placeholder="Select notification channels" + style={{ width: 200 }} /> + on value + + {getOperatorSymbol()} + + + updateThreshold(threshold.id, 'thresholdValue', e.target.value) + } + style={{ width: 100 }} + type="number" + /> + {yAxisUnitSelect} + {!notificationSettings.routingPolicies && ( + <> + send to + + updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value) + } + style={{ width: 100 }} + type="number" + /> + +
- {showRecoveryThreshold && ( - - - - updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value) - } - style={{ width: 210 }} - /> - - )} ); } diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx index 7823616a10db..fd5e8bd10e55 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router-dom'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; import { CreateAlertProvider } from '../../context'; import AlertCondition from '../AlertCondition'; @@ -105,7 +106,7 @@ const renderAlertCondition = ( return render( - + @@ -126,9 +127,10 @@ describe('AlertCondition', () => { // Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component) expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument(); - expect( - screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), - ).not.toBeInTheDocument(); + // TODO: uncomment this when anomaly tab is implemented + // expect( + // screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), + // ).not.toBeInTheDocument(); // Verify threshold tab is active by default const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT); @@ -136,7 +138,8 @@ describe('AlertCondition', () => { // Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs) expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); - expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + // TODO: uncomment this when anomaly tab is implemented + // expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); }); it('renders threshold tab by default', () => { @@ -151,7 +154,8 @@ describe('AlertCondition', () => { ).not.toBeInTheDocument(); }); - it('renders anomaly tab when alert type supports multiple tabs', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('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(); @@ -165,7 +169,8 @@ describe('AlertCondition', () => { ).not.toBeInTheDocument(); }); - it('shows AnomalyThreshold component when alert type is anomaly based', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => { renderAlertCondition(); // Click on anomaly tab to switch to anomaly-based alert @@ -176,7 +181,8 @@ describe('AlertCondition', () => { expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument(); }); - it('switches between threshold and anomaly tabs', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('switches between threshold and anomaly tabs', () => { renderAlertCondition(); // Initially shows threshold component @@ -201,7 +207,8 @@ describe('AlertCondition', () => { ).not.toBeInTheDocument(); }); - it('applies active tab styling correctly', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('applies active tab styling correctly', () => { renderAlertCondition(); const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT); @@ -222,21 +229,21 @@ describe('AlertCondition', () => { it('shows multiple tabs for METRICS_BASED_ALERT', () => { renderAlertCondition('METRIC_BASED_ALERT'); - // Both tabs should be visible + // 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('shows multiple tabs for ANOMALY_BASED_ALERT', () => { renderAlertCondition('ANOMALY_BASED_ALERT'); - // Both tabs should be visible expect(screen.getByText(THRESHOLD_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(); + // TODO: uncomment this when anomaly tab is implemented + // expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + // expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); }); it('shows only threshold tab for LOGS_BASED_ALERT', () => { diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx index dd4b6385f343..78cfadcb5bbe 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx @@ -3,11 +3,23 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router-dom'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; import { Channels } from 'types/api/channels/getAll'; import { CreateAlertProvider } from '../../context'; import AlertThreshold from '../AlertThreshold'; +const mockChannels: Channels[] = []; +const mockRefreshChannels = jest.fn(); +const mockIsLoadingChannels = false; +const mockIsErrorChannels = false; +const mockProps = { + channels: mockChannels, + isLoadingChannels: mockIsLoadingChannels, + isErrorChannels: mockIsErrorChannels, + refreshChannels: mockRefreshChannels, +}; + jest.mock('uplot', () => { const paths = { spline: jest.fn(), @@ -99,7 +111,7 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({ const TEST_STRINGS = { ADD_THRESHOLD: 'Add Threshold', AT_LEAST_ONCE: 'AT LEAST ONCE', - IS_ABOVE: 'IS ABOVE', + IS_ABOVE: 'ABOVE', } as const; const createTestQueryClient = (): QueryClient => @@ -116,8 +128,8 @@ const renderAlertThreshold = (): ReturnType => { return render( - - + + , @@ -125,7 +137,10 @@ const renderAlertThreshold = (): ReturnType => { }; const verifySelectRenders = (title: string): void => { - const select = screen.getByTitle(title); + let select = screen.queryByTitle(title); + if (!select) { + select = screen.getByText(title); + } expect(select).toBeInTheDocument(); }; diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx index 6729519830f5..1e2a6e982684 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx @@ -1,14 +1,15 @@ /* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { render, screen } from '@testing-library/react'; -import { - INITIAL_ALERT_STATE, - INITIAL_ALERT_THRESHOLD_STATE, -} from 'container/CreateAlertV2/context/constants'; +import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils'; +import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils'; +import * as appHooks from 'providers/App/App'; import * as context from '../../context'; import AnomalyThreshold from '../AnomalyThreshold'; +jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState()); + jest.mock('uplot', () => { const paths = { spline: jest.fn(), @@ -23,12 +24,12 @@ jest.mock('uplot', () => { const mockSetAlertState = jest.fn(); const mockSetThresholdState = jest.fn(); -jest.spyOn(context, 'useCreateAlertState').mockReturnValue({ - alertState: INITIAL_ALERT_STATE, - setAlertState: mockSetAlertState, - thresholdState: INITIAL_ALERT_THRESHOLD_STATE, - setThresholdState: mockSetThresholdState, -} as any); +jest.spyOn(context, 'useCreateAlertState').mockReturnValue( + createMockAlertContextState({ + setThresholdState: mockSetThresholdState, + setAlertState: mockSetAlertState, + }), +); // Mock useQueryBuilder hook jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ @@ -54,7 +55,14 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ })); const renderAnomalyThreshold = (): ReturnType => - render(); + render( + , + ); describe('AnomalyThreshold', () => { beforeEach(() => { diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx index 01fcbf97ce92..17300be43241 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx @@ -2,15 +2,37 @@ /* eslint-disable react/jsx-props-no-spreading */ import { fireEvent, render, screen } from '@testing-library/react'; import { DefaultOptionType } from 'antd/es/select'; +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 ThresholdItem from '../ThresholdItem'; import { ThresholdItemProps } from '../types'; -// Mock the enableRecoveryThreshold utility -jest.mock('../../utils', () => ({ - enableRecoveryThreshold: jest.fn(() => true), -})); +jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState()); + +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock: any = jest.fn(() => ({ + paths, + })); + uplotMock.paths = paths; + return uplotMock; +}); + +const mockSetAlertState = jest.fn(); +const mockSetThresholdState = jest.fn(); +jest.spyOn(context, 'useCreateAlertState').mockReturnValue( + createMockAlertContextState({ + setThresholdState: mockSetThresholdState, + setAlertState: mockSetAlertState, + }), +); const TEST_CONSTANTS = { THRESHOLD_ID: 'test-threshold-1', @@ -21,6 +43,7 @@ const TEST_CONSTANTS = { CHANNEL_2: 'channel-2', CHANNEL_3: 'channel-3', EMAIL_CHANNEL_NAME: 'Email Channel', + EMAIL_CHANNEL_TRUNCATED: 'Email Chan...', ENTER_THRESHOLD_NAME: 'Enter threshold name', ENTER_THRESHOLD_VALUE: 'Enter threshold value', ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value', @@ -59,6 +82,8 @@ const defaultProps: ThresholdItemProps = { channels: mockChannels, isLoadingChannels: false, units: mockUnits, + isErrorChannels: false, + refreshChannels: jest.fn(), }; const renderThresholdItem = ( @@ -77,10 +102,11 @@ const verifySelectorWidth = ( expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`); }; -const showRecoveryThreshold = (): void => { - const recoveryButton = screen.getByRole('button', { name: '' }); - fireEvent.click(recoveryButton); -}; +// TODO: Unskip this when recovery threshold is implemented +// const showRecoveryThreshold = (): void => { +// const recoveryButton = screen.getByRole('button', { name: '' }); +// fireEvent.click(recoveryButton); +// }; const verifyComponentRendersWithLoading = (): void => { expect( @@ -122,7 +148,7 @@ describe('ThresholdItem', () => { const valueInput = screen.getByPlaceholderText( TEST_CONSTANTS.ENTER_THRESHOLD_VALUE, ); - expect(valueInput).toHaveValue('100'); + expect(valueInput).toHaveValue(100); }); it('renders unit selector with correct value', () => { @@ -132,15 +158,6 @@ describe('ThresholdItem', () => { expect(screen.getByText('Bytes')).toBeInTheDocument(); }); - it('renders channels selector with correct value', () => { - renderThresholdItem(); - - // Check for the channels selector by looking for the displayed text - expect( - screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), - ).toBeInTheDocument(); - }); - it('updates threshold label when label input changes', () => { const updateThreshold = jest.fn(); renderThresholdItem({ updateThreshold }); @@ -212,38 +229,31 @@ describe('ThresholdItem', () => { // The remove button is the second button (with circle-x icon) const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(2); // Recovery button + remove button + expect(buttons).toHaveLength(1); // remove button }); it('does not show remove button when showRemoveButton is false', () => { renderThresholdItem({ showRemoveButton: false }); - // Only the recovery button should be present - const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(1); // Only recovery button + // No buttons should be present + const buttons = screen.queryAllByRole('button'); + expect(buttons).toHaveLength(0); }); it('calls removeThreshold when remove button is clicked', () => { const removeThreshold = jest.fn(); renderThresholdItem({ showRemoveButton: true, removeThreshold }); - // The remove button is the second button (with circle-x icon) + // The remove button is the first button (with circle-x icon) const buttons = screen.getAllByRole('button'); - const removeButton = buttons[1]; // Second button is the remove button + const removeButton = buttons[0]; fireEvent.click(removeButton); expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID); }); - it('shows recovery threshold button when recovery threshold is enabled', () => { - renderThresholdItem(); - - // The recovery button is the first button (with chart-line icon) - const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(1); // Recovery button - }); - - it('shows recovery threshold inputs when recovery button is clicked', () => { + // TODO: Unskip this when recovery threshold is implemented + it.skip('shows recovery threshold inputs when recovery button is clicked', () => { renderThresholdItem(); // The recovery button is the first button (with chart-line icon) @@ -251,13 +261,16 @@ describe('ThresholdItem', () => { const recoveryButton = buttons[0]; // First button is the recovery button fireEvent.click(recoveryButton); - expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Enter recovery threshold value'), + ).toBeInTheDocument(); expect( screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE), ).toBeInTheDocument(); }); - it('updates recovery threshold value when input changes', () => { + // TODO: Unskip this when recovery threshold is implemented + it.skip('updates recovery threshold value when input changes', () => { const updateThreshold = jest.fn(); renderThresholdItem({ updateThreshold }); @@ -290,22 +303,6 @@ describe('ThresholdItem', () => { verifyUnitSelectorDisabled(); }); - it('renders channels as multiple select options', () => { - renderThresholdItem(); - - // Check that channels are rendered as multiple select - expect( - screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), - ).toBeInTheDocument(); - - // Should be able to select multiple channels - const channelSelectors = screen.getAllByRole('combobox'); - const channelSelector = channelSelectors[1]; // Second combobox is the channels selector - fireEvent.change(channelSelector, { - target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] }, - }); - }); - it('handles empty threshold values correctly', () => { const emptyThreshold = { ...mockThreshold, @@ -318,7 +315,7 @@ describe('ThresholdItem', () => { renderThresholdItem({ threshold: emptyThreshold }); expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue(''); - expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0'); + expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0); }); it('renders with correct input widths', () => { @@ -331,13 +328,13 @@ describe('ThresholdItem', () => { TEST_CONSTANTS.ENTER_THRESHOLD_VALUE, ); - expect(labelInput).toHaveStyle('width: 260px'); - expect(valueInput).toHaveStyle('width: 210px'); + expect(labelInput).toHaveStyle('width: 200px'); + expect(valueInput).toHaveStyle('width: 100px'); }); it('renders channels selector with correct width', () => { renderThresholdItem(); - verifySelectorWidth(1, '260px'); + verifySelectorWidth(1, '350px'); }); it('renders unit selector with correct width', () => { @@ -350,37 +347,14 @@ describe('ThresholdItem', () => { verifyComponentRendersWithLoading(); }); - it('renders recovery threshold with correct initial value', () => { + it.skip('renders recovery threshold with correct initial value', () => { renderThresholdItem(); - showRecoveryThreshold(); + // showRecoveryThreshold(); const recoveryValueInput = screen.getByPlaceholderText( TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE, ); - expect(recoveryValueInput).toHaveValue('80'); - }); - - it('renders recovery threshold label as disabled', () => { - renderThresholdItem(); - showRecoveryThreshold(); - - const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold'); - expect(recoveryLabelInput).toBeDisabled(); - }); - - it('renders correct channel options', () => { - renderThresholdItem(); - - // Check that channels are rendered - expect( - screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), - ).toBeInTheDocument(); - - // Should be able to select different channels - const channelSelectors = screen.getAllByRole('combobox'); - const channelSelector = channelSelectors[1]; // Second combobox is the channels selector - fireEvent.change(channelSelector, { target: { value: 'channel-2' } }); - expect(screen.getByText('Slack Channel')).toBeInTheDocument(); + expect(recoveryValueInput).toHaveValue(80); }); it('handles threshold without channels', () => { diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss index 8e9fc6e223cc..d8ef5bd295d9 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss +++ b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss @@ -67,7 +67,7 @@ padding-right: 72px; background-color: var(--bg-ink-500); border: 1px solid var(--bg-slate-400); - width: fit-content; + width: 100%; .alert-condition-sentences { display: flex; @@ -90,7 +90,7 @@ } .ant-select { - width: 240px !important; + width: 240px; .ant-select-selector { background-color: var(--bg-ink-300); @@ -148,6 +148,7 @@ display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; .ant-input { background-color: var(--bg-ink-400); @@ -277,6 +278,29 @@ } } } + + .routing-policies-info-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 16px; + background-color: #4568dc1a; + border: 1px solid var(--bg-robin-500); + padding: 8px 16px; + + .ant-typography { + color: var(--bg-robin-500); + } + } +} + +.anomaly-threshold-container { + .ant-select { + .ant-select-selector { + min-width: 150px; + } + } } .condensed-alert-threshold-container, @@ -293,7 +317,8 @@ .ant-btn { display: flex; align-items: center; - width: 240px; + min-width: 240px; + width: auto; justify-content: space-between; background-color: var(--bg-ink-300); border: 1px solid var(--bg-slate-400); @@ -301,6 +326,7 @@ .evaluate-alert-conditions-button-left { color: var(--bg-vanilla-400); font-size: 12px; + flex-shrink: 0; } .evaluate-alert-conditions-button-right { @@ -308,6 +334,7 @@ align-items: center; color: var(--bg-vanilla-400); gap: 8px; + flex-shrink: 0; .evaluate-alert-conditions-button-right-text { font-size: 12px; @@ -318,3 +345,229 @@ } } } + +.lightMode { + .alert-condition-container { + .alert-condition { + .alert-condition-tabs { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-300); + + .explorer-view-option { + border-left: 0.5px solid var(--bg-vanilla-300); + border-bottom: 0.5px solid var(--bg-vanilla-300); + + &.active-tab { + background-color: var(--bg-vanilla-100); + + &:hover { + background-color: var(--bg-vanilla-100) !important; + } + } + + &:disabled { + background-color: var(--bg-vanilla-300); + } + + &:hover { + color: var(--bg-ink-400); + } + } + } + } + } + + .alert-threshold-container, + .anomaly-threshold-container { + background-color: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300); + + .alert-condition-sentences { + .alert-condition-sentence { + .sentence-text { + color: var(--text-ink-400); + } + + .ant-select { + .ant-select-selector { + background-color: var(--bg-vanilla-300); + border: 1px solid var(--bg-vanilla-300); + color: var(--text-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + + .ant-select-selection-item { + color: var(--bg-ink-400); + } + + .ant-select-arrow { + color: var(--bg-ink-400); + } + } + } + } + + .thresholds-section { + .threshold-item { + .threshold-row { + .threshold-controls { + .threshold-inputs { + display: flex; + align-items: center; + gap: 8px; + } + + .ant-input { + background-color: var(--bg-vanilla-200); + border: 1px solid var(--bg-vanilla-300); + color: var(--bg-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + + .ant-select { + .ant-select-selector { + background-color: var(--bg-vanilla-200); + border: 1px solid var(--bg-vanilla-300); + color: var(--bg-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + + .ant-select-selection-item { + color: var(--bg-ink-400); + } + + .ant-select-arrow { + color: var(--bg-ink-400); + } + } + + .icon-btn { + color: var(--bg-ink-400); + border: 1px solid var(--bg-vanilla-300); + } + } + } + + .recovery-threshold-input-group { + .recovery-threshold-btn { + color: var(--bg-ink-400); + background-color: var(--bg-vanilla-200) !important; + border: 1px solid var(--bg-vanilla-300); + } + + .ant-input { + background-color: var(--bg-vanilla-200); + border: 1px solid var(--bg-vanilla-300); + color: var(--bg-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + } + + .add-threshold-btn { + border: 1px dashed var(--bg-vanilla-300); + color: var(--bg-ink-300); + + &:hover { + border-color: var(--bg-ink-300); + color: var(--bg-ink-400); + } + } + } + } + } + + .condensed-evaluation-settings-container { + .ant-btn { + background-color: var(--bg-vanilla-300); + border: 1px solid var(--bg-vanilla-300); + min-width: 240px; + width: auto; + + .evaluate-alert-conditions-button-left { + color: var(--bg-ink-400); + flex-shrink: 0; + } + + .evaluate-alert-conditions-button-right { + color: var(--bg-ink-400); + flex-shrink: 0; + + .evaluate-alert-conditions-button-right-text { + background-color: var(--bg-vanilla-300); + } + } + } + } +} + +.highlighted-text { + font-weight: bold; + color: var(--bg-robin-400); + margin: 0 4px; +} + +// Tooltip styles +.tooltip-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + + .tooltip-description { + margin-bottom: 8px; + + span { + font-weight: bold; + color: var(--bg-robin-400); + } + } + + .tooltip-example { + margin-bottom: 8px; + color: #8b92a0; + } + + .tooltip-link { + .tooltip-link-text { + color: #1890ff; + font-size: 11px; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/types.ts b/frontend/src/container/CreateAlertV2/AlertCondition/types.ts index 382955b58a10..a6dd211089b7 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/types.ts +++ b/frontend/src/container/CreateAlertV2/AlertCondition/types.ts @@ -1,14 +1,18 @@ import { DefaultOptionType } from 'antd/es/select'; import { Channels } from 'types/api/channels/getAll'; -import { Threshold } from '../context/types'; +import { + NotificationSettingsAction, + NotificationSettingsState, + Threshold, +} from '../context/types'; export type UpdateThreshold = { (thresholdId: string, field: 'channels', value: string[]): void; ( thresholdId: string, field: Exclude, - value: string, + value: string | number | null, ): void; }; @@ -20,4 +24,20 @@ export interface ThresholdItemProps { channels: Channels[]; isLoadingChannels: boolean; units: DefaultOptionType[]; + isErrorChannels: boolean; + refreshChannels: () => void; +} + +export interface AnomalyAndThresholdProps { + channels: Channels[]; + isLoadingChannels: boolean; + isErrorChannels: boolean; + refreshChannels: () => void; +} + +export interface RoutingPolicyBannerProps { + notificationSettings: NotificationSettingsState; + setNotificationSettings: ( + notificationSettings: NotificationSettingsAction, + ) => void; } diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx index af4d89c47f51..51e4a591e9e9 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx @@ -1,9 +1,19 @@ +import { Button, Flex, Switch, Typography } from 'antd'; import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select'; import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils'; import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants'; +import ROUTES from 'constants/routes'; +import { + AlertThresholdMatchType, + AlertThresholdOperator, +} from 'container/CreateAlertV2/context/types'; import { getSelectedQueryOptions } from 'container/FormAlertRules/utils'; +import { IUser } from 'providers/App/types'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { EQueryType } from 'types/common/dashboard'; +import { USER_ROLES } from 'types/roles'; + +import { RoutingPolicyBannerProps } from './types'; export function getQueryNames(currentQuery: Query): BaseOptionType[] { const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator( @@ -44,3 +54,360 @@ export function getCategorySelectOptionByName( ) || [] ); } + +const getOperatorWord = (op: AlertThresholdOperator): string => { + switch (op) { + case AlertThresholdOperator.IS_ABOVE: + return 'exceed'; + case AlertThresholdOperator.IS_BELOW: + return 'fall below'; + case AlertThresholdOperator.IS_EQUAL_TO: + return 'equal'; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return 'not equal'; + default: + return 'exceed'; + } +}; + +const getThresholdValue = (op: AlertThresholdOperator): number => { + switch (op) { + case AlertThresholdOperator.IS_ABOVE: + return 80; + case AlertThresholdOperator.IS_BELOW: + return 50; + case AlertThresholdOperator.IS_EQUAL_TO: + return 100; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return 0; + default: + return 80; + } +}; + +const getDataPoints = ( + matchType: AlertThresholdMatchType, + op: AlertThresholdOperator, +): number[] => { + const dataPointMap: Record< + AlertThresholdMatchType, + Record + > = { + [AlertThresholdMatchType.AT_LEAST_ONCE]: { + [AlertThresholdOperator.IS_BELOW]: [60, 45, 40, 55, 35], + [AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0], + [AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95], + }, + [AlertThresholdMatchType.ALL_THE_TIME]: { + [AlertThresholdOperator.IS_BELOW]: [45, 40, 35, 42, 38], + [AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12], + [AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95], + }, + [AlertThresholdMatchType.ON_AVERAGE]: { + [AlertThresholdOperator.IS_BELOW]: [60, 40, 45, 35, 45], + [AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12], + [AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95], + }, + [AlertThresholdMatchType.IN_TOTAL]: { + [AlertThresholdOperator.IS_BELOW]: [8, 5, 10, 12, 8], + [AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30], + [AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30], + [AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30], + }, + [AlertThresholdMatchType.LAST]: { + [AlertThresholdOperator.IS_BELOW]: [75, 85, 90, 78, 45], + [AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25], + [AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95], + }, + }; + + return dataPointMap[matchType]?.[op] || [75, 85, 90, 78, 95]; +}; + +const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => { + const symbolMap: Record = { + [AlertThresholdOperator.IS_ABOVE]: '>', + [AlertThresholdOperator.IS_BELOW]: '<', + [AlertThresholdOperator.IS_EQUAL_TO]: '=', + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=', + [AlertThresholdOperator.ABOVE_BELOW]: '>', + }; + return symbolMap[op] || '>'; +}; + +const handleTooltipClick = ( + e: React.MouseEvent | React.KeyboardEvent, +): void => { + e.stopPropagation(); +}; + +function TooltipContent({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleTooltipClick(e); + } + }} + className="tooltip-content" + > + {children} +
+ ); +} + +function TooltipExample({ + children, + dataPoints, + operatorSymbol, + thresholdValue, + matchType, +}: { + children: React.ReactNode; + dataPoints: number[]; + operatorSymbol: string; + thresholdValue: number; + matchType: AlertThresholdMatchType; +}): JSX.Element { + return ( +
+ Example: +
+ Say, For a 5-minute window (configured in Evaluation settings), 1 min + aggregation interval (set up in query) → 5{' '} + {matchType === AlertThresholdMatchType.IN_TOTAL + ? 'error counts' + : 'data points'} + : [{dataPoints.join(', ')}]
+ With threshold {operatorSymbol} {thresholdValue}: {children} +
+ ); +} + +function TooltipLink(): JSX.Element { + return ( + + ); +} + +export const getMatchTypeTooltip = ( + matchType: AlertThresholdMatchType, + operator: AlertThresholdOperator, +): React.ReactNode => { + const operatorSymbol = getTooltipOperatorSymbol(operator); + const operatorWord = getOperatorWord(operator); + const thresholdValue = getThresholdValue(operator); + const dataPoints = getDataPoints(matchType, operator); + const getMatchingPointsCount = (): number => + dataPoints.filter((p) => { + switch (operator) { + case AlertThresholdOperator.IS_ABOVE: + return p > thresholdValue; + case AlertThresholdOperator.IS_BELOW: + return p < thresholdValue; + case AlertThresholdOperator.IS_EQUAL_TO: + return p === thresholdValue; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return p !== thresholdValue; + default: + return p > thresholdValue; + } + }).length; + + switch (matchType) { + case AlertThresholdMatchType.AT_LEAST_ONCE: + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if ANY of + those aggregated data points crosses the threshold. +
+ + Alert triggers ({getMatchingPointsCount()} points {operatorWord}{' '} + {thresholdValue}) + + +
+ ); + + case AlertThresholdMatchType.ALL_THE_TIME: + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if ALL{' '} + aggregated data points cross the threshold. +
+ + Alert triggers (all points {operatorWord} {thresholdValue})
+ If any point was {thresholdValue}, no alert would fire +
+ +
+ ); + + case AlertThresholdMatchType.ON_AVERAGE: { + const average = ( + dataPoints.reduce((a, b) => a + b, 0) / dataPoints.length + ).toFixed(1); + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if the{' '} + AVERAGE of all aggregated data points crosses the threshold. +
+ + Alert triggers (average = {average}) + + +
+ ); + } + + case AlertThresholdMatchType.IN_TOTAL: { + const total = dataPoints.reduce((a, b) => a + b, 0); + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if the{' '} + SUM of all aggregated data points crosses the threshold. +
+ + Alert triggers (total = {total}) + + +
+ ); + } + + case AlertThresholdMatchType.LAST: { + const lastPoint = dataPoints[dataPoints.length - 1]; + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers based on the{' '} + MOST RECENT aggregated data point only. +
+ + Alert triggers (last point = {lastPoint}) + + +
+ ); + } + + default: + return ''; + } +}; + +export function NotificationChannelsNotFoundContent({ + user, + refreshChannels, +}: { + user: IUser; + refreshChannels: () => void; +}): JSX.Element { + return ( + + + No channels yet. + {user?.role === USER_ROLES.ADMIN ? ( + + Create one + + + ) : ( + Please ask your admin to create one. + )} + + + + ); +} + +export function RoutingPolicyBanner({ + notificationSettings, + setNotificationSettings, +}: RoutingPolicyBannerProps): JSX.Element { + return ( +
+ + Use Routing Policies for dynamic routing + + { + setNotificationSettings({ + type: 'SET_ROUTING_POLICIES', + payload: value, + }); + }} + /> +
+ ); +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx index 8ad31b7c5981..becfe20fdd87 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx @@ -38,7 +38,6 @@ function CreateAlertHeader(): JSX.Element {
New Alert Rule
-
- - setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value }) - } - className="alert-header__input description" - placeholder="Click to add description..." - /> diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx index adb4e8ed8b97..ff74fc01476c 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx @@ -1,9 +1,21 @@ /* eslint-disable react/jsx-props-no-spreading */ import { fireEvent, render, screen } from '@testing-library/react'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule'; +import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule'; import { CreateAlertProvider } from '../../context'; import CreateAlertHeader from '../CreateAlertHeader'; +jest.spyOn(useCreateAlertRuleHook, 'useCreateAlertRule').mockReturnValue({ + mutate: jest.fn(), + isLoading: false, +} as any); +jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({ + mutate: jest.fn(), + isLoading: false, +} as any); + jest.mock('uplot', () => { const paths = { spline: jest.fn(), @@ -27,7 +39,7 @@ jest.mock('react-router-dom', () => ({ const renderCreateAlertHeader = (): ReturnType => render( - + , ); @@ -44,14 +56,6 @@ describe('CreateAlertHeader', () => { expect(nameInput).toBeInTheDocument(); }); - it('renders description input with placeholder', () => { - renderCreateAlertHeader(); - const descriptionInput = screen.getByPlaceholderText( - 'Click to add description...', - ); - expect(descriptionInput).toBeInTheDocument(); - }); - it('renders LabelsInput component', () => { renderCreateAlertHeader(); expect(screen.getByText('+ Add labels')).toBeInTheDocument(); @@ -65,13 +69,4 @@ describe('CreateAlertHeader', () => { expect(nameInput).toHaveValue('Test Alert'); }); - - it('updates description when typing in description input', () => { - renderCreateAlertHeader(); - const descriptionInput = screen.getByPlaceholderText( - 'Click to add description...', - ); - fireEvent.change(descriptionInput, { target: { value: 'Test Description' } }); - expect(descriptionInput).toHaveValue('Test Description'); - }); }); diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss index c594cbebc226..e586d98a5016 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss @@ -3,21 +3,6 @@ font-family: inherit; color: var(--text-vanilla-100); - /* Top bar with diagonal stripes */ - &__tab-bar { - height: 32px; - display: flex; - align-items: center; - background: repeating-linear-gradient( - -45deg, - #0f0f0f, - #0f0f0f 10px, - #101010 10px, - #101010 20px - ); - padding-left: 0; - } - /* Tab block visuals */ &__tab { display: flex; @@ -44,6 +29,8 @@ display: flex; flex-direction: column; gap: 8px; + min-width: 300px; + flex: 1; } &__input.title { @@ -51,6 +38,8 @@ font-weight: 500; background-color: transparent; color: var(--text-vanilla-100); + width: 100%; + min-width: 300px; } &__input:focus, @@ -64,6 +53,15 @@ background-color: transparent; color: var(--text-vanilla-300); } + + .ant-btn { + display: flex; + gap: 4px; + align-items: center; + color: var(--text-vanilla-100); + border: 1px solid var(--bg-slate-300); + margin-right: 16px; + } } .labels-input { @@ -149,3 +147,65 @@ } } } + +.lightMode { + .alert-header { + background-color: var(--bg-vanilla-100); + color: var(--text-ink-100); + + &__tab { + background-color: var(--bg-vanilla-100); + color: var(--text-ink-100); + } + + &__tab::before { + color: var(--bg-ink-100); + } + + &__content { + background: var(--bg-vanilla-100); + } + + &__input.title { + color: var(--text-ink-100); + } + + &__input.description { + color: var(--text-ink-300); + } + } + + .labels-input { + &__add-button { + color: var(--bg-ink-400); + border: 1px solid var(--bg-vanilla-300); + + &:hover { + border-color: var(--bg-ink-300); + color: var(--bg-ink-500); + } + } + + &__label-pill { + background-color: #ad7f581a; + color: var(--bg-sienna-400); + border: 1px solid var(--bg-sienna-500); + } + + &__remove-button { + color: var(--bg-sienna-400); + + &:hover { + color: var(--text-ink-100); + } + } + + &__input { + color: var(--bg-ink-500); + + &::placeholder { + color: var(--bg-ink-300); + } + } + } +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss b/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss index 23c38b075b8b..7ecb3fce40a6 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss +++ b/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss @@ -1,17 +1,20 @@ -$top-nav-background-1: #0f0f0f; -$top-nav-background-2: #101010; - .create-alert-v2-container { background-color: var(--bg-ink-500); + padding-bottom: 100px; } -.top-nav-container { - background: repeating-linear-gradient( - -45deg, - $top-nav-background-1, - $top-nav-background-1 10px, - $top-nav-background-2 10px, - $top-nav-background-2 20px - ); - margin-bottom: 0; +.lightMode { + .create-alert-v2-container { + background-color: var(--bg-vanilla-100); + } +} + +.sticky-page-spinner { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.35); + z-index: 10000; + pointer-events: auto; } diff --git a/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx index 0ce7e0821fff..dc60cedb7c93 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx @@ -2,27 +2,32 @@ import './CreateAlertV2.styles.scss'; import { initialQueriesMap } from 'constants/queryBuilder'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; -import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import AlertCondition from './AlertCondition'; import { CreateAlertProvider } from './context'; +import { buildInitialAlertDef } from './context/utils'; import CreateAlertHeader from './CreateAlertHeader'; import EvaluationSettings from './EvaluationSettings'; +import Footer from './Footer'; import NotificationSettings from './NotificationSettings'; import QuerySection from './QuerySection'; -import { showCondensedLayout } from './utils'; +import { CreateAlertV2Props } from './types'; +import { showCondensedLayout, Spinner } from './utils'; -function CreateAlertV2({ - initialQuery = initialQueriesMap.metrics, -}: { - initialQuery?: Query; -}): JSX.Element { - useShareBuilderUrl({ defaultValue: initialQuery }); +function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element { + const queryToRedirect = buildInitialAlertDef(alertType); + const currentQueryToRedirect = mapQueryDataFromApi( + queryToRedirect.condition.compositeQuery, + ); + + useShareBuilderUrl({ defaultValue: currentQueryToRedirect }); const showCondensedLayoutFlag = showCondensedLayout(); return ( - + +
@@ -30,6 +35,7 @@ function CreateAlertV2({ {!showCondensedLayoutFlag ? : null}
+