diff --git a/frontend/public/locales/en-GB/alerts.json b/frontend/public/locales/en-GB/alerts.json index 5c5c3b851e..8dd0ccbe47 100644 --- a/frontend/public/locales/en-GB/alerts.json +++ b/frontend/public/locales/en-GB/alerts.json @@ -56,6 +56,7 @@ "option_last": "last", "option_above": "above", "option_below": "below", + "option_above_below": "above/below", "option_equal": "is equal to", "option_notequal": "not equal to", "button_query": "Query", @@ -110,6 +111,8 @@ "choose_alert_type": "Choose a type for the alert", "metric_based_alert": "Metric based Alert", "metric_based_alert_desc": "Send a notification when a condition occurs in the metric data.", + "anomaly_based_alert": "Anomaly based Alert", + "anomaly_based_alert_desc": "Send a notification when a condition occurs in the metric data.", "log_based_alert": "Log-based Alert", "log_based_alert_desc": "Send a notification when a condition occurs in the logs data.", "traces_based_alert": "Trace-based Alert", diff --git a/frontend/public/locales/en-GB/rules.json b/frontend/public/locales/en-GB/rules.json index 9ac3641c7a..63ae437d7f 100644 --- a/frontend/public/locales/en-GB/rules.json +++ b/frontend/public/locales/en-GB/rules.json @@ -43,6 +43,7 @@ "option_last": "last", "option_above": "above", "option_below": "below", + "option_above_below": "above/below", "option_equal": "is equal to", "option_notequal": "not equal to", "button_query": "Query", diff --git a/frontend/public/locales/en/alerts.json b/frontend/public/locales/en/alerts.json index 3ad8390731..6adeb7382b 100644 --- a/frontend/public/locales/en/alerts.json +++ b/frontend/public/locales/en/alerts.json @@ -13,9 +13,12 @@ "button_no": "No", "remove_label_confirm": "This action will remove all the labels. Do you want to proceed?", "remove_label_success": "Labels cleared", - "alert_form_step1": "Step 1 - Define the metric", - "alert_form_step2": "Step 2 - Define Alert Conditions", - "alert_form_step3": "Step 3 - Alert Configuration", + "alert_form_step1": "Choose a detection method", + "alert_form_step2": "Define the metric", + "alert_form_step3": "Define Alert Conditions", + "alert_form_step4": "Alert Configuration", + "threshold_alert_desc": "An alert is triggered whenever a metric deviates from an expected threshold.", + "anomaly_detection_alert_desc": "An alert is triggered whenever a metric deviates from an expected pattern.", "metric_query_max_limit": "Can not create query. You can create maximum of 5 queries", "confirm_save_title": "Save Changes", "confirm_save_content_part1": "Your alert built with", @@ -35,6 +38,7 @@ "button_cancelchanges": "Cancel", "button_discard": "Discard", "text_condition1": "Send a notification when", + "text_condition1_anomaly": "Send notification when the observed value for", "text_condition2": "the threshold", "text_condition3": "during the last", "option_1min": "1 min", @@ -56,6 +60,7 @@ "option_last": "last", "option_above": "above", "option_below": "below", + "option_above_below": "above/below", "option_equal": "is equal to", "option_notequal": "not equal to", "button_query": "Query", @@ -109,7 +114,9 @@ "user_tooltip_more_help": "More details on how to create alerts", "choose_alert_type": "Choose a type for the alert", "metric_based_alert": "Metric based Alert", + "anomaly_based_alert": "Anomaly based Alert", "metric_based_alert_desc": "Send a notification when a condition occurs in the metric data.", + "anomaly_based_alert_desc": "Send a notification when a condition occurs in the metric data.", "log_based_alert": "Log-based Alert", "log_based_alert_desc": "Send a notification when a condition occurs in the logs data.", "traces_based_alert": "Trace-based Alert", diff --git a/frontend/public/locales/en/rules.json b/frontend/public/locales/en/rules.json index 9ac3641c7a..63ae437d7f 100644 --- a/frontend/public/locales/en/rules.json +++ b/frontend/public/locales/en/rules.json @@ -43,6 +43,7 @@ "option_last": "last", "option_above": "above", "option_below": "below", + "option_above_below": "above/below", "option_equal": "is equal to", "option_notequal": "not equal to", "button_query": "Query", diff --git a/frontend/src/constants/alerts.ts b/frontend/src/constants/alerts.ts index 3565ded3d7..425926ea47 100644 --- a/frontend/src/constants/alerts.ts +++ b/frontend/src/constants/alerts.ts @@ -2,6 +2,7 @@ import { AlertTypes } from 'types/api/alerts/alertTypes'; import { DataSource } from 'types/common/queryBuilder'; export const ALERTS_DATA_SOURCE_MAP: Record = { + [AlertTypes.ANOMALY_BASED_ALERT]: DataSource.METRICS, [AlertTypes.METRICS_BASED_ALERT]: DataSource.METRICS, [AlertTypes.LOGS_BASED_ALERT]: DataSource.LOGS, [AlertTypes.TRACES_BASED_ALERT]: DataSource.TRACES, diff --git a/frontend/src/constants/query.ts b/frontend/src/constants/query.ts index 3ee0a39634..2214e9487c 100644 --- a/frontend/src/constants/query.ts +++ b/frontend/src/constants/query.ts @@ -36,4 +36,5 @@ export enum QueryParams { topic = 'topic', partition = 'partition', selectedTimelineQuery = 'selectedTimelineQuery', + ruleType = 'ruleType', } diff --git a/frontend/src/constants/queryFunctionOptions.ts b/frontend/src/constants/queryFunctionOptions.ts index 4a7b3b0413..df0ad0ed0e 100644 --- a/frontend/src/constants/queryFunctionOptions.ts +++ b/frontend/src/constants/queryFunctionOptions.ts @@ -3,6 +3,10 @@ import { QueryFunctionsTypes } from 'types/common/queryBuilder'; import { SelectOption } from 'types/common/select'; export const metricQueryFunctionOptions: SelectOption[] = [ + { + value: QueryFunctionsTypes.ANOMALY, + label: 'Anomaly', + }, { value: QueryFunctionsTypes.CUTOFF_MIN, label: 'Cut Off Min', @@ -67,6 +71,10 @@ export const metricQueryFunctionOptions: SelectOption[] = [ value: QueryFunctionsTypes.TIME_SHIFT, label: 'Time Shift', }, + { + value: QueryFunctionsTypes.TIME_SHIFT, + label: 'Time Shift', + }, ]; export const logsQueryFunctionOptions: SelectOption[] = [ @@ -80,10 +88,15 @@ interface QueryFunctionConfigType { showInput: boolean; inputType?: string; placeholder?: string; + disabled?: boolean; }; } export const queryFunctionsTypesConfig: QueryFunctionConfigType = { + anomaly: { + showInput: false, + disabled: true, + }, cutOffMin: { showInput: true, inputType: 'text', diff --git a/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.styles.scss b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.styles.scss new file mode 100644 index 0000000000..308dfb68c9 --- /dev/null +++ b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.styles.scss @@ -0,0 +1,92 @@ +.anomaly-alert-evaluation-view { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 8px; + width: 100%; + height: 100%; + + .anomaly-alert-evaluation-view-chart-section { + height: 100%; + width: 100%; + + display: flex; + justify-content: center; + align-items: center; + + &.has-multi-series-data { + width: calc(100% - 240px); + } + + .anomaly-alert-evaluation-view-no-data-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + } + } + + .anomaly-alert-evaluation-view-series-selection { + display: flex; + flex-direction: column; + gap: 8px; + width: 240px; + padding: 8px; + height: 100%; + + .anomaly-alert-evaluation-view-series-list { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + + .anomaly-alert-evaluation-view-series-list-title { + margin-top: 12px; + font-size: 13px !important; + font-weight: 400; + } + + .anomaly-alert-evaluation-view-series-list-items { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + overflow-y: auto; + + .anomaly-alert-evaluation-view-series-list-item { + display: flex; + flex-direction: row; + gap: 8px; + + cursor: pointer; + } + } + } + } + + .uplot { + .u-title { + text-align: center; + font-size: 18px; + font-weight: 400; + display: flex; + height: 40px; + font-size: 13px; + align-items: center; + } + + .u-legend { + display: flex; + margin-top: 16px; + + tbody { + width: 100%; + + .u-series { + display: inline-flex; + } + } + } + } +} diff --git a/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.tsx b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.tsx new file mode 100644 index 0000000000..8357d9bd57 --- /dev/null +++ b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.tsx @@ -0,0 +1,280 @@ +import 'uplot/dist/uPlot.min.css'; +import './AnomalyAlertEvaluationView.styles.scss'; + +import { Checkbox, Typography } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import getAxes from 'lib/uPlotLib/utils/getAxes'; +import { getUplotChartDataForAnomalyDetection } from 'lib/uPlotLib/utils/getUplotChartData'; +import { getYAxisScaleForAnomalyDetection } from 'lib/uPlotLib/utils/getYAxisScale'; +import { LineChart } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import uPlot from 'uplot'; + +function UplotChart({ + data, + options, + chartRef, +}: { + data: any; + options: any; + chartRef: any; +}): JSX.Element { + const plotInstance = useRef(null); + + useEffect(() => { + if (plotInstance.current) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + plotInstance.current.destroy(); + } + + if (data && data.length > 0) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line new-cap + plotInstance.current = new uPlot(options, data, chartRef.current); + } + + return (): void => { + if (plotInstance.current) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + plotInstance.current.destroy(); + } + }; + }, [data, options, chartRef]); + + return
; +} + +function AnomalyAlertEvaluationView({ + data, + yAxisUnit, +}: { + data: any; + yAxisUnit: string; +}): JSX.Element { + const { spline } = uPlot.paths; + // eslint-disable-next-line @typescript-eslint/naming-convention + const _spline = spline ? spline() : undefined; + const chartRef = useRef(null); + const isDarkMode = useIsDarkMode(); + const [seriesData, setSeriesData] = useState({}); + const [selectedSeries, setSelectedSeries] = useState(null); + + const graphRef = useRef(null); + const dimensions = useResizeObserver(graphRef); + + useEffect(() => { + const chartData = getUplotChartDataForAnomalyDetection(data); + setSeriesData(chartData); + }, [data]); + + useEffect(() => { + const seriesKeys = Object.keys(seriesData); + if (seriesKeys.length === 1) { + setSelectedSeries(seriesKeys[0]); // Automatically select if only one series + } else { + setSelectedSeries(null); // Default to "Show All" if multiple series + } + }, [seriesData]); + + const handleSeriesChange = (series: string | null): void => { + setSelectedSeries(series); + }; + + const bandsPlugin = { + hooks: { + draw: [ + (u: any): void => { + if (!selectedSeries) return; + + const { ctx } = u; + const upperBandIdx = 3; + const lowerBandIdx = 4; + + const xData = u.data[0]; + const yUpperData = u.data[upperBandIdx]; + const yLowerData = u.data[lowerBandIdx]; + + const strokeStyle = + u.series[1]?.stroke || seriesData[selectedSeries].color; + const fillStyle = + typeof strokeStyle === 'string' + ? strokeStyle.replace(')', ', 0.1)') + : 'rgba(255, 255, 255, 0.1)'; + + ctx.beginPath(); + const firstX = u.valToPos(xData[0], 'x', true); + const firstUpperY = u.valToPos(yUpperData[0], 'y', true); + ctx.moveTo(firstX, firstUpperY); + + for (let i = 0; i < xData.length; i++) { + const x = u.valToPos(xData[i], 'x', true); + const y = u.valToPos(yUpperData[i], 'y', true); + ctx.lineTo(x, y); + } + + for (let i = xData.length - 1; i >= 0; i--) { + const x = u.valToPos(xData[i], 'x', true); + const y = u.valToPos(yLowerData[i], 'y', true); + ctx.lineTo(x, y); + } + + ctx.closePath(); + ctx.fillStyle = fillStyle; + ctx.fill(); + }, + ], + }, + }; + + const allSeries = Object.keys(seriesData); + + const initialData = allSeries.length + ? [ + seriesData[allSeries[0]].data[0], // Shared X-axis + ...allSeries.map((key) => seriesData[key].data[1]), // Map through Y-axis data for all series + ] + : []; + + const options = { + width: dimensions.width, + height: dimensions.height - 36, + plugins: [bandsPlugin], + focus: { + alpha: 0.3, + }, + series: [ + { + label: 'Time', + }, + ...(selectedSeries + ? [ + { + label: `Main Series`, + stroke: seriesData[selectedSeries].color, + width: 2, + show: true, + paths: _spline, + }, + { + label: `Predicted Value`, + stroke: seriesData[selectedSeries].color, + width: 1, + dash: [2, 2], + show: true, + paths: _spline, + }, + { + label: `Upper Band`, + stroke: 'transparent', + show: false, + paths: _spline, + }, + { + label: `Lower Band`, + stroke: 'transparent', + show: false, + paths: _spline, + }, + ] + : allSeries.map((seriesKey) => ({ + label: seriesKey, + stroke: seriesData[seriesKey].color, + width: 2, + show: true, + paths: _spline, + }))), + ], + scales: { + x: { + time: true, + }, + y: { + ...getYAxisScaleForAnomalyDetection({ + seriesData, + selectedSeries, + initialData, + yAxisUnit, + }), + }, + }, + grid: { + show: true, + }, + legend: { + show: true, + }, + axes: getAxes(isDarkMode, yAxisUnit), + }; + + return ( +
+
1 ? 'has-multi-series-data' : '' + }`} + ref={graphRef} + > + {allSeries.length > 0 ? ( + + ) : ( +
+ + + No Data +
+ )} +
+ + {allSeries.length > 1 && ( +
+ {allSeries.length > 1 && ( +
+ + Select a series + +
+ handleSeriesChange(null)} + > + Show All + + + {allSeries.map((seriesKey) => ( + handleSeriesChange(seriesKey)} + > + {seriesKey} + + ))} +
+
+ )} +
+ )} +
+ ); +} + +export default AnomalyAlertEvaluationView; diff --git a/frontend/src/container/AnomalyAlertEvaluationView/index.tsx b/frontend/src/container/AnomalyAlertEvaluationView/index.tsx new file mode 100644 index 0000000000..b99070cf6d --- /dev/null +++ b/frontend/src/container/AnomalyAlertEvaluationView/index.tsx @@ -0,0 +1,3 @@ +import AnomalyAlertEvaluationView from './AnomalyAlertEvaluationView'; + +export default AnomalyAlertEvaluationView; diff --git a/frontend/src/container/CreateAlertRule/SelectAlertType/config.ts b/frontend/src/container/CreateAlertRule/SelectAlertType/config.ts index c973684e67..b03b7dd767 100644 --- a/frontend/src/container/CreateAlertRule/SelectAlertType/config.ts +++ b/frontend/src/container/CreateAlertRule/SelectAlertType/config.ts @@ -4,6 +4,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes'; import { OptionType } from './types'; export const getOptionList = (t: TFunction): OptionType[] => [ + { + title: t('anomaly_based_alert'), + selection: AlertTypes.ANOMALY_BASED_ALERT, + description: t('anomaly_based_alert_desc'), + }, { title: t('metric_based_alert'), selection: AlertTypes.METRICS_BASED_ALERT, diff --git a/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx b/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx index 48075649b7..1a6d14f379 100644 --- a/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx +++ b/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx @@ -17,6 +17,10 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element { function handleRedirection(option: AlertTypes): void { let url = ''; switch (option) { + case AlertTypes.ANOMALY_BASED_ALERT: + url = + 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples'; + break; case AlertTypes.METRICS_BASED_ALERT: url = 'https://signoz.io/docs/alerts-management/metrics-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples'; diff --git a/frontend/src/container/CreateAlertRule/defaults.ts b/frontend/src/container/CreateAlertRule/defaults.ts index f9735e7644..9393c7bc1a 100644 --- a/frontend/src/container/CreateAlertRule/defaults.ts +++ b/frontend/src/container/CreateAlertRule/defaults.ts @@ -7,9 +7,11 @@ import { import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertDef, + defaultAlgorithm, defaultCompareOp, defaultEvalWindow, defaultMatchType, + defaultSeasonality, } from 'types/api/alerts/def'; import { EQueryType } from 'types/common/dashboard'; @@ -46,6 +48,8 @@ export const alertDefaults: AlertDef = { }, op: defaultCompareOp, matchType: defaultMatchType, + algorithm: defaultAlgorithm, + seasonality: defaultSeasonality, }, labels: { severity: 'warning', @@ -145,6 +149,7 @@ export const exceptionAlertDefaults: AlertDef = { }; export const ALERTS_VALUES_MAP: Record = { + [AlertTypes.ANOMALY_BASED_ALERT]: alertDefaults, [AlertTypes.METRICS_BASED_ALERT]: alertDefaults, [AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults, [AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults, diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx index f7e491cd70..55c4fdc090 100644 --- a/frontend/src/container/CreateAlertRule/index.tsx +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -2,7 +2,7 @@ import { Form, Row } from 'antd'; import logEvent from 'api/common/logEvent'; import { ENTITY_VERSION_V4 } from 'constants/app'; import { QueryParams } from 'constants/query'; -import FormAlertRules from 'container/FormAlertRules'; +import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; import history from 'lib/history'; import { useEffect, useState } from 'react'; @@ -45,6 +45,7 @@ function CreateRules(): JSX.Element { const onSelectType = (typ: AlertTypes): void => { setAlertType(typ); + switch (typ) { case AlertTypes.LOGS_BASED_ALERT: setInitValues(logAlertDefaults); @@ -55,13 +56,37 @@ function CreateRules(): JSX.Element { case AlertTypes.EXCEPTIONS_BASED_ALERT: setInitValues(exceptionAlertDefaults); break; + case AlertTypes.ANOMALY_BASED_ALERT: + setInitValues({ + ...alertDefaults, + version: version || ENTITY_VERSION_V4, + ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT, + }); + break; default: setInitValues({ ...alertDefaults, version: version || ENTITY_VERSION_V4, + ruleType: AlertDetectionTypes.THRESHOLD_ALERT, }); } - queryParams.set(QueryParams.alertType, typ); + + queryParams.set( + QueryParams.alertType, + typ === AlertTypes.ANOMALY_BASED_ALERT + ? AlertTypes.METRICS_BASED_ALERT + : typ, + ); + + if (typ === AlertTypes.ANOMALY_BASED_ALERT) { + queryParams.set( + QueryParams.ruleType, + AlertDetectionTypes.ANOMALY_DETECTION_ALERT, + ); + } else { + queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT); + } + const generatedUrl = `${location.pathname}?${queryParams.toString()}`; history.replace(generatedUrl); }; diff --git a/frontend/src/container/EditRules/index.tsx b/frontend/src/container/EditRules/index.tsx index 206c9d2d3e..b6a32615a6 100644 --- a/frontend/src/container/EditRules/index.tsx +++ b/frontend/src/container/EditRules/index.tsx @@ -7,18 +7,16 @@ function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element { const [formInstance] = Form.useForm(); return ( -
- -
+ ); } diff --git a/frontend/src/container/FormAlertRules/BasicInfo.tsx b/frontend/src/container/FormAlertRules/BasicInfo.tsx index 177c9e0fde..6e686d1188 100644 --- a/frontend/src/container/FormAlertRules/BasicInfo.tsx +++ b/frontend/src/container/FormAlertRules/BasicInfo.tsx @@ -96,7 +96,7 @@ function BasicInfo({ return ( <> - {t('alert_form_step3')} + {t('alert_form_step4')} - {headline} - -
- {queryResponse.isLoading && ( - - )} - {(queryResponse?.isError || queryResponse?.error) && ( - - {' '} - {queryResponse.error.message || t('preview_chart_unexpected_error')} - - )} - - {chartData && !queryResponse.isError && !queryResponse.isLoading && ( - - )} -
- +
+ + {headline} + +
+ {queryResponse.isLoading && ( + + )} + {(queryResponse?.isError || queryResponse?.error) && ( + + + {queryResponse.error.message || t('preview_chart_unexpected_error')} + + )} + + {chartDataAvailable && !isAnomalyDetectionAlert && ( + + )} + + {chartDataAvailable && + isAnomalyDetectionAlert && + queryResponse?.data?.payload?.data?.resultType === 'anomaly' && ( + + )} +
+
+
); } diff --git a/frontend/src/container/FormAlertRules/FormAlertRules.styles.scss b/frontend/src/container/FormAlertRules/FormAlertRules.styles.scss index 25b37923b5..22a272ca93 100644 --- a/frontend/src/container/FormAlertRules/FormAlertRules.styles.scss +++ b/frontend/src/container/FormAlertRules/FormAlertRules.styles.scss @@ -21,6 +21,70 @@ } } +.steps-container { + width: 80%; +} + +.qb-chart-preview-container { + margin-bottom: 1rem; + display: flex; + flex-direction: row; + gap: 1rem; +} + +.overview-header { + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + + .alert-type-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .alert-type-title { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + + .ant-typography { + margin: 0; + } + } +} + +.chart-preview-container { + position: relative; + display: flex; + flex-direction: row; + gap: 1rem; + + .ant-card { + flex: 1; + } +} + +.detection-method-container { + margin: 24px 0; + + .ant-tabs-nav { + margin-bottom: 0; + + .ant-tabs-tab { + padding: 12px 0; + } + } + + .detection-method-description { + padding: 8px 0; + font-size: 12px; + } +} + .info-help-btns { display: grid; grid-template-columns: auto auto; diff --git a/frontend/src/container/FormAlertRules/QuerySection.tsx b/frontend/src/container/FormAlertRules/QuerySection.tsx index ff958fd253..12248f7357 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.tsx +++ b/frontend/src/container/FormAlertRules/QuerySection.tsx @@ -222,7 +222,7 @@ function QuerySection({ }; return ( <> - {t('alert_form_step1')} + {t('alert_form_step2')}
{renderTabs(alertType)}
{renderQuerySection(currentTab)} diff --git a/frontend/src/container/FormAlertRules/RuleOptions.styles.scss b/frontend/src/container/FormAlertRules/RuleOptions.styles.scss new file mode 100644 index 0000000000..5d2a24f0b3 --- /dev/null +++ b/frontend/src/container/FormAlertRules/RuleOptions.styles.scss @@ -0,0 +1,6 @@ +.rule-definition { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} diff --git a/frontend/src/container/FormAlertRules/RuleOptions.tsx b/frontend/src/container/FormAlertRules/RuleOptions.tsx index ed0792d9d5..9ab9a66678 100644 --- a/frontend/src/container/FormAlertRules/RuleOptions.tsx +++ b/frontend/src/container/FormAlertRules/RuleOptions.tsx @@ -1,3 +1,5 @@ +import './RuleOptions.styles.scss'; + import { Checkbox, Collapse, @@ -18,14 +20,17 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useTranslation } from 'react-i18next'; import { AlertDef, + defaultAlgorithm, defaultCompareOp, defaultEvalWindow, defaultFrequency, defaultMatchType, + defaultSeasonality, } from 'types/api/alerts/def'; import { EQueryType } from 'types/common/dashboard'; import { popupContainer } from 'utils/selectPopupContainer'; +import { AlertDetectionTypes } from '.'; import { FormContainer, InlineSelect, @@ -43,6 +48,8 @@ function RuleOptions({ const { t } = useTranslation('alerts'); const { currentQuery } = useQueryBuilder(); + const { ruleType } = alertDef; + const handleMatchOptChange = (value: string | unknown): void => { const m = (value as string) || alertDef.condition?.matchType; setAlertDef({ @@ -86,8 +93,19 @@ function RuleOptions({ > {t('option_above')} {t('option_below')} - {t('option_equal')} - {t('option_notequal')} + + {/* hide equal and not eqaul in case of analmoy based alert */} + + {ruleType !== 'anomaly_rule' && ( + <> + {t('option_equal')} + {t('option_notequal')} + + )} + + {ruleType === 'anomaly_rule' && ( + {t('option_above_below')} + )} ); @@ -101,9 +119,14 @@ function RuleOptions({ > {t('option_atleastonce')} {t('option_allthetimes')} - {t('option_onaverage')} - {t('option_intotal')} - {t('option_last')} + + {ruleType !== 'anomaly_rule' && ( + <> + {t('option_onaverage')} + {t('option_intotal')} + {t('option_last')} + + )} ); @@ -115,6 +138,28 @@ function RuleOptions({ }); }; + const onChangeAlgorithm = (value: string | unknown): void => { + const alg = (value as string) || alertDef.condition.algorithm; + setAlertDef({ + ...alertDef, + condition: { + ...alertDef.condition, + algorithm: alg, + }, + }); + }; + + const onChangeSeasonality = (value: string | unknown): void => { + const seasonality = (value as string) || alertDef.condition.seasonality; + setAlertDef({ + ...alertDef, + condition: { + ...alertDef.condition, + seasonality, + }, + }); + }; + const renderEvalWindows = (): JSX.Element => ( ); + const renderAlgorithms = (): JSX.Element => ( + + Standard + + ); + + const renderSeasonality = (): JSX.Element => ( + + Hourly + Daily + Weekly + + ); + const renderThresholdRuleOpts = (): JSX.Element => ( @@ -166,6 +237,39 @@ function RuleOptions({ ); + const renderAnomalyRuleOpts = ( + onChange: InputNumberProps['onChange'], + ): JSX.Element => ( + + + {t('text_condition1_anomaly')} + + {t('text_condition3')} {renderEvalWindows()} + is + e.currentTarget.blur()} + /> + deviations + {renderCompareOps()} + the predicted data + {renderMatchOpts()} + using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '} + seasonality + + + ); + const renderPromRuleOptions = (): JSX.Element => ( @@ -245,36 +349,46 @@ function RuleOptions({ return ( <> - {t('alert_form_step2')} + {t('alert_form_step3')} - {queryCategory === EQueryType.PROM - ? renderPromRuleOptions() - : renderThresholdRuleOpts()} + {queryCategory === EQueryType.PROM && renderPromRuleOptions()} + {queryCategory !== EQueryType.PROM && + ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && ( + <>{renderAnomalyRuleOpts(onChange)} + )} + + {queryCategory !== EQueryType.PROM && + ruleType === AlertDetectionTypes.THRESHOLD_ALERT && + renderThresholdRuleOpts()} - - - e.currentTarget.blur()} - /> - - - - + + + )} + diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 2947b2a0b3..1515f52a0a 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -3,7 +3,6 @@ import './FormAlertRules.styles.scss'; import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons'; import { Button, - Col, FormInstance, Modal, SelectProps, @@ -13,8 +12,6 @@ import { import saveAlertApi from 'api/alerts/save'; import testAlertApi from 'api/alerts/testAlert'; import logEvent from 'api/common/logEvent'; -import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport'; -import { alertHelpMessage } from 'components/LaunchChatSupport/util'; import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts'; import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; @@ -33,6 +30,8 @@ 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 { BellDot, ExternalLink } from 'lucide-react'; +import Tabs2 from 'periscope/components/Tabs2'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from 'react-query'; @@ -44,7 +43,11 @@ import { defaultEvalWindow, defaultMatchType, } from 'types/api/alerts/def'; -import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { + IBuilderQuery, + Query, + QueryFunctionProps, +} from 'types/api/queryBuilder/queryBuilderData'; import { EQueryType } from 'types/common/dashboard'; import { GlobalReducer } from 'types/reducer/globalTime'; @@ -56,13 +59,16 @@ import { ActionButton, ButtonContainer, MainFormContainer, - PanelContainer, StepContainer, - StyledLeftContainer, + StepHeading, } from './styles'; -import UserGuide from './UserGuide'; import { getSelectedQueryOptions } from './utils'; +export enum AlertDetectionTypes { + THRESHOLD_ALERT = 'threshold_rule', + ANOMALY_DETECTION_ALERT = 'anomaly_rule', +} + // eslint-disable-next-line sonarjs/cognitive-complexity function FormAlertRules({ alertType, @@ -86,6 +92,7 @@ function FormAlertRules({ const { currentQuery, stagedQuery, + handleSetQueryData, handleRunQuery, handleSetConfig, initialDataSource, @@ -108,6 +115,12 @@ function FormAlertRules({ const [alertDef, setAlertDef] = useState(initialValue); const [yAxisUnit, setYAxisUnit] = useState(currentQuery.unit || ''); + const alertTypeFromURL = urlQuery.get(QueryParams.ruleType); + + const [detectionMethod, setDetectionMethod] = useState( + AlertDetectionTypes.THRESHOLD_ALERT, + ); + useEffect(() => { if (!isEqual(currentQuery.unit, yAxisUnit)) { setYAxisUnit(currentQuery.unit || ''); @@ -138,6 +151,45 @@ function FormAlertRules({ useShareBuilderUrl(sq); + const updateFunctions = (data: IBuilderQuery): QueryFunctionProps[] => { + const anomalyFunction = { + name: 'anomaly', + args: [], + namedArgs: { z_score_threshold: 9 }, + }; + const functions = data.functions || []; + + if (alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) { + // Add anomaly if not already present + if (!functions.some((func) => func.name === 'anomaly')) { + functions.push(anomalyFunction); + } + } else { + // Remove anomaly if present + const index = functions.findIndex((func) => func.name === 'anomaly'); + if (index !== -1) { + functions.splice(index, 1); + } + } + + return functions; + }; + + const updateFunctionsBasedOnAlertType = (): void => { + for (let index = 0; index < currentQuery.builder.queryData.length; index++) { + const queryData = currentQuery.builder.queryData[index]; + + const updatedFunctions = updateFunctions(queryData); + queryData.functions = updatedFunctions; + handleSetQueryData(index, queryData); + } + }; + + useEffect(() => { + updateFunctionsBasedOnAlertType(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detectionMethod, alertDef, currentQuery.builder.queryData.length]); + useEffect(() => { const broadcastToSpecificChannels = (initialValue && @@ -145,11 +197,22 @@ function FormAlertRules({ initialValue.preferredChannels.length > 0) || isNewRule; + let ruleType = AlertDetectionTypes.THRESHOLD_ALERT; + + if (initialValue.ruleType) { + ruleType = initialValue.ruleType as AlertDetectionTypes; + } else if (alertTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) { + ruleType = AlertDetectionTypes.ANOMALY_DETECTION_ALERT; + } + setAlertDef({ ...initialValue, broadcastToAll: !broadcastToSpecificChannels, + ruleType, }); - }, [initialValue, isNewRule]); + + setDetectionMethod(ruleType); + }, [initialValue, isNewRule, alertTypeFromURL]); useEffect(() => { // Set selectedQueryName based on the length of queryOptions @@ -300,12 +363,15 @@ function FormAlertRules({ const postableAlert: AlertDef = { ...alertDef, preferredChannels: alertDef.broadcastToAll ? [] : alertDef.preferredChannels, - alertType, + alertType: + alertType === AlertTypes.ANOMALY_BASED_ALERT + ? AlertTypes.METRICS_BASED_ALERT + : alertType, source: window?.location.toString(), ruleType: currentQuery.queryType === EQueryType.PROM ? 'promql_rule' - : 'threshold_rule', + : alertDef.ruleType, condition: { ...alertDef.condition, compositeQuery: { @@ -322,6 +388,12 @@ function FormAlertRules({ }, }, }; + + if (alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) { + postableAlert.condition.algorithm = alertDef.condition.algorithm; + postableAlert.condition.seasonality = alertDef.condition.seasonality; + } + return postableAlert; }; @@ -585,63 +657,97 @@ function FormAlertRules({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - 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; - } - logEvent('Alert: Check example alert clicked', { - dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes], - isNewRule: !ruleId || ruleId === 0, - ruleId, - queryType: currentQuery.queryType, - link: url, - }); - window.open(url, '_blank'); - } + const tabs = [ + { + value: AlertDetectionTypes.THRESHOLD_ALERT, + label: 'Threshold Alert', + }, + { + value: AlertDetectionTypes.ANOMALY_DETECTION_ALERT, + label: 'Anomaly Detection Alert', + }, + ]; + + const handleDetectionMethodChange = (value: any): void => { + setAlertDef((def) => ({ + ...def, + ruleType: value, + })); + + setDetectionMethod(value); + }; return ( <> {Element} - - - +
+
+
+ {isNewRule && ( + + + + {alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT && + 'Anomaly Detection Alert'} + {alertDef.alertType === AlertTypes.METRICS_BASED_ALERT && + 'Metrics Based Alert'} + {alertDef.alertType === AlertTypes.LOGS_BASED_ALERT && + 'Logs Based Alert'} + {alertDef.alertType === AlertTypes.TRACES_BASED_ALERT && + 'Traces Based Alert'} + {alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT && + 'Exceptions Based Alert'} + + )} +
+ + +
+ + +
{currentQuery.queryType === EQueryType.QUERY_BUILDER && renderQBChartPreview()} {currentQuery.queryType === EQueryType.PROM && renderPromAndChQueryChartPreview()} {currentQuery.queryType === EQueryType.CLICKHOUSE && renderPromAndChQueryChartPreview()} +
- - - + + + + +
+ {alertDef.alertType === AlertTypes.METRICS_BASED_ALERT && ( +
+ {t('alert_form_step1')} + + + +
+ {detectionMethod === AlertDetectionTypes.ANOMALY_DETECTION_ALERT + ? t('anomaly_detection_alert_desc') + : t('threshold_alert_desc')} +
+
+ )} {renderBasicInfo()} - - - } - disabled={ - isAlertNameMissing || - isAlertAvailableToSave || - !isChannelConfigurationValid || - queryStatus === 'error' - } - > - {isNewRule ? t('button_createrule') : t('button_savechanges')} - - - +
+ + } disabled={ isAlertNameMissing || + isAlertAvailableToSave || !isChannelConfigurationValid || queryStatus === 'error' } - type="default" - onClick={onTestRuleHandler} > - {' '} - {t('button_testrule')} + {isNewRule ? t('button_createrule') : t('button_savechanges')} - - {ruleId === 0 && t('button_cancelchanges')} - {ruleId > 0 && t('button_discard')} - - -
- - - -
- - -
- - + {' '} + {t('button_testrule')} + + + {ruleId === 0 && t('button_cancelchanges')} + {ruleId > 0 && t('button_discard')} + + + +
); } diff --git a/frontend/src/container/FormAlertRules/styles.ts b/frontend/src/container/FormAlertRules/styles.ts index 11205c0ab4..d282a484a2 100644 --- a/frontend/src/container/FormAlertRules/styles.ts +++ b/frontend/src/container/FormAlertRules/styles.ts @@ -1,13 +1,9 @@ -import { Button, Card, Col, Form, Input, Row, Select, Typography } from 'antd'; +import { Button, Card, Col, Form, Input, Select, Typography } from 'antd'; import styled from 'styled-components'; const { TextArea } = Input; const { Item } = Form; -export const PanelContainer = styled(Row)` - flex-wrap: nowrap; -`; - export const StyledLeftContainer = styled(Col)` &&& { margin-right: 1rem; diff --git a/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx b/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx index a341d5db55..1611e36444 100644 --- a/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx +++ b/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx @@ -33,7 +33,7 @@ export default function Function({ handleDeleteFunction, }: FunctionProps): JSX.Element { const isDarkMode = useIsDarkMode(); - const { showInput } = queryFunctionsTypesConfig[funcData.name]; + const { showInput, disabled } = queryFunctionsTypesConfig[funcData.name]; let functionValue; @@ -62,6 +62,7 @@ export default function Function({