import './FormAlertRules.styles.scss'; import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons'; import { Button, Col, FormInstance, Modal, SelectProps, Tooltip, Typography, } from 'antd'; import saveAlertApi from 'api/alerts/save'; import testAlertApi from 'api/alerts/testAlert'; import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn'; import { alertHelpMessage } from 'components/facingIssueBtn/util'; import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag'; import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag'; import { BuilderUnitsFilter } from 'container/QueryBuilder/filters'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; import { MESSAGE, useIsFeatureDisabled } from 'hooks/useFeatureFlag'; import { useNotifications } from 'hooks/useNotifications'; import useUrlQuery from 'hooks/useUrlQuery'; import history from 'lib/history'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; import { isEqual } from 'lodash-es'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from 'react-query'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertDef, defaultEvalWindow, defaultMatchType, } from 'types/api/alerts/def'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { EQueryType } from 'types/common/dashboard'; import { GlobalReducer } from 'types/reducer/globalTime'; import BasicInfo from './BasicInfo'; import ChartPreview from './ChartPreview'; import QuerySection from './QuerySection'; import RuleOptions from './RuleOptions'; import { ActionButton, ButtonContainer, MainFormContainer, PanelContainer, StepContainer, StyledLeftContainer, } from './styles'; import UserGuide from './UserGuide'; import { getSelectedQueryOptions } from './utils'; // eslint-disable-next-line sonarjs/cognitive-complexity function FormAlertRules({ alertType, formInstance, initialValue, ruleId, }: FormAlertRuleProps): JSX.Element { // init namespace for translations const { t } = useTranslation('alerts'); const { selectedTime: globalSelectedInterval } = useSelector< AppState, GlobalReducer >((state) => state.globalTime); const urlQuery = useUrlQuery(); // In case of alert the panel types should always be "Graph" only const panelType = PANEL_TYPES.TIME_SERIES; const { currentQuery, stagedQuery, handleRunQuery, handleSetConfig, initialDataSource, redirectWithQueryBuilderData, } = useQueryBuilder(); useEffect(() => { handleSetConfig(panelType || PANEL_TYPES.TIME_SERIES, initialDataSource); }, [handleSetConfig, initialDataSource, panelType]); // use query client const ruleCache = useQueryClient(); const isNewRule = ruleId === 0; const [loading, setLoading] = useState(false); // alertDef holds the form values to be posted const [alertDef, setAlertDef] = useState(initialValue); const [yAxisUnit, setYAxisUnit] = useState(currentQuery.unit || ''); useEffect(() => { if (!isEqual(currentQuery.unit, yAxisUnit)) { setYAxisUnit(currentQuery.unit || ''); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentQuery.unit]); // initQuery contains initial query when component was mounted const initQuery = useMemo(() => initialValue.condition.compositeQuery, [ initialValue, ]); const queryOptions = useMemo(() => { const queryConfig: Record SelectProps['options']> = { [EQueryType.QUERY_BUILDER]: () => [ ...(getSelectedQueryOptions(currentQuery.builder.queryData) || []), ...(getSelectedQueryOptions(currentQuery.builder.queryFormulas) || []), ], [EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql), [EQueryType.CLICKHOUSE]: () => getSelectedQueryOptions(currentQuery.clickhouse_sql), }; return queryConfig[currentQuery.queryType]?.() || []; }, [currentQuery]); const sq = useMemo(() => mapQueryDataFromApi(initQuery), [initQuery]); useShareBuilderUrl(sq); useEffect(() => { const broadcastToSpecificChannels = (initialValue && initialValue.preferredChannels && initialValue.preferredChannels.length > 0) || isNewRule; setAlertDef({ ...initialValue, broadcastToAll: !broadcastToSpecificChannels, }); }, [initialValue, isNewRule]); useEffect(() => { // Set selectedQueryName based on the length of queryOptions const selectedQueryName = alertDef?.condition?.selectedQueryName; if ( !selectedQueryName || !queryOptions.some((option) => option.value === selectedQueryName) ) { setAlertDef((def) => ({ ...def, condition: { ...def.condition, selectedQueryName: queryOptions.length > 0 ? String(queryOptions[0].value) : undefined, }, })); } }, [alertDef, currentQuery?.queryType, queryOptions]); const onCancelHandler = useCallback(() => { urlQuery.delete(QueryParams.compositeQuery); urlQuery.delete(QueryParams.panelTypes); urlQuery.delete(QueryParams.ruleId); urlQuery.delete(QueryParams.relativeTime); history.replace(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`); }, [urlQuery]); // onQueryCategoryChange handles changes to query category // in state as well as sets additional defaults const onQueryCategoryChange = (val: EQueryType): void => { const element = document.getElementById('top'); if (element) { element.scrollIntoView({ behavior: 'smooth' }); } if (val === EQueryType.PROM) { setAlertDef({ ...alertDef, condition: { ...alertDef.condition, matchType: defaultMatchType, }, evalWindow: defaultEvalWindow, }); } const query: Query = { ...currentQuery, queryType: val }; // update step interval is removed from here as if the user enters // any value we will use that rather than auto update redirectWithQueryBuilderData(query); }; const { notifications } = useNotifications(); const validatePromParams = useCallback((): boolean => { let retval = true; if (currentQuery.queryType !== EQueryType.PROM) return retval; if (!currentQuery.promql || currentQuery.promql.length === 0) { notifications.error({ message: 'Error', description: t('promql_required'), }); return false; } currentQuery.promql.forEach((item) => { if (item.query === '') { notifications.error({ message: 'Error', description: t('promql_required'), }); retval = false; } }); return retval; }, [t, currentQuery, notifications]); const validateChQueryParams = useCallback((): boolean => { let retval = true; if (currentQuery.queryType !== EQueryType.CLICKHOUSE) return retval; if ( !currentQuery.clickhouse_sql || currentQuery.clickhouse_sql.length === 0 ) { notifications.error({ message: 'Error', description: t('chquery_required'), }); return false; } currentQuery.clickhouse_sql.forEach((item) => { if (item.query === '') { notifications.error({ message: 'Error', description: t('chquery_required'), }); retval = false; } }); return retval; }, [t, currentQuery, notifications]); const validateQBParams = useCallback((): boolean => { if (currentQuery.queryType !== EQueryType.QUERY_BUILDER) return true; if ( !currentQuery.builder.queryData || currentQuery.builder.queryData?.length === 0 ) { notifications.error({ message: 'Error', description: t('condition_required'), }); return false; } if (alertDef.condition?.target !== 0 && !alertDef.condition?.target) { notifications.error({ message: 'Error', description: t('target_missing'), }); return false; } return true; }, [t, alertDef, currentQuery, notifications]); const isFormValid = useCallback((): boolean => { if (!alertDef.alert || alertDef.alert === '') { return false; } if (!validatePromParams()) { return false; } if (!validateChQueryParams()) { return false; } return validateQBParams(); }, [validateQBParams, validateChQueryParams, alertDef, validatePromParams]); const preparePostData = (): AlertDef => { const postableAlert: AlertDef = { ...alertDef, preferredChannels: alertDef.broadcastToAll ? [] : alertDef.preferredChannels, alertType, source: window?.location.toString(), ruleType: currentQuery.queryType === EQueryType.PROM ? 'promql_rule' : 'threshold_rule', condition: { ...alertDef.condition, compositeQuery: { builderQueries: { ...mapQueryDataToApi(currentQuery.builder.queryData, 'queryName').data, ...mapQueryDataToApi(currentQuery.builder.queryFormulas, 'queryName') .data, }, promQueries: mapQueryDataToApi(currentQuery.promql, 'name').data, chQueries: mapQueryDataToApi(currentQuery.clickhouse_sql, 'name').data, queryType: currentQuery.queryType, panelType: panelType || initQuery.panelType, unit: currentQuery.unit, }, }, }; return postableAlert; }; const memoizedPreparePostData = useCallback(preparePostData, [ currentQuery, alertDef, alertType, initQuery, panelType, ]); const isAlertAvailable = useIsFeatureDisabled( FeatureKeys.QUERY_BUILDER_ALERTS, ); const saveRule = useCallback(async () => { if (!isFormValid()) { return; } const postableAlert = memoizedPreparePostData(); setLoading(true); try { const apiReq = ruleId && ruleId > 0 ? { data: postableAlert, id: ruleId } : { data: postableAlert }; const response = await saveAlertApi(apiReq); if (response.statusCode === 200) { notifications.success({ message: 'Success', description: !ruleId || ruleId === 0 ? t('rule_created') : t('rule_edited'), }); // invalidate rule in cache ruleCache.invalidateQueries(['ruleId', ruleId]); // eslint-disable-next-line sonarjs/no-identical-functions setTimeout(() => { urlQuery.delete(QueryParams.compositeQuery); urlQuery.delete(QueryParams.panelTypes); urlQuery.delete(QueryParams.ruleId); urlQuery.delete(QueryParams.relativeTime); history.replace(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`); }, 2000); } else { notifications.error({ message: 'Error', description: response.error || t('unexpected_error'), }); } } catch (e) { notifications.error({ message: 'Error', description: t('unexpected_error'), }); } setLoading(false); }, [ isFormValid, memoizedPreparePostData, ruleId, notifications, t, ruleCache, urlQuery, ]); const onSaveHandler = useCallback(async () => { const content = ( {' '} {t('confirm_save_content_part1')}{' '} {' '} {t('confirm_save_content_part2')} ); Modal.confirm({ icon: , title: t('confirm_save_title'), centered: true, content, onOk: saveRule, className: 'create-alert-modal', }); }, [t, saveRule, currentQuery]); const onTestRuleHandler = useCallback(async () => { if (!isFormValid()) { return; } const postableAlert = memoizedPreparePostData(); setLoading(true); try { const response = await testAlertApi({ data: postableAlert }); if (response.statusCode === 200) { const { payload } = response; if (payload?.alertCount === 0) { notifications.error({ message: 'Error', description: t('no_alerts_found'), }); } else { notifications.success({ message: 'Success', description: t('rule_test_fired'), }); } } else { notifications.error({ message: 'Error', description: response.error || t('unexpected_error'), }); } } catch (e) { notifications.error({ message: 'Error', description: t('unexpected_error'), }); } setLoading(false); }, [t, isFormValid, memoizedPreparePostData, notifications]); const renderBasicInfo = (): JSX.Element => ( ); const renderQBChartPreview = (): JSX.Element => ( } name="" query={stagedQuery} selectedInterval={globalSelectedInterval} alertDef={alertDef} yAxisUnit={yAxisUnit || ''} graphType={panelType || PANEL_TYPES.TIME_SERIES} /> ); const renderPromAndChQueryChartPreview = (): JSX.Element => ( } name="Chart Preview" query={stagedQuery} alertDef={alertDef} selectedInterval={globalSelectedInterval} yAxisUnit={yAxisUnit || ''} graphType={panelType || PANEL_TYPES.TIME_SERIES} /> ); const isAlertNameMissing = !formInstance.getFieldValue('alert'); const isAlertAvailableToSave = isAlertAvailable && currentQuery.queryType === EQueryType.QUERY_BUILDER && alertType !== AlertTypes.METRICS_BASED_ALERT; const onUnitChangeHandler = (value: string): void => { setYAxisUnit(value); // reset target unit setAlertDef((def) => ({ ...def, condition: { ...def.condition, targetUnit: undefined, }, })); }; const isChannelConfigurationValid = alertDef?.broadcastToAll || (alertDef.preferredChannels && alertDef.preferredChannels.length > 0); const isRuleCreated = !ruleId || ruleId === 0; function handleRedirection(option: AlertTypes): void { let url = ''; switch (option) { case AlertTypes.METRICS_BASED_ALERT: url = 'https://signoz.io/docs/alerts-management/metrics-based-alerts/?utm_source=product&utm_medium=alert-creation-page#examples'; break; case AlertTypes.LOGS_BASED_ALERT: url = 'https://signoz.io/docs/alerts-management/log-based-alerts/?utm_source=product&utm_medium=alert-creation-page#examples'; break; case AlertTypes.TRACES_BASED_ALERT: url = 'https://signoz.io/docs/alerts-management/trace-based-alerts/?utm_source=product&utm_medium=alert-creation-page#examples'; break; case AlertTypes.EXCEPTIONS_BASED_ALERT: url = 'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page#examples'; break; default: break; } window.open(url, '_blank'); } return ( <> {Element} {currentQuery.queryType === EQueryType.QUERY_BUILDER && renderQBChartPreview()} {currentQuery.queryType === EQueryType.PROM && renderPromAndChQueryChartPreview()} {currentQuery.queryType === EQueryType.CLICKHOUSE && renderPromAndChQueryChartPreview()} handleRunQuery(true)} alertDef={alertDef} panelType={panelType || PANEL_TYPES.TIME_SERIES} key={currentQuery.queryType} /> {renderBasicInfo()} } disabled={ isAlertNameMissing || isAlertAvailableToSave || !isChannelConfigurationValid } > {isNewRule ? t('button_createrule') : t('button_savechanges')} {' '} {t('button_testrule')} {ruleId === 0 && t('button_cancelchanges')} {ruleId > 0 && t('button_discard')}
); } FormAlertRules.defaultProps = { alertType: AlertTypes.METRICS_BASED_ALERT, }; interface FormAlertRuleProps { alertType?: AlertTypes; formInstance: FormInstance; initialValue: AlertDef; ruleId: number; } export default FormAlertRules;