diff --git a/frontend/src/api/alerts/updateAlertRule.ts b/frontend/src/api/alerts/updateAlertRule.ts new file mode 100644 index 000000000000..6553d685338a --- /dev/null +++ b/frontend/src/api/alerts/updateAlertRule.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; + +export interface UpdateAlertRuleResponse { + data: string; + status: string; +} + +const updateAlertRule = async ( + id: string, + postableAlertRule: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.put(`/rules/${id}`, { + ...postableAlertRule, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default updateAlertRule; diff --git a/frontend/src/api/routingPolicies/createRoutingPolicy.ts b/frontend/src/api/routingPolicies/createRoutingPolicy.ts index 5ec9847b69b7..69bf58bd8c86 100644 --- a/frontend/src/api/routingPolicies/createRoutingPolicy.ts +++ b/frontend/src/api/routingPolicies/createRoutingPolicy.ts @@ -6,9 +6,7 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api'; export interface CreateRoutingPolicyBody { name: string; expression: string; - actions: { - channels: string[]; - }; + channels: string[]; description?: string; } @@ -23,7 +21,7 @@ const createRoutingPolicy = async ( SuccessResponseV2 | ErrorResponseV2 > => { try { - const response = await axios.post(`/notification-policy`, props); + const response = await axios.post(`/route_policies`, props); return { httpStatusCode: response.status, data: response.data, diff --git a/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts b/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts index 5b0d3df14d97..444ec0b1ab99 100644 --- a/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts +++ b/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts @@ -14,9 +14,7 @@ const deleteRoutingPolicy = async ( SuccessResponseV2 | ErrorResponseV2 > => { try { - const response = await axios.delete( - `/notification-policy/${routingPolicyId}`, - ); + const response = await axios.delete(`/route_policies/${routingPolicyId}`); return { httpStatusCode: response.status, diff --git a/frontend/src/api/routingPolicies/getRoutingPolicies.ts b/frontend/src/api/routingPolicies/getRoutingPolicies.ts index 43191aebd77f..b06d359c9d8f 100644 --- a/frontend/src/api/routingPolicies/getRoutingPolicies.ts +++ b/frontend/src/api/routingPolicies/getRoutingPolicies.ts @@ -25,7 +25,7 @@ export const getRoutingPolicies = async ( headers?: Record, ): Promise | ErrorResponseV2> => { try { - const response = await axios.get('/notification-policy', { + const response = await axios.get('/route_policies', { signal, headers, }); diff --git a/frontend/src/api/routingPolicies/updateRoutingPolicy.ts b/frontend/src/api/routingPolicies/updateRoutingPolicy.ts index 08448562cdd0..63731fe5bf98 100644 --- a/frontend/src/api/routingPolicies/updateRoutingPolicy.ts +++ b/frontend/src/api/routingPolicies/updateRoutingPolicy.ts @@ -6,9 +6,7 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api'; export interface UpdateRoutingPolicyBody { name: string; expression: string; - actions: { - channels: string[]; - }; + channels: string[]; description: string; } @@ -24,7 +22,7 @@ const updateRoutingPolicy = async ( SuccessResponseV2 | ErrorResponseV2 > => { try { - const response = await axios.put(`/notification-policy/${id}`, { + const response = await axios.put(`/route_policies/${id}`, { ...props, }); diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx index 89fc094584d9..b502dd09717a 100644 --- a/frontend/src/container/CreateAlertRule/index.tsx +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -3,7 +3,6 @@ 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'; @@ -127,7 +126,8 @@ function CreateRules(): JSX.Element { ); } - const showNewCreateAlertsPageFlag = showNewCreateAlertsPage(); + const showNewCreateAlertsPageFlag = + queryParams.get('showNewCreateAlertsPage') === 'true'; if ( showNewCreateAlertsPageFlag && diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx index c2d57e4c3829..c67eebf77490 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx @@ -5,6 +5,7 @@ import { Button, Select, Tooltip, Typography } from 'antd'; import classNames from 'classnames'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { Plus } from 'lucide-react'; +import { useEffect } from 'react'; import { useCreateAlertState } from '../context'; import { @@ -46,6 +47,19 @@ function AlertThreshold({ const queryNames = getQueryNames(currentQuery); + useEffect(() => { + if ( + queryNames.length > 0 && + !queryNames.some((query) => query.value === thresholdState.selectedQuery) + ) { + setThresholdState({ + type: 'SET_SELECTED_QUERY', + payload: queryNames[0].value, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryNames, thresholdState.selectedQuery]); + const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || ''); const categorySelectOptions = getCategorySelectOptionByName( selectedCategory || '', diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx index 78cfadcb5bbe..ab91089e205a 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx @@ -108,6 +108,11 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({ ]), })); +jest.mock('container/CreateAlertV2/utils', () => ({ + ...jest.requireActual('container/CreateAlertV2/utils'), + showCondensedLayout: jest.fn().mockReturnValue(false), +})); + const TEST_STRINGS = { ADD_THRESHOLD: 'Add Threshold', AT_LEAST_ONCE: 'AT LEAST ONCE', @@ -204,11 +209,11 @@ describe('AlertThreshold', () => { // First addition should add WARNING threshold fireEvent.click(addButton); - expect(screen.getByText('WARNING')).toBeInTheDocument(); + expect(screen.getByText('warning')).toBeInTheDocument(); // Second addition should add INFO threshold fireEvent.click(addButton); - expect(screen.getByText('INFO')).toBeInTheDocument(); + expect(screen.getByText('info')).toBeInTheDocument(); // Third addition should add random threshold fireEvent.click(addButton); @@ -280,7 +285,7 @@ describe('AlertThreshold', () => { renderAlertThreshold(); // Should have initial critical threshold - expect(screen.getByText('CRITICAL')).toBeInTheDocument(); + expect(screen.getByText('critical')).toBeInTheDocument(); verifySelectRenders(TEST_STRINGS.IS_ABOVE); verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE); }); diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss index d8ef5bd295d9..c9ab64447862 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss +++ b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss @@ -494,15 +494,21 @@ } } } + } - .add-threshold-btn { - border: 1px dashed var(--bg-vanilla-300); - color: var(--bg-ink-300); + .add-threshold-btn, + .ant-btn.add-threshold-btn { + border: 1px dashed var(--bg-vanilla-300); + color: var(--bg-ink-300); + background-color: transparent; - &:hover { - border-color: var(--bg-ink-300); - color: var(--bg-ink-400); - } + .ant-typography { + color: var(--bg-ink-400); + } + + &:hover { + border-color: var(--bg-ink-300); + color: var(--bg-ink-400); } } } diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx index becfe20fdd87..e076f23e19f8 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx @@ -1,5 +1,6 @@ import './styles.scss'; +import classNames from 'classnames'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useCallback, useMemo } from 'react'; import { Labels } from 'types/api/alerts/def'; @@ -8,7 +9,7 @@ import { useCreateAlertState } from '../context'; import LabelsInput from './LabelsInput'; function CreateAlertHeader(): JSX.Element { - const { alertState, setAlertState } = useCreateAlertState(); + const { alertState, setAlertState, isEditMode } = useCreateAlertState(); const { currentQuery } = useQueryBuilder(); @@ -34,10 +35,14 @@ function CreateAlertHeader(): JSX.Element { ); return ( -
-
-
New Alert Rule
-
+
+ {!isEditMode && ( +
+
New Alert Rule
+
+ )}
{ const paths = { @@ -37,6 +44,8 @@ jest.mock('react-router-dom', () => ({ }), })); +const ENTER_ALERT_RULE_NAME_PLACEHOLDER = 'Enter alert rule name'; + const renderCreateAlertHeader = (): ReturnType => render( @@ -52,7 +61,9 @@ describe('CreateAlertHeader', () => { it('renders name input with placeholder', () => { renderCreateAlertHeader(); - const nameInput = screen.getByPlaceholderText('Enter alert rule name'); + const nameInput = screen.getByPlaceholderText( + ENTER_ALERT_RULE_NAME_PLACEHOLDER, + ); expect(nameInput).toBeInTheDocument(); }); @@ -63,10 +74,30 @@ describe('CreateAlertHeader', () => { it('updates name when typing in name input', () => { renderCreateAlertHeader(); - const nameInput = screen.getByPlaceholderText('Enter alert rule name'); + const nameInput = screen.getByPlaceholderText( + ENTER_ALERT_RULE_NAME_PLACEHOLDER, + ); fireEvent.change(nameInput, { target: { value: 'Test Alert' } }); expect(nameInput).toHaveValue('Test Alert'); }); + + it('renders the header with title when isEditMode is true', () => { + render( + + + , + ); + expect(screen.queryByText('New Alert Rule')).not.toBeInTheDocument(); + expect( + screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER), + ).toHaveValue('TEST_ALERT'); + }); }); diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss index e586d98a5016..b1d0511262f3 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss @@ -175,10 +175,19 @@ } } + .edit-alert-header { + width: 100%; + } + + .edit-alert-header .alert-header__content { + background: var(--bg-vanilla-200); + } + .labels-input { &__add-button { color: var(--bg-ink-400); border: 1px solid var(--bg-vanilla-300); + background-color: var(--bg-vanilla-100); &:hover { border-color: var(--bg-ink-300); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptionItem/AdvancedOptionItem.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptionItem/AdvancedOptionItem.tsx index 7efb380a388c..89dc56e3b84c 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptionItem/AdvancedOptionItem.tsx +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptionItem/AdvancedOptionItem.tsx @@ -2,7 +2,7 @@ import './styles.scss'; import { Switch, Tooltip, Typography } from 'antd'; import { Info } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { IAdvancedOptionItemProps } from '../types'; @@ -12,9 +12,14 @@ function AdvancedOptionItem({ input, tooltipText, onToggle, + defaultShowInput, }: IAdvancedOptionItemProps): JSX.Element { const [showInput, setShowInput] = useState(false); + useEffect(() => { + setShowInput(defaultShowInput); + }, [defaultShowInput]); + const handleOnToggle = (): void => { onToggle?.(); setShowInput((currentShowInput) => !currentShowInput); @@ -42,7 +47,7 @@ function AdvancedOptionItem({ > {input}
- +
); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptions.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptions.tsx index dfa8d033cfc0..a02ff1881ad9 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptions.tsx +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptions.tsx @@ -42,6 +42,7 @@ function AdvancedOptions(): JSX.Element { payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled, }) } + defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled} /> {/* TODO: Add back when the functionality is implemented */} {/* { title={defaultProps.title} description={defaultProps.description} input={defaultProps.input} + defaultShowInput={false} />, ); @@ -50,6 +51,7 @@ describe('AdvancedOptionItem', () => { title={defaultProps.title} description={defaultProps.description} input={defaultProps.input} + defaultShowInput={false} />, ); @@ -65,6 +67,7 @@ describe('AdvancedOptionItem', () => { title={defaultProps.title} description={defaultProps.description} input={defaultProps.input} + defaultShowInput={false} />, ); @@ -88,6 +91,7 @@ describe('AdvancedOptionItem', () => { title={defaultProps.title} description={defaultProps.description} input={defaultProps.input} + defaultShowInput={false} />, ); @@ -117,6 +121,7 @@ describe('AdvancedOptionItem', () => { title={defaultProps.title} description={defaultProps.description} input={defaultProps.input} + defaultShowInput={false} />, ); @@ -146,6 +151,7 @@ describe('AdvancedOptionItem', () => { title={defaultProps.title} description={defaultProps.description} input={defaultProps.input} + defaultShowInput={false} />, ); @@ -160,9 +166,24 @@ describe('AdvancedOptionItem', () => { description={defaultProps.description} input={defaultProps.input} tooltipText="mock tooltip text" + defaultShowInput={false} />, ); const tooltipIcon = screen.getByTestId('tooltip-icon'); expect(tooltipIcon).toBeInTheDocument(); }); + + it('should show input when defaultShowInput is true', () => { + render( + , + ); + const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID); + expect(inputElement).toBeInTheDocument(); + expect(inputElement).toBeVisible(); + }); }); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/EvaluationSettings.test.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/EvaluationSettings.test.tsx index 741a918404c6..6949faca149d 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/EvaluationSettings.test.tsx +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/EvaluationSettings.test.tsx @@ -6,6 +6,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes'; import EvaluationSettings from '../EvaluationSettings'; import { createMockAlertContextState } from './testUtils'; +jest.mock('container/CreateAlertV2/utils', () => ({ + ...jest.requireActual('container/CreateAlertV2/utils'), + showCondensedLayout: jest.fn().mockReturnValue(false), +})); + const mockSetEvaluationWindow = jest.fn(); jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue( createMockAlertContextState({ diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts index 1486e73e81a2..3055250b61dc 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts @@ -31,6 +31,9 @@ export const createMockAlertContextState = ( isCreatingAlertRule: false, isTestingAlertRule: false, createAlertRule: jest.fn(), + isUpdatingAlertRule: false, + updateAlertRule: jest.fn(), + isEditMode: false, ...overrides, }); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts index f3f0afadd69c..1372163f6cba 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts @@ -11,6 +11,7 @@ export interface IAdvancedOptionItemProps { input: JSX.Element; tooltipText?: string; onToggle?: () => void; + defaultShowInput: boolean; } export enum RollingWindowTimeframes { diff --git a/frontend/src/container/CreateAlertV2/Footer/Footer.tsx b/frontend/src/container/CreateAlertV2/Footer/Footer.tsx index c4b39531c931..d40cbfd9d638 100644 --- a/frontend/src/container/CreateAlertV2/Footer/Footer.tsx +++ b/frontend/src/container/CreateAlertV2/Footer/Footer.tsx @@ -26,11 +26,17 @@ function Footer(): JSX.Element { isCreatingAlertRule, testAlertRule, isTestingAlertRule, + updateAlertRule, + isUpdatingAlertRule, + isEditMode, } = useCreateAlertState(); const { currentQuery } = useQueryBuilder(); const { safeNavigate } = useSafeNavigate(); - const handleDiscard = (): void => discardAlertRule(); + const handleDiscard = (): void => { + discardAlertRule(); + safeNavigate('/alerts'); + }; const alertValidationMessage = useMemo( () => @@ -99,15 +105,27 @@ function Footer(): JSX.Element { notificationSettings, query: currentQuery, }); - createAlertRule(payload, { - onSuccess: () => { - toast.success('Alert rule created successfully'); - safeNavigate('/alerts'); - }, - onError: (error) => { - toast.error(error.message); - }, - }); + if (isEditMode) { + updateAlertRule(payload, { + onSuccess: () => { + toast.success('Alert rule updated successfully'); + safeNavigate('/alerts'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + } else { + createAlertRule(payload, { + onSuccess: () => { + toast.success('Alert rule created successfully'); + safeNavigate('/alerts'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + } }, [ alertType, basicAlertState, @@ -116,16 +134,22 @@ function Footer(): JSX.Element { evaluationWindow, notificationSettings, currentQuery, + isEditMode, + updateAlertRule, createAlertRule, safeNavigate, ]); const disableButtons = - isCreatingAlertRule || isTestingAlertRule || !!alertValidationMessage; + isCreatingAlertRule || isTestingAlertRule || isUpdatingAlertRule; const saveAlertButton = useMemo(() => { let button = ( - @@ -141,7 +165,7 @@ function Footer(): JSX.Element {
diff --git a/frontend/src/container/CreateAlertV2/Footer/__tests__/Footer.test.tsx b/frontend/src/container/CreateAlertV2/Footer/__tests__/Footer.test.tsx new file mode 100644 index 000000000000..7e4de2c7852f --- /dev/null +++ b/frontend/src/container/CreateAlertV2/Footer/__tests__/Footer.test.tsx @@ -0,0 +1,248 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { + AlertThresholdMatchType, + AlertThresholdOperator, +} from 'container/CreateAlertV2/context/types'; +import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils'; + +import * as createAlertState from '../../context'; +import Footer from '../Footer'; + +// Mock the hooks used by Footer component +jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ + useQueryBuilder: jest.fn(), +})); + +jest.mock('hooks/useSafeNavigate', () => ({ + useSafeNavigate: jest.fn(), +})); + +const mockCreateAlertRule = jest.fn(); +const mockTestAlertRule = jest.fn(); +const mockUpdateAlertRule = jest.fn(); +const mockDiscardAlertRule = jest.fn(); + +// Import the mocked hooks +const { useQueryBuilder } = jest.requireMock( + 'hooks/queryBuilder/useQueryBuilder', +); +const { useSafeNavigate } = jest.requireMock('hooks/useSafeNavigate'); + +const mockAlertContextState = createMockAlertContextState({ + createAlertRule: mockCreateAlertRule, + testAlertRule: mockTestAlertRule, + updateAlertRule: mockUpdateAlertRule, + discardAlertRule: mockDiscardAlertRule, + alertState: { + name: 'Test Alert', + labels: {}, + yAxisUnit: undefined, + }, + thresholdState: { + selectedQuery: 'A', + operator: AlertThresholdOperator.ABOVE_BELOW, + matchType: AlertThresholdMatchType.AT_LEAST_ONCE, + evaluationWindow: '5m0s', + algorithm: 'standard', + seasonality: 'hourly', + thresholds: [ + { + id: '1', + label: 'CRITICAL', + thresholdValue: 0, + recoveryThresholdValue: null, + unit: '', + channels: ['test-channel'], + color: '#ff0000', + }, + ], + }, +}); + +jest + .spyOn(createAlertState, 'useCreateAlertState') + .mockReturnValue(mockAlertContextState); + +const SAVE_ALERT_RULE_TEXT = 'Save Alert Rule'; +const TEST_NOTIFICATION_TEXT = 'Test Notification'; +const DISCARD_TEXT = 'Discard'; + +describe('Footer', () => { + beforeEach(() => { + useQueryBuilder.mockReturnValue({ + currentQuery: { + builder: { + queryData: [], + queryFormulas: [], + }, + promql: [], + clickhouse_sql: [], + queryType: 'builder', + }, + }); + + useSafeNavigate.mockReturnValue({ + safeNavigate: jest.fn(), + }); + }); + + it('should render the component with 3 buttons', () => { + render(
); + expect(screen.getByText(SAVE_ALERT_RULE_TEXT)).toBeInTheDocument(); + expect(screen.getByText(TEST_NOTIFICATION_TEXT)).toBeInTheDocument(); + expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument(); + }); + + it('discard action works correctly', () => { + render(
); + fireEvent.click(screen.getByText(DISCARD_TEXT)); + expect(mockDiscardAlertRule).toHaveBeenCalled(); + }); + + it('save alert rule action works correctly', () => { + render(
); + fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT)); + expect(mockCreateAlertRule).toHaveBeenCalled(); + }); + + it('update alert rule action works correctly', () => { + jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({ + ...mockAlertContextState, + isEditMode: true, + }); + render(
); + fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT)); + expect(mockUpdateAlertRule).toHaveBeenCalled(); + }); + + it('test notification action works correctly', () => { + render(
); + fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT)); + expect(mockTestAlertRule).toHaveBeenCalled(); + }); + + it('all buttons are disabled when creating alert rule', () => { + jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({ + ...mockAlertContextState, + isCreatingAlertRule: true, + }); + render(
); + + expect( + screen.getByRole('button', { name: /save alert rule/i }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: /test notification/i }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled(); + }); + + it('all buttons are disabled when updating alert rule', () => { + jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({ + ...mockAlertContextState, + isUpdatingAlertRule: true, + }); + render(
); + + // Target the button elements directly instead of the text spans inside them + expect( + screen.getByRole('button', { name: /save alert rule/i }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: /test notification/i }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled(); + }); + + it('all buttons are disabled when testing alert rule', () => { + jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({ + ...mockAlertContextState, + isTestingAlertRule: true, + }); + render(
); + + // Target the button elements directly instead of the text spans inside them + expect( + screen.getByRole('button', { name: /save alert rule/i }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: /test notification/i }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled(); + }); + + it('create and test buttons are disabled when alert name is missing', () => { + jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({ + ...mockAlertContextState, + alertState: { + ...mockAlertContextState.alertState, + name: '', + }, + }); + render(
); + + expect( + screen.getByRole('button', { name: /save alert rule/i }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: /test notification/i }), + ).toBeDisabled(); + }); + + it('create and test buttons are disabled when notifcation channels are missing and routing policies are disabled', () => { + jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({ + ...mockAlertContextState, + notificationSettings: { + ...mockAlertContextState.notificationSettings, + routingPolicies: false, + }, + thresholdState: { + ...mockAlertContextState.thresholdState, + thresholds: [ + { + ...mockAlertContextState.thresholdState.thresholds[0], + channels: [], + }, + ], + }, + }); + + render(
); + + expect( + screen.getByRole('button', { name: /save alert rule/i }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: /test notification/i }), + ).toBeDisabled(); + }); + + it('buttons are enabled even with no notification channels when routing policies are enabled', () => { + jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({ + ...mockAlertContextState, + notificationSettings: { + ...mockAlertContextState.notificationSettings, + routingPolicies: true, + }, + thresholdState: { + ...mockAlertContextState.thresholdState, + thresholds: [ + { + ...mockAlertContextState.thresholdState.thresholds[0], + channels: [], + }, + ], + }, + }); + + render(
); + + expect( + screen.getByRole('button', { name: /save alert rule/i }), + ).toBeEnabled(); + expect( + screen.getByRole('button', { name: /test notification/i }), + ).toBeEnabled(); + expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled(); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/Footer/__tests__/utils.test.ts b/frontend/src/container/CreateAlertV2/Footer/__tests__/utils.test.ts new file mode 100644 index 000000000000..3e8fa9071894 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/Footer/__tests__/utils.test.ts @@ -0,0 +1,524 @@ +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; +import { initialQueriesMap } from 'constants/queryBuilder'; +import { + INITIAL_ADVANCED_OPTIONS_STATE, + INITIAL_ALERT_STATE, + INITIAL_ALERT_THRESHOLD_STATE, + INITIAL_EVALUATION_WINDOW_STATE, + INITIAL_NOTIFICATION_SETTINGS_STATE, +} from 'container/CreateAlertV2/context/constants'; +import { + AdvancedOptionsState, + EvaluationWindowState, + NotificationSettingsState, +} from 'container/CreateAlertV2/context/types'; +import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { EQueryType } from 'types/common/dashboard'; + +import { BuildCreateAlertRulePayloadArgs } from '../types'; +import { + buildCreateThresholdAlertRulePayload, + getAlertOnAbsentProps, + getEnforceMinimumDatapointsProps, + getEvaluationProps, + getFormattedTimeValue, + getNotificationSettingsProps, + validateCreateAlertState, +} from '../utils'; + +describe('Footer utils', () => { + describe('getFormattedTimeValue', () => { + it('for 60 seconds', () => { + expect(getFormattedTimeValue(60, UniversalYAxisUnit.SECONDS)).toBe('60s'); + }); + it('for 60 minutes', () => { + expect(getFormattedTimeValue(60, UniversalYAxisUnit.MINUTES)).toBe('60m'); + }); + it('for 60 hours', () => { + expect(getFormattedTimeValue(60, UniversalYAxisUnit.HOURS)).toBe('60h'); + }); + it('for 60 days', () => { + expect(getFormattedTimeValue(60, UniversalYAxisUnit.DAYS)).toBe('60d'); + }); + }); + + describe('validateCreateAlertState', () => { + const args: BuildCreateAlertRulePayloadArgs = { + alertType: AlertTypes.METRICS_BASED_ALERT, + basicAlertState: INITIAL_ALERT_STATE, + thresholdState: INITIAL_ALERT_THRESHOLD_STATE, + advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE, + evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE, + notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE, + query: initialQueriesMap.metrics, + }; + + it('when alert name is not provided', () => { + expect(validateCreateAlertState(args)).toBeDefined(); + expect(validateCreateAlertState(args)).toBe('Please enter an alert name'); + }); + + it('when threshold label is not provided', () => { + const currentArgs: BuildCreateAlertRulePayloadArgs = { + ...args, + basicAlertState: { + ...args.basicAlertState, + name: 'test name', + }, + thresholdState: { + ...args.thresholdState, + thresholds: [ + { + ...args.thresholdState.thresholds[0], + label: '', + }, + ], + }, + }; + expect(validateCreateAlertState(currentArgs)).toBeDefined(); + expect(validateCreateAlertState(currentArgs)).toBe( + 'Please enter a label for each threshold', + ); + }); + + it('when threshold channels are not provided', () => { + const currentArgs: BuildCreateAlertRulePayloadArgs = { + ...args, + basicAlertState: { + ...args.basicAlertState, + name: 'test name', + }, + }; + expect(validateCreateAlertState(currentArgs)).toBeDefined(); + expect(validateCreateAlertState(currentArgs)).toBe( + 'Please select at least one channel for each threshold or enable routing policies', + ); + }); + + it('when threshold channels are not provided but routing policies are enabled', () => { + const currentArgs: BuildCreateAlertRulePayloadArgs = { + ...args, + basicAlertState: { + ...args.basicAlertState, + name: 'test name', + }, + notificationSettings: { + ...args.notificationSettings, + routingPolicies: true, + }, + }; + expect(validateCreateAlertState(currentArgs)).toBeNull(); + }); + + it('when threshold channels are provided', () => { + const currentArgs: BuildCreateAlertRulePayloadArgs = { + ...args, + basicAlertState: { + ...args.basicAlertState, + name: 'test name', + }, + thresholdState: { + ...args.thresholdState, + thresholds: [ + { + ...args.thresholdState.thresholds[0], + channels: ['test channel'], + }, + ], + }, + }; + expect(validateCreateAlertState(currentArgs)).toBeNull(); + }); + }); + + describe('getNotificationSettingsProps', () => { + it('when initial notification settings are provided', () => { + const notificationSettings = INITIAL_NOTIFICATION_SETTINGS_STATE; + const props = getNotificationSettingsProps(notificationSettings); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + groupBy: [], + renotify: { + enabled: false, + interval: '1m', + alertStates: [], + }, + usePolicy: false, + }); + }); + }); + + it('renotification is enabled', () => { + const notificationSettings: NotificationSettingsState = { + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + reNotification: { + enabled: true, + value: 1, + unit: UniversalYAxisUnit.MINUTES, + conditions: ['firing'], + }, + }; + const props = getNotificationSettingsProps(notificationSettings); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + groupBy: [], + renotify: { + enabled: true, + interval: '1m', + alertStates: ['firing'], + }, + usePolicy: false, + }); + }); + + it('routing policies are enabled', () => { + const notificationSettings: NotificationSettingsState = { + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + routingPolicies: true, + }; + const props = getNotificationSettingsProps(notificationSettings); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + groupBy: [], + renotify: { + enabled: false, + interval: '1m', + alertStates: [], + }, + usePolicy: true, + }); + }); + + it('group by notifications are provided', () => { + const notificationSettings: NotificationSettingsState = { + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + multipleNotifications: ['test group'], + }; + const props = getNotificationSettingsProps(notificationSettings); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + groupBy: ['test group'], + renotify: { + enabled: false, + interval: '1m', + alertStates: [], + }, + usePolicy: false, + }); + }); + + describe('getAlertOnAbsentProps', () => { + it('when alert on absent is disabled', () => { + const advancedOptions: AdvancedOptionsState = { + ...INITIAL_ADVANCED_OPTIONS_STATE, + sendNotificationIfDataIsMissing: { + enabled: false, + toleranceLimit: 0, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + }; + const props = getAlertOnAbsentProps(advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + alertOnAbsent: false, + }); + }); + + it('when alert on absent is enabled', () => { + const advancedOptions: AdvancedOptionsState = { + ...INITIAL_ADVANCED_OPTIONS_STATE, + sendNotificationIfDataIsMissing: { + enabled: true, + toleranceLimit: 13, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + }; + const props = getAlertOnAbsentProps(advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + alertOnAbsent: true, + absentFor: 13, + }); + }); + }); + + describe('getEnforceMinimumDatapointsProps', () => { + it('when enforce minimum datapoints is disabled', () => { + const advancedOptions: AdvancedOptionsState = { + ...INITIAL_ADVANCED_OPTIONS_STATE, + enforceMinimumDatapoints: { + enabled: false, + minimumDatapoints: 0, + }, + }; + const props = getEnforceMinimumDatapointsProps(advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + requireMinPoints: false, + }); + }); + + it('when enforce minimum datapoints is enabled', () => { + const advancedOptions: AdvancedOptionsState = { + ...INITIAL_ADVANCED_OPTIONS_STATE, + enforceMinimumDatapoints: { + enabled: true, + minimumDatapoints: 12, + }, + }; + const props = getEnforceMinimumDatapointsProps(advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + requireMinPoints: true, + requiredNumPoints: 12, + }); + }); + }); + + describe('getEvaluationProps', () => { + const advancedOptions: AdvancedOptionsState = { + ...INITIAL_ADVANCED_OPTIONS_STATE, + evaluationCadence: { + ...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence, + mode: 'default', + default: { + value: 12, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + }, + }; + + it('for rolling window with non-custom timeframe', () => { + const evaluationWindow: EvaluationWindowState = { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType: 'rolling', + timeframe: '5m0s', + }; + const props = getEvaluationProps(evaluationWindow, advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + kind: 'rolling', + spec: { + evalWindow: '5m0s', + frequency: '12m', + }, + }); + }); + + it('for rolling window with custom timeframe', () => { + const evaluationWindow: EvaluationWindowState = { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType: 'rolling', + timeframe: 'custom', + startingAt: { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + number: '13', + unit: UniversalYAxisUnit.MINUTES, + }, + }; + const props = getEvaluationProps(evaluationWindow, advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + kind: 'rolling', + spec: { + evalWindow: '13m', + frequency: '12m', + }, + }); + }); + + it('for cumulative window with current hour', () => { + const evaluationWindow: EvaluationWindowState = { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType: 'cumulative', + timeframe: 'currentHour', + startingAt: { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + number: '14', + timezone: 'UTC', + }, + }; + const props = getEvaluationProps(evaluationWindow, advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + kind: 'cumulative', + spec: { + schedule: { type: 'hourly', minute: 14 }, + frequency: '12m', + timezone: 'UTC', + }, + }); + }); + + it('for cumulative window with current day', () => { + const evaluationWindow: EvaluationWindowState = { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType: 'cumulative', + timeframe: 'currentDay', + startingAt: { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + time: '15:43:00', + timezone: 'UTC', + }, + }; + const props = getEvaluationProps(evaluationWindow, advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + kind: 'cumulative', + spec: { + schedule: { type: 'daily', hour: 15, minute: 43 }, + frequency: '12m', + timezone: 'UTC', + }, + }); + }); + + it('for cumulative window with current month', () => { + const evaluationWindow: EvaluationWindowState = { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType: 'cumulative', + timeframe: 'currentMonth', + startingAt: { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + number: '17', + timezone: 'UTC', + time: '16:34:00', + }, + }; + const props = getEvaluationProps(evaluationWindow, advancedOptions); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + kind: 'cumulative', + spec: { + schedule: { type: 'monthly', day: 17, hour: 16, minute: 34 }, + frequency: '12m', + timezone: 'UTC', + }, + }); + }); + }); + + describe('buildCreateThresholdAlertRulePayload', () => { + const mockCreateAlertContextState = createMockAlertContextState(); + const INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = { + basicAlertState: mockCreateAlertContextState.alertState, + thresholdState: mockCreateAlertContextState.thresholdState, + advancedOptions: mockCreateAlertContextState.advancedOptions, + evaluationWindow: mockCreateAlertContextState.evaluationWindow, + notificationSettings: mockCreateAlertContextState.notificationSettings, + query: initialQueriesMap.metrics, + alertType: mockCreateAlertContextState.alertType, + }; + + it('verify buildCreateThresholdAlertRulePayload', () => { + const props = buildCreateThresholdAlertRulePayload( + INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS, + ); + expect(props).toBeDefined(); + expect(props).toStrictEqual({ + alert: '', + alertType: 'METRIC_BASED_ALERT', + annotations: { + description: + 'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})', + summary: + 'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})', + }, + condition: { + alertOnAbsent: false, + compositeQuery: { + builderQueries: undefined, + chQueries: undefined, + panelType: 'graph', + promQueries: undefined, + queries: [ + { + spec: { + aggregations: [ + { + metricName: '', + reduceTo: undefined, + spaceAggregation: 'sum', + temporality: undefined, + timeAggregation: 'count', + }, + ], + disabled: false, + filter: { + expression: '', + }, + functions: undefined, + groupBy: undefined, + having: undefined, + legend: undefined, + limit: undefined, + name: 'A', + offset: undefined, + order: undefined, + selectFields: undefined, + signal: 'metrics', + source: '', + stepInterval: null, + }, + type: 'builder_query', + }, + ], + queryType: 'builder', + unit: undefined, + }, + requireMinPoints: false, + selectedQueryName: 'A', + thresholds: { + kind: 'basic', + spec: [ + { + channels: [], + matchType: '1', + name: 'critical', + op: '1', + target: 0, + targetUnit: '', + }, + ], + }, + }, + evaluation: { + kind: 'rolling', + spec: { + evalWindow: '5m0s', + frequency: '1m', + }, + }, + labels: {}, + notificationSettings: { + groupBy: [], + renotify: { + enabled: false, + interval: '1m', + alertStates: [], + }, + usePolicy: false, + }, + ruleType: 'threshold_rule', + schemaVersion: 'v2alpha1', + source: 'http://localhost/', + version: 'v5', + }); + }); + + it('verify for promql query type', () => { + const currentArgs: BuildCreateAlertRulePayloadArgs = { + ...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS, + query: { + ...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS.query, + queryType: EQueryType.PROM, + }, + }; + const props = buildCreateThresholdAlertRulePayload(currentArgs); + expect(props).toBeDefined(); + expect(props.condition.compositeQuery.queryType).toBe('promql'); + expect(props.ruleType).toBe('promql_rule'); + }); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/Footer/utils.tsx b/frontend/src/container/CreateAlertV2/Footer/utils.tsx index 91c13fab972f..9e0fc1ccf235 100644 --- a/frontend/src/container/CreateAlertV2/Footer/utils.tsx +++ b/frontend/src/container/CreateAlertV2/Footer/utils.tsx @@ -17,7 +17,7 @@ import { import { BuildCreateAlertRulePayloadArgs } from './types'; // Get formatted time/unit pairs for create alert api payload -function getFormattedTimeValue(timeValue: number, unit: string): string { +export function getFormattedTimeValue(timeValue: number, unit: string): string { const unitMap: Record = { [UniversalYAxisUnit.SECONDS]: 's', [UniversalYAxisUnit.MINUTES]: 'm', @@ -57,20 +57,18 @@ export function getNotificationSettingsProps( notificationSettings: NotificationSettingsState, ): PostableAlertRuleV2['notificationSettings'] { const notificationSettingsProps: PostableAlertRuleV2['notificationSettings'] = { - notificationGroupBy: notificationSettings.multipleNotifications || [], - alertStates: notificationSettings.reNotification.enabled - ? notificationSettings.reNotification.conditions - : [], - notificationPolicy: notificationSettings.routingPolicies, + groupBy: notificationSettings.multipleNotifications || [], + usePolicy: notificationSettings.routingPolicies, + renotify: { + enabled: notificationSettings.reNotification.enabled, + interval: getFormattedTimeValue( + notificationSettings.reNotification.value, + notificationSettings.reNotification.unit, + ), + alertStates: notificationSettings.reNotification.conditions, + }, }; - if (notificationSettings.reNotification.enabled) { - notificationSettingsProps.renotify = getFormattedTimeValue( - notificationSettings.reNotification.value, - notificationSettings.reNotification.unit, - ); - } - return notificationSettingsProps; } diff --git a/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx b/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx index c099b40a2dbe..4b6e0b17e0b8 100644 --- a/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx +++ b/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx @@ -103,6 +103,7 @@ function NotificationSettings(): JSX.Element { }, }); }} + defaultShowInput={notificationSettings.reNotification.enabled} />
diff --git a/frontend/src/container/CreateAlertV2/NotificationSettings/__tests__/NotificationSettings.test.tsx b/frontend/src/container/CreateAlertV2/NotificationSettings/__tests__/NotificationSettings.test.tsx index e2b74c113ec9..181971676bac 100644 --- a/frontend/src/container/CreateAlertV2/NotificationSettings/__tests__/NotificationSettings.test.tsx +++ b/frontend/src/container/CreateAlertV2/NotificationSettings/__tests__/NotificationSettings.test.tsx @@ -24,6 +24,11 @@ jest.mock( }), ); +jest.mock('container/CreateAlertV2/utils', () => ({ + ...jest.requireActual('container/CreateAlertV2/utils'), + showCondensedLayout: jest.fn().mockReturnValue(false), +})); + const initialNotificationSettings = createMockAlertContextState() .notificationSettings; const mockSetNotificationSettings = jest.fn(); diff --git a/frontend/src/container/CreateAlertV2/QuerySection/styles.scss b/frontend/src/container/CreateAlertV2/QuerySection/styles.scss index bd584770baa0..9b33f9a27945 100644 --- a/frontend/src/container/CreateAlertV2/QuerySection/styles.scss +++ b/frontend/src/container/CreateAlertV2/QuerySection/styles.scss @@ -132,7 +132,7 @@ border-bottom: 0.5px solid var(--bg-vanilla-300); &.active-tab { - background-color: var(--bg-vanilla-100); + background-color: var(--bg-vanilla-300); &:hover { background-color: var(--bg-vanilla-100) !important; diff --git a/frontend/src/container/CreateAlertV2/__tests__/utils.test.tsx b/frontend/src/container/CreateAlertV2/__tests__/utils.test.tsx new file mode 100644 index 000000000000..5ab1e63a1264 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/__tests__/utils.test.tsx @@ -0,0 +1,358 @@ +import { Color } from '@signozhq/design-tokens'; +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; + +import { defaultPostableAlertRuleV2 } from '../constants'; +import { INITIAL_ALERT_STATE } from '../context/constants'; +import { + AlertThresholdMatchType, + AlertThresholdOperator, +} from '../context/types'; +import { + getAdvancedOptionsStateFromAlertDef, + getColorForThreshold, + getCreateAlertLocalStateFromAlertDef, + getEvaluationWindowStateFromAlertDef, + getNotificationSettingsStateFromAlertDef, + getThresholdStateFromAlertDef, + parseGoTime, +} from '../utils'; + +describe('CreateAlertV2 utils', () => { + describe('getColorForThreshold', () => { + it('should return the correct color for the pre-defined threshold', () => { + expect(getColorForThreshold('critical')).toBe(Color.BG_SAKURA_500); + expect(getColorForThreshold('warning')).toBe(Color.BG_AMBER_500); + expect(getColorForThreshold('info')).toBe(Color.BG_ROBIN_500); + }); + }); + + describe('parseGoTime', () => { + it('should return the correct time and unit for the given input', () => { + expect(parseGoTime('1h')).toStrictEqual({ + time: 1, + unit: UniversalYAxisUnit.HOURS, + }); + expect(parseGoTime('1m')).toStrictEqual({ + time: 1, + unit: UniversalYAxisUnit.MINUTES, + }); + expect(parseGoTime('1s')).toStrictEqual({ + time: 1, + unit: UniversalYAxisUnit.SECONDS, + }); + expect(parseGoTime('1h0m')).toStrictEqual({ + time: 1, + unit: UniversalYAxisUnit.HOURS, + }); + }); + }); + + describe('getEvaluationWindowStateFromAlertDef', () => { + it('for rolling window with non-custom timeframe', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + evaluation: { + ...defaultPostableAlertRuleV2.evaluation, + kind: 'rolling', + spec: { + evalWindow: '5m0s', + }, + }, + }; + const props = getEvaluationWindowStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + windowType: 'rolling', + timeframe: '5m0s', + }); + }); + + it('for rolling window with custom timeframe', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + evaluation: { + ...defaultPostableAlertRuleV2.evaluation, + kind: 'rolling', + spec: { + evalWindow: '13m0s', + }, + }, + }; + const props = getEvaluationWindowStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + windowType: 'rolling', + timeframe: 'custom', + startingAt: { + number: '13', + unit: UniversalYAxisUnit.MINUTES, + }, + }); + }); + + it('for cumulative window with current hour', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + evaluation: { + kind: 'cumulative', + spec: { + schedule: { + type: 'hourly', + minute: 14, + }, + }, + }, + }; + const props = getEvaluationWindowStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + windowType: 'cumulative', + timeframe: 'currentHour', + startingAt: { + number: '14', + }, + }); + }); + + it('for cumulative window with current day', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + evaluation: { + ...defaultPostableAlertRuleV2.evaluation, + kind: 'cumulative', + spec: { + schedule: { + type: 'daily', + hour: 14, + minute: 15, + }, + }, + }, + }; + const props = getEvaluationWindowStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + windowType: 'cumulative', + timeframe: 'currentDay', + startingAt: { + time: '14:15:00', + }, + }); + }); + + it('for cumulative window with current month', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + evaluation: { + ...defaultPostableAlertRuleV2.evaluation, + kind: 'cumulative', + spec: { + schedule: { + type: 'monthly', + day: 12, + hour: 16, + minute: 34, + }, + timezone: 'UTC', + }, + }, + }; + const props = getEvaluationWindowStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + windowType: 'cumulative', + timeframe: 'currentMonth', + startingAt: { + number: '12', + timezone: 'UTC', + time: '16:34:00', + }, + }); + }); + }); + + describe('getNotificationSettingsStateFromAlertDef', () => { + it('should return the correct notification settings state for the given alert def', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + notificationSettings: { + groupBy: ['email'], + renotify: { + enabled: true, + interval: '1m0s', + alertStates: ['firing'], + }, + usePolicy: true, + }, + }; + const props = getNotificationSettingsStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + multipleNotifications: ['email'], + reNotification: { + enabled: true, + value: 1, + unit: UniversalYAxisUnit.MINUTES, + conditions: ['firing'], + }, + description: + 'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})', + routingPolicies: true, + }); + }); + + it('when renotification is not provided', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + notificationSettings: { + groupBy: ['email'], + usePolicy: false, + }, + }; + const props = getNotificationSettingsStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + multipleNotifications: ['email'], + reNotification: { + enabled: false, + value: 1, + unit: UniversalYAxisUnit.MINUTES, + conditions: [], + }, + }); + }); + }); + + describe('getAdvancedOptionsStateFromAlertDef', () => { + it('should return the correct advanced options state for the given alert def', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + condition: { + ...defaultPostableAlertRuleV2.condition, + compositeQuery: { + ...defaultPostableAlertRuleV2.condition.compositeQuery, + unit: UniversalYAxisUnit.MINUTES, + }, + requiredNumPoints: 13, + requireMinPoints: true, + alertOnAbsent: true, + absentFor: 12, + }, + evaluation: { + ...defaultPostableAlertRuleV2.evaluation, + spec: { + frequency: '1m0s', + }, + }, + }; + const props = getAdvancedOptionsStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + sendNotificationIfDataIsMissing: { + enabled: true, + toleranceLimit: 12, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + enforceMinimumDatapoints: { + enabled: true, + minimumDatapoints: 13, + }, + evaluationCadence: { + mode: 'default', + default: { + value: 1, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + }, + }); + }); + }); + + describe('getThresholdStateFromAlertDef', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + annotations: { + summary: 'test summary', + description: 'test description', + }, + condition: { + ...defaultPostableAlertRuleV2.condition, + thresholds: { + kind: 'basic', + spec: [ + { + name: 'critical', + target: 1, + targetUnit: UniversalYAxisUnit.MINUTES, + channels: ['email'], + matchType: AlertThresholdMatchType.AT_LEAST_ONCE, + op: AlertThresholdOperator.IS_ABOVE, + }, + ], + }, + selectedQueryName: 'test', + }, + }; + const props = getThresholdStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + selectedQuery: 'test', + operator: AlertThresholdOperator.IS_ABOVE, + matchType: AlertThresholdMatchType.AT_LEAST_ONCE, + thresholds: [ + { + id: expect.any(String), + label: 'critical', + thresholdValue: 1, + recoveryThresholdValue: null, + unit: UniversalYAxisUnit.MINUTES, + color: Color.BG_SAKURA_500, + channels: ['email'], + }, + ], + }); + }); + + describe('getCreateAlertLocalStateFromAlertDef', () => { + it('should return the correct create alert local state for the given alert def', () => { + const args: PostableAlertRuleV2 = { + ...defaultPostableAlertRuleV2, + annotations: { + summary: 'test summary', + description: 'test description', + }, + alert: 'test-alert', + labels: { + severity: 'warning', + team: 'test-team', + }, + condition: { + ...defaultPostableAlertRuleV2.condition, + compositeQuery: { + ...defaultPostableAlertRuleV2.condition.compositeQuery, + unit: UniversalYAxisUnit.MINUTES, + }, + }, + }; + const props = getCreateAlertLocalStateFromAlertDef(args); + expect(props).toBeDefined(); + expect(props).toMatchObject({ + basicAlertState: { + ...INITIAL_ALERT_STATE, + name: 'test-alert', + labels: { + severity: 'warning', + team: 'test-team', + }, + yAxisUnit: UniversalYAxisUnit.MINUTES, + }, + // as we have already verified these utils in their respective tests + thresholdState: expect.any(Object), + advancedOptionsState: expect.any(Object), + evaluationWindowState: expect.any(Object), + notificationSettingsState: expect.any(Object), + }); + }); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/constants.ts b/frontend/src/container/CreateAlertV2/constants.ts new file mode 100644 index 000000000000..5049c8099b4e --- /dev/null +++ b/frontend/src/container/CreateAlertV2/constants.ts @@ -0,0 +1,74 @@ +import { ENTITY_VERSION_V5 } from 'constants/app'; +import { + initialQueryBuilderFormValuesMap, + initialQueryPromQLData, + PANEL_TYPES, +} from 'constants/queryBuilder'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { + NEW_ALERT_SCHEMA_VERSION, + PostableAlertRuleV2, +} from 'types/api/alerts/alertTypesV2'; +import { EQueryType } from 'types/common/dashboard'; + +const defaultAnnotations = { + description: + 'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})', + summary: + 'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}', +}; + +const defaultNotificationSettings: PostableAlertRuleV2['notificationSettings'] = { + groupBy: [], + renotify: { + enabled: false, + interval: '1m', + alertStates: [], + }, + usePolicy: false, +}; + +const defaultEvaluation: PostableAlertRuleV2['evaluation'] = { + kind: 'rolling', + spec: { + evalWindow: '5m0s', + frequency: '1m', + }, +}; + +export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = { + alertType: AlertTypes.METRICS_BASED_ALERT, + version: ENTITY_VERSION_V5, + schemaVersion: NEW_ALERT_SCHEMA_VERSION, + condition: { + compositeQuery: { + builderQueries: { + A: initialQueryBuilderFormValuesMap.metrics, + }, + promQueries: { A: initialQueryPromQLData }, + chQueries: { + A: { + name: 'A', + query: ``, + legend: '', + disabled: false, + }, + }, + queryType: EQueryType.QUERY_BUILDER, + panelType: PANEL_TYPES.TIME_SERIES, + unit: undefined, + }, + selectedQueryName: 'A', + alertOnAbsent: true, + absentFor: 10, + requireMinPoints: false, + requiredNumPoints: 0, + }, + labels: { + severity: 'warning', + }, + annotations: defaultAnnotations, + notificationSettings: defaultNotificationSettings, + alert: 'TEST_ALERT', + evaluation: defaultEvaluation, +}; diff --git a/frontend/src/container/CreateAlertV2/context/constants.ts b/frontend/src/container/CreateAlertV2/context/constants.ts index b6b6a286c919..dabd9b1508fc 100644 --- a/frontend/src/container/CreateAlertV2/context/constants.ts +++ b/frontend/src/container/CreateAlertV2/context/constants.ts @@ -27,7 +27,7 @@ export const INITIAL_ALERT_STATE: AlertState = { export const INITIAL_CRITICAL_THRESHOLD: Threshold = { id: v4(), - label: 'CRITICAL', + label: 'critical', thresholdValue: 0, recoveryThresholdValue: null, unit: '', @@ -37,7 +37,7 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = { export const INITIAL_WARNING_THRESHOLD: Threshold = { id: v4(), - label: 'WARNING', + label: 'warning', thresholdValue: 0, recoveryThresholdValue: null, unit: '', @@ -47,7 +47,7 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = { export const INITIAL_INFO_THRESHOLD: Threshold = { id: v4(), - label: 'INFO', + label: 'info', thresholdValue: 0, recoveryThresholdValue: null, unit: '', @@ -177,7 +177,7 @@ export const NOTIFICATION_MESSAGE_PLACEHOLDER = export const RE_NOTIFICATION_CONDITION_OPTIONS = [ { value: 'firing', label: 'Firing' }, - { value: 'no-data', label: 'No Data' }, + { value: 'nodata', label: 'No Data' }, ]; export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = { diff --git a/frontend/src/container/CreateAlertV2/context/index.tsx b/frontend/src/container/CreateAlertV2/context/index.tsx index fb03ed5870cc..89514dcba112 100644 --- a/frontend/src/container/CreateAlertV2/context/index.tsx +++ b/frontend/src/container/CreateAlertV2/context/index.tsx @@ -2,6 +2,7 @@ import { QueryParams } from 'constants/query'; import { AlertDetectionTypes } from 'container/FormAlertRules'; import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule'; import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule'; +import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import { @@ -50,7 +51,7 @@ export const useCreateAlertState = (): ICreateAlertContextProps => { export function CreateAlertProvider( props: ICreateAlertProviderProps, ): JSX.Element { - const { children } = props; + const { children, initialAlertState, isEditMode, ruleId } = props; const [alertState, setAlertState] = useReducer( alertCreationReducer, @@ -114,6 +115,31 @@ export function CreateAlertProvider( }); }, [alertType]); + useEffect(() => { + if (isEditMode && initialAlertState) { + setAlertState({ + type: 'SET_INITIAL_STATE', + payload: initialAlertState.basicAlertState, + }); + setThresholdState({ + type: 'SET_INITIAL_STATE', + payload: initialAlertState.thresholdState, + }); + setEvaluationWindow({ + type: 'SET_INITIAL_STATE', + payload: initialAlertState.evaluationWindowState, + }); + setAdvancedOptions({ + type: 'SET_INITIAL_STATE', + payload: initialAlertState.advancedOptionsState, + }); + setNotificationSettings({ + type: 'SET_INITIAL_STATE', + payload: initialAlertState.notificationSettingsState, + }); + } + }, [initialAlertState, isEditMode]); + const discardAlertRule = useCallback(() => { setAlertState({ type: 'RESET', @@ -143,6 +169,11 @@ export function CreateAlertProvider( isLoading: isTestingAlertRule, } = useTestAlertRule(); + const { + mutate: updateAlertRule, + isLoading: isUpdatingAlertRule, + } = useUpdateAlertRule(ruleId || ''); + const contextValue: ICreateAlertContextProps = useMemo( () => ({ alertState, @@ -162,6 +193,9 @@ export function CreateAlertProvider( isCreatingAlertRule, testAlertRule, isTestingAlertRule, + updateAlertRule, + isUpdatingAlertRule, + isEditMode: isEditMode || false, }), [ alertState, @@ -176,6 +210,9 @@ export function CreateAlertProvider( isCreatingAlertRule, testAlertRule, isTestingAlertRule, + updateAlertRule, + isUpdatingAlertRule, + isEditMode, ], ); diff --git a/frontend/src/container/CreateAlertV2/context/types.ts b/frontend/src/container/CreateAlertV2/context/types.ts index dcc495375760..8a3da59580a6 100644 --- a/frontend/src/container/CreateAlertV2/context/types.ts +++ b/frontend/src/container/CreateAlertV2/context/types.ts @@ -1,5 +1,6 @@ import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule'; import { TestAlertRuleResponse } from 'api/alerts/testAlertRule'; +import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule'; import { Dayjs } from 'dayjs'; import { Dispatch } from 'react'; import { UseMutateFunction } from 'react-query'; @@ -8,6 +9,8 @@ import { AlertTypes } from 'types/api/alerts/alertTypes'; import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; import { Labels } from 'types/api/alerts/def'; +import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types'; + export interface ICreateAlertContextProps { alertState: AlertState; setAlertState: Dispatch; @@ -36,11 +39,22 @@ export interface ICreateAlertContextProps { unknown >; discardAlertRule: () => void; + isUpdatingAlertRule: boolean; + updateAlertRule: UseMutateFunction< + SuccessResponse | ErrorResponse, + Error, + PostableAlertRuleV2, + unknown + >; + isEditMode: boolean; } export interface ICreateAlertProviderProps { children: React.ReactNode; initialAlertType: AlertTypes; + initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn; + isEditMode?: boolean; + ruleId?: string; } export enum AlertCreationStep { @@ -60,6 +74,7 @@ export type CreateAlertAction = | { type: 'SET_ALERT_NAME'; payload: string } | { type: 'SET_ALERT_LABELS'; payload: Labels } | { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined } + | { type: 'SET_INITIAL_STATE'; payload: AlertState } | { type: 'RESET' }; export interface Threshold { @@ -127,6 +142,7 @@ export type AlertThresholdAction = | { type: 'SET_ALGORITHM'; payload: string } | { type: 'SET_SEASONALITY'; payload: string } | { type: 'SET_THRESHOLDS'; payload: Threshold[] } + | { type: 'SET_INITIAL_STATE'; payload: AlertThresholdState } | { type: 'RESET' }; export interface AdvancedOptionsState { @@ -198,6 +214,7 @@ export type AdvancedOptionsAction = }; } | { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode } + | { type: 'SET_INITIAL_STATE'; payload: AdvancedOptionsState } | { type: 'RESET' }; export interface EvaluationWindowState { @@ -219,6 +236,7 @@ export type EvaluationWindowAction = payload: { time: string; number: string; timezone: string; unit: string }; } | { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode } + | { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState } | { type: 'RESET' }; export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule'; @@ -229,7 +247,7 @@ export interface NotificationSettingsState { enabled: boolean; value: number; unit: string; - conditions: ('firing' | 'no-data')[]; + conditions: ('firing' | 'nodata')[]; }; description: string; routingPolicies: boolean; @@ -246,9 +264,10 @@ export type NotificationSettingsAction = enabled: boolean; value: number; unit: string; - conditions: ('firing' | 'no-data')[]; + conditions: ('firing' | 'nodata')[]; }; } | { type: 'SET_DESCRIPTION'; payload: string } | { type: 'SET_ROUTING_POLICIES'; payload: boolean } + | { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState } | { type: 'RESET' }; diff --git a/frontend/src/container/CreateAlertV2/context/utils.tsx b/frontend/src/container/CreateAlertV2/context/utils.tsx index 623a4f802a92..54d03e00d2c3 100644 --- a/frontend/src/container/CreateAlertV2/context/utils.tsx +++ b/frontend/src/container/CreateAlertV2/context/utils.tsx @@ -53,6 +53,8 @@ export const alertCreationReducer = ( }; case 'RESET': return INITIAL_ALERT_STATE; + case 'SET_INITIAL_STATE': + return action.payload; default: return state; } @@ -119,6 +121,8 @@ export const alertThresholdReducer = ( return { ...state, thresholds: action.payload }; case 'RESET': return INITIAL_ALERT_THRESHOLD_STATE; + case 'SET_INITIAL_STATE': + return action.payload; default: return state; } @@ -174,6 +178,8 @@ export const advancedOptionsReducer = ( ...state, evaluationCadence: { ...state.evaluationCadence, mode: action.payload }, }; + case 'SET_INITIAL_STATE': + return action.payload; case 'RESET': return INITIAL_ADVANCED_OPTIONS_STATE; default: @@ -202,6 +208,8 @@ export const evaluationWindowReducer = ( return { ...state, startingAt: action.payload }; case 'RESET': return INITIAL_EVALUATION_WINDOW_STATE; + case 'SET_INITIAL_STATE': + return action.payload; default: return state; } @@ -222,6 +230,8 @@ export const notificationSettingsReducer = ( return { ...state, routingPolicies: action.payload }; case 'RESET': return INITIAL_NOTIFICATION_SETTINGS_STATE; + case 'SET_INITIAL_STATE': + return action.payload; default: return state; } diff --git a/frontend/src/container/CreateAlertV2/types.ts b/frontend/src/container/CreateAlertV2/types.ts index 257f832bed3c..565b3106696a 100644 --- a/frontend/src/container/CreateAlertV2/types.ts +++ b/frontend/src/container/CreateAlertV2/types.ts @@ -1,5 +1,21 @@ import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { + AdvancedOptionsState, + AlertState, + AlertThresholdState, + EvaluationWindowState, + NotificationSettingsState, +} from './context/types'; + export interface CreateAlertV2Props { alertType: AlertTypes; } + +export interface GetCreateAlertLocalStateFromAlertDefReturn { + basicAlertState: AlertState; + thresholdState: AlertThresholdState; + advancedOptionsState: AdvancedOptionsState; + evaluationWindowState: EvaluationWindowState; + notificationSettingsState: NotificationSettingsState; +} diff --git a/frontend/src/container/CreateAlertV2/utils.tsx b/frontend/src/container/CreateAlertV2/utils.tsx index 171254380699..d0072681b721 100644 --- a/frontend/src/container/CreateAlertV2/utils.tsx +++ b/frontend/src/container/CreateAlertV2/utils.tsx @@ -1,7 +1,31 @@ +import { Color } from '@signozhq/design-tokens'; import { Spin } from 'antd'; +import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils'; +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; +import { getRandomColor } from 'container/ExplorerOptions/utils'; import { createPortal } from 'react-dom'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; +import { v4 } from 'uuid'; import { useCreateAlertState } from './context'; +import { + INITIAL_ADVANCED_OPTIONS_STATE, + INITIAL_ALERT_STATE, + INITIAL_ALERT_THRESHOLD_STATE, + INITIAL_EVALUATION_WINDOW_STATE, + INITIAL_NOTIFICATION_SETTINGS_STATE, +} from './context/constants'; +import { + AdvancedOptionsState, + AlertState, + AlertThresholdMatchType, + AlertThresholdOperator, + AlertThresholdState, + EvaluationWindowState, + NotificationSettingsState, +} from './context/types'; +import { EVALUATION_WINDOW_TIMEFRAME } from './EvaluationSettings/constants'; +import { GetCreateAlertLocalStateFromAlertDefReturn } from './types'; // UI side feature flag export const showNewCreateAlertsPage = (): boolean => @@ -11,12 +35,12 @@ export const showNewCreateAlertsPage = (): boolean => // Layout 1 - Default layout // Layout 2 - Condensed layout export const showCondensedLayout = (): boolean => - localStorage.getItem('showCondensedLayout') === 'true'; + localStorage.getItem('hideCondensedLayout') !== 'true'; export function Spinner(): JSX.Element | null { - const { isCreatingAlertRule } = useCreateAlertState(); + const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState(); - if (!isCreatingAlertRule) return null; + if (!isCreatingAlertRule && !isUpdatingAlertRule) return null; return createPortal(
@@ -25,3 +49,263 @@ export function Spinner(): JSX.Element | null { document.body, ); } + +export function getColorForThreshold(thresholdLabel: string): string { + if (thresholdLabel === 'critical') { + return Color.BG_SAKURA_500; + } + if (thresholdLabel === 'warning') { + return Color.BG_AMBER_500; + } + if (thresholdLabel === 'info') { + return Color.BG_ROBIN_500; + } + return getRandomColor(); +} + +export function parseGoTime( + input: string, +): { time: number; unit: UniversalYAxisUnit } { + const regex = /(\d+)([hms])/g; + const matches = [...input.matchAll(regex)]; + + const nonZero = matches.find(([, value]) => parseInt(value, 10) > 0); + if (!nonZero) { + return { time: 1, unit: UniversalYAxisUnit.MINUTES }; + } + + const time = parseInt(nonZero[1], 10); + const unitMap: Record = { + h: UniversalYAxisUnit.HOURS, + m: UniversalYAxisUnit.MINUTES, + s: UniversalYAxisUnit.SECONDS, + }; + + return { time, unit: unitMap[nonZero[2]] }; +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function getEvaluationWindowStateFromAlertDef( + alertDef: PostableAlertRuleV2, +): EvaluationWindowState { + const windowType = alertDef.evaluation?.kind as 'rolling' | 'cumulative'; + + function getRollingWindowTimeframe(): string { + if ( + // Default values for rolling window + EVALUATION_WINDOW_TIMEFRAME.rolling + .map((option) => option.value) + .includes(alertDef.evaluation?.spec?.evalWindow || '') + ) { + return alertDef.evaluation?.spec?.evalWindow || ''; + } + return 'custom'; + } + + function getCumulativeWindowTimeframe(): string { + switch (alertDef.evaluation?.spec?.schedule?.type) { + case 'hourly': + return 'currentHour'; + case 'daily': + return 'currentDay'; + case 'monthly': + return 'currentMonth'; + default: + return 'currentHour'; + } + } + + function convertApiFieldToTime(hour: number, minute: number): string { + return `${hour.toString().padStart(2, '0')}:${minute + .toString() + .padStart(2, '0')}:00`; + } + + function getCumulativeWindowStartingAt(): EvaluationWindowState['startingAt'] { + const timeframe = getCumulativeWindowTimeframe(); + if (timeframe === 'currentHour') { + return { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + number: alertDef.evaluation?.spec?.schedule?.minute?.toString() || '0', + }; + } + if (timeframe === 'currentDay') { + return { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + time: convertApiFieldToTime( + alertDef.evaluation?.spec?.schedule?.hour || 0, + alertDef.evaluation?.spec?.schedule?.minute || 0, + ), + timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value, + }; + } + if (timeframe === 'currentMonth') { + return { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + number: alertDef.evaluation?.spec?.schedule?.day?.toString() || '0', + timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value, + time: convertApiFieldToTime( + alertDef.evaluation?.spec?.schedule?.hour || 0, + alertDef.evaluation?.spec?.schedule?.minute || 0, + ), + }; + } + return INITIAL_EVALUATION_WINDOW_STATE.startingAt; + } + + if (windowType === 'rolling') { + const timeframe = getRollingWindowTimeframe(); + if (timeframe === 'custom') { + return { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType, + timeframe, + startingAt: { + ...INITIAL_EVALUATION_WINDOW_STATE.startingAt, + number: parseGoTime( + alertDef.evaluation?.spec?.evalWindow || '1m', + ).time.toString(), + unit: parseGoTime(alertDef.evaluation?.spec?.evalWindow || '1m').unit, + }, + }; + } + return { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType, + timeframe, + }; + } + + return { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType, + timeframe: getCumulativeWindowTimeframe(), + startingAt: getCumulativeWindowStartingAt(), + }; +} + +export function getNotificationSettingsStateFromAlertDef( + alertDef: PostableAlertRuleV2, +): NotificationSettingsState { + const description = alertDef.annotations?.description || ''; + const multipleNotifications = alertDef.notificationSettings?.groupBy || []; + const routingPolicies = alertDef.notificationSettings?.usePolicy || false; + + const reNotificationEnabled = + alertDef.notificationSettings?.renotify?.enabled || false; + const reNotificationConditions = + alertDef.notificationSettings?.renotify?.alertStates?.map( + (state) => state as 'firing' | 'nodata', + ) || []; + const reNotificationValue = alertDef.notificationSettings?.renotify + ? parseGoTime(alertDef.notificationSettings.renotify.interval || '1m').time + : 1; + const reNotificationUnit = alertDef.notificationSettings?.renotify + ? parseGoTime(alertDef.notificationSettings.renotify.interval || '1m').unit + : UniversalYAxisUnit.MINUTES; + + return { + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + description, + multipleNotifications, + routingPolicies, + reNotification: { + enabled: reNotificationEnabled, + conditions: reNotificationConditions, + value: reNotificationValue, + unit: reNotificationUnit, + }, + }; +} + +export function getAdvancedOptionsStateFromAlertDef( + alertDef: PostableAlertRuleV2, +): AdvancedOptionsState { + return { + ...INITIAL_ADVANCED_OPTIONS_STATE, + sendNotificationIfDataIsMissing: { + ...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing, + toleranceLimit: alertDef.condition.absentFor || 0, + enabled: alertDef.condition.alertOnAbsent || false, + }, + enforceMinimumDatapoints: { + ...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints, + minimumDatapoints: alertDef.condition.requiredNumPoints || 0, + enabled: alertDef.condition.requireMinPoints || false, + }, + evaluationCadence: { + ...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence, + mode: 'default', + default: { + ...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence.default, + value: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').time, + timeUnit: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').unit, + }, + }, + }; +} + +export function getThresholdStateFromAlertDef( + alertDef: PostableAlertRuleV2, +): AlertThresholdState { + return { + ...INITIAL_ALERT_THRESHOLD_STATE, + thresholds: + alertDef.condition.thresholds?.spec.map((threshold) => ({ + id: v4(), + label: threshold.name, + thresholdValue: threshold.target, + recoveryThresholdValue: null, + unit: threshold.targetUnit, + color: getColorForThreshold(threshold.name), + channels: threshold.channels, + })) || [], + selectedQuery: alertDef.condition.selectedQueryName || '', + operator: + (alertDef.condition.thresholds?.spec[0].op as AlertThresholdOperator) || + AlertThresholdOperator.IS_ABOVE, + matchType: + (alertDef.condition.thresholds?.spec[0] + .matchType as AlertThresholdMatchType) || + AlertThresholdMatchType.AT_LEAST_ONCE, + }; +} + +export function getCreateAlertLocalStateFromAlertDef( + alertDef: PostableAlertRuleV2 | undefined, +): GetCreateAlertLocalStateFromAlertDefReturn { + if (!alertDef) { + return { + basicAlertState: INITIAL_ALERT_STATE, + thresholdState: INITIAL_ALERT_THRESHOLD_STATE, + advancedOptionsState: INITIAL_ADVANCED_OPTIONS_STATE, + evaluationWindowState: INITIAL_EVALUATION_WINDOW_STATE, + notificationSettingsState: INITIAL_NOTIFICATION_SETTINGS_STATE, + }; + } + // Basic alert state + const basicAlertState: AlertState = { + ...INITIAL_ALERT_STATE, + name: alertDef.alert, + labels: alertDef.labels || {}, + yAxisUnit: alertDef.condition.compositeQuery.unit, + }; + + const thresholdState = getThresholdStateFromAlertDef(alertDef); + + const advancedOptionsState = getAdvancedOptionsStateFromAlertDef(alertDef); + + const evaluationWindowState = getEvaluationWindowStateFromAlertDef(alertDef); + + const notificationSettingsState = getNotificationSettingsStateFromAlertDef( + alertDef, + ); + + return { + basicAlertState, + thresholdState, + advancedOptionsState, + evaluationWindowState, + notificationSettingsState, + }; +} diff --git a/frontend/src/container/EditAlertV2/EditAlertV2.tsx b/frontend/src/container/EditAlertV2/EditAlertV2.tsx new file mode 100644 index 000000000000..f02b2e363b37 --- /dev/null +++ b/frontend/src/container/EditAlertV2/EditAlertV2.tsx @@ -0,0 +1,56 @@ +import '../CreateAlertV2/CreateAlertV2.styles.scss'; + +import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; +import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; +import { useMemo } from 'react'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; + +import AlertCondition from '../CreateAlertV2/AlertCondition'; +import { buildInitialAlertDef } from '../CreateAlertV2/context/utils'; +import EvaluationSettings from '../CreateAlertV2/EvaluationSettings'; +import Footer from '../CreateAlertV2/Footer'; +import NotificationSettings from '../CreateAlertV2/NotificationSettings'; +import QuerySection from '../CreateAlertV2/QuerySection'; +import { showCondensedLayout, Spinner } from '../CreateAlertV2/utils'; + +interface EditAlertV2Props { + alertType?: AlertTypes; + initialAlert: PostableAlertRuleV2; +} + +function EditAlertV2({ + alertType = AlertTypes.METRICS_BASED_ALERT, + initialAlert, +}: EditAlertV2Props): JSX.Element { + const currentQueryToRedirect = useMemo(() => { + const basicAlertDef = buildInitialAlertDef(alertType); + return mapQueryDataFromApi( + initialAlert?.condition.compositeQuery || + basicAlertDef.condition.compositeQuery, + ); + }, [initialAlert, alertType]); + + useShareBuilderUrl({ defaultValue: currentQueryToRedirect }); + + const showCondensedLayoutFlag = showCondensedLayout(); + + return ( + <> + +
+ + + {!showCondensedLayoutFlag ? : null} + +
+