feat: anomaly detection UI (#5916)

* feat: anamoly detection - initial ui

* feat: anamoly detection - oct 10

* feat: use antd checkbox

* feat: handle multiple series

* feat: handle chart height on switch btwn threshold / anomaly

* feat: do not update url on detection type change

* chore: pr clean up

* feat: remove chart container background
This commit is contained in:
Yunus M 2024-10-14 10:31:02 +05:30 committed by GitHub
parent ecae842fa1
commit 2180118094
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1171 additions and 221 deletions

View File

@ -56,6 +56,7 @@
"option_last": "last", "option_last": "last",
"option_above": "above", "option_above": "above",
"option_below": "below", "option_below": "below",
"option_above_below": "above/below",
"option_equal": "is equal to", "option_equal": "is equal to",
"option_notequal": "not equal to", "option_notequal": "not equal to",
"button_query": "Query", "button_query": "Query",
@ -110,6 +111,8 @@
"choose_alert_type": "Choose a type for the alert", "choose_alert_type": "Choose a type for the alert",
"metric_based_alert": "Metric based Alert", "metric_based_alert": "Metric based Alert",
"metric_based_alert_desc": "Send a notification when a condition occurs in the metric data.", "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": "Log-based Alert",
"log_based_alert_desc": "Send a notification when a condition occurs in the logs data.", "log_based_alert_desc": "Send a notification when a condition occurs in the logs data.",
"traces_based_alert": "Trace-based Alert", "traces_based_alert": "Trace-based Alert",

View File

@ -43,6 +43,7 @@
"option_last": "last", "option_last": "last",
"option_above": "above", "option_above": "above",
"option_below": "below", "option_below": "below",
"option_above_below": "above/below",
"option_equal": "is equal to", "option_equal": "is equal to",
"option_notequal": "not equal to", "option_notequal": "not equal to",
"button_query": "Query", "button_query": "Query",

View File

@ -13,9 +13,12 @@
"button_no": "No", "button_no": "No",
"remove_label_confirm": "This action will remove all the labels. Do you want to proceed?", "remove_label_confirm": "This action will remove all the labels. Do you want to proceed?",
"remove_label_success": "Labels cleared", "remove_label_success": "Labels cleared",
"alert_form_step1": "Step 1 - Define the metric", "alert_form_step1": "Choose a detection method",
"alert_form_step2": "Step 2 - Define Alert Conditions", "alert_form_step2": "Define the metric",
"alert_form_step3": "Step 3 - Alert Configuration", "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", "metric_query_max_limit": "Can not create query. You can create maximum of 5 queries",
"confirm_save_title": "Save Changes", "confirm_save_title": "Save Changes",
"confirm_save_content_part1": "Your alert built with", "confirm_save_content_part1": "Your alert built with",
@ -35,6 +38,7 @@
"button_cancelchanges": "Cancel", "button_cancelchanges": "Cancel",
"button_discard": "Discard", "button_discard": "Discard",
"text_condition1": "Send a notification when", "text_condition1": "Send a notification when",
"text_condition1_anomaly": "Send notification when the observed value for",
"text_condition2": "the threshold", "text_condition2": "the threshold",
"text_condition3": "during the last", "text_condition3": "during the last",
"option_1min": "1 min", "option_1min": "1 min",
@ -56,6 +60,7 @@
"option_last": "last", "option_last": "last",
"option_above": "above", "option_above": "above",
"option_below": "below", "option_below": "below",
"option_above_below": "above/below",
"option_equal": "is equal to", "option_equal": "is equal to",
"option_notequal": "not equal to", "option_notequal": "not equal to",
"button_query": "Query", "button_query": "Query",
@ -109,7 +114,9 @@
"user_tooltip_more_help": "More details on how to create alerts", "user_tooltip_more_help": "More details on how to create alerts",
"choose_alert_type": "Choose a type for the alert", "choose_alert_type": "Choose a type for the alert",
"metric_based_alert": "Metric based 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.", "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": "Log-based Alert",
"log_based_alert_desc": "Send a notification when a condition occurs in the logs data.", "log_based_alert_desc": "Send a notification when a condition occurs in the logs data.",
"traces_based_alert": "Trace-based Alert", "traces_based_alert": "Trace-based Alert",

View File

@ -43,6 +43,7 @@
"option_last": "last", "option_last": "last",
"option_above": "above", "option_above": "above",
"option_below": "below", "option_below": "below",
"option_above_below": "above/below",
"option_equal": "is equal to", "option_equal": "is equal to",
"option_notequal": "not equal to", "option_notequal": "not equal to",
"button_query": "Query", "button_query": "Query",

View File

@ -2,6 +2,7 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
export const ALERTS_DATA_SOURCE_MAP: Record<AlertTypes, DataSource> = { export const ALERTS_DATA_SOURCE_MAP: Record<AlertTypes, DataSource> = {
[AlertTypes.ANOMALY_BASED_ALERT]: DataSource.METRICS,
[AlertTypes.METRICS_BASED_ALERT]: DataSource.METRICS, [AlertTypes.METRICS_BASED_ALERT]: DataSource.METRICS,
[AlertTypes.LOGS_BASED_ALERT]: DataSource.LOGS, [AlertTypes.LOGS_BASED_ALERT]: DataSource.LOGS,
[AlertTypes.TRACES_BASED_ALERT]: DataSource.TRACES, [AlertTypes.TRACES_BASED_ALERT]: DataSource.TRACES,

View File

@ -36,4 +36,5 @@ export enum QueryParams {
topic = 'topic', topic = 'topic',
partition = 'partition', partition = 'partition',
selectedTimelineQuery = 'selectedTimelineQuery', selectedTimelineQuery = 'selectedTimelineQuery',
ruleType = 'ruleType',
} }

View File

@ -3,6 +3,10 @@ import { QueryFunctionsTypes } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select'; import { SelectOption } from 'types/common/select';
export const metricQueryFunctionOptions: SelectOption<string, string>[] = [ export const metricQueryFunctionOptions: SelectOption<string, string>[] = [
{
value: QueryFunctionsTypes.ANOMALY,
label: 'Anomaly',
},
{ {
value: QueryFunctionsTypes.CUTOFF_MIN, value: QueryFunctionsTypes.CUTOFF_MIN,
label: 'Cut Off Min', label: 'Cut Off Min',
@ -67,6 +71,10 @@ export const metricQueryFunctionOptions: SelectOption<string, string>[] = [
value: QueryFunctionsTypes.TIME_SHIFT, value: QueryFunctionsTypes.TIME_SHIFT,
label: 'Time Shift', label: 'Time Shift',
}, },
{
value: QueryFunctionsTypes.TIME_SHIFT,
label: 'Time Shift',
},
]; ];
export const logsQueryFunctionOptions: SelectOption<string, string>[] = [ export const logsQueryFunctionOptions: SelectOption<string, string>[] = [
@ -80,10 +88,15 @@ interface QueryFunctionConfigType {
showInput: boolean; showInput: boolean;
inputType?: string; inputType?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean;
}; };
} }
export const queryFunctionsTypesConfig: QueryFunctionConfigType = { export const queryFunctionsTypesConfig: QueryFunctionConfigType = {
anomaly: {
showInput: false,
disabled: true,
},
cutOffMin: { cutOffMin: {
showInput: true, showInput: true,
inputType: 'text', inputType: 'text',

View File

@ -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;
}
}
}
}
}

View File

@ -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 <div ref={chartRef} />;
}
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<HTMLDivElement>(null);
const isDarkMode = useIsDarkMode();
const [seriesData, setSeriesData] = useState<any>({});
const [selectedSeries, setSelectedSeries] = useState<string | null>(null);
const graphRef = useRef<HTMLDivElement>(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 (
<div className="anomaly-alert-evaluation-view">
<div
className={`anomaly-alert-evaluation-view-chart-section ${
allSeries.length > 1 ? 'has-multi-series-data' : ''
}`}
ref={graphRef}
>
{allSeries.length > 0 ? (
<UplotChart
data={selectedSeries ? seriesData[selectedSeries].data : initialData}
options={options}
chartRef={chartRef}
/>
) : (
<div className="anomaly-alert-evaluation-view-no-data-container">
<LineChart size={48} strokeWidth={0.5} />
<Typography>No Data</Typography>
</div>
)}
</div>
{allSeries.length > 1 && (
<div className="anomaly-alert-evaluation-view-series-selection">
{allSeries.length > 1 && (
<div className="anomaly-alert-evaluation-view-series-list">
<Typography.Title
level={5}
className="anomaly-alert-evaluation-view-series-list-title"
>
Select a series
</Typography.Title>
<div className="anomaly-alert-evaluation-view-series-list-items">
<Checkbox
className="anomaly-alert-evaluation-view-series-list-item"
type="checkbox"
name="series"
value="all"
checked={selectedSeries === null}
onChange={(): void => handleSeriesChange(null)}
>
Show All
</Checkbox>
{allSeries.map((seriesKey) => (
<Checkbox
className="anomaly-alert-evaluation-view-series-list-item"
key={seriesKey}
type="checkbox"
name="series"
value={seriesKey}
checked={selectedSeries === seriesKey}
onChange={(): void => handleSeriesChange(seriesKey)}
>
{seriesKey}
</Checkbox>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
export default AnomalyAlertEvaluationView;

View File

@ -0,0 +1,3 @@
import AnomalyAlertEvaluationView from './AnomalyAlertEvaluationView';
export default AnomalyAlertEvaluationView;

View File

@ -4,6 +4,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { OptionType } from './types'; import { OptionType } from './types';
export const getOptionList = (t: TFunction): OptionType[] => [ 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'), title: t('metric_based_alert'),
selection: AlertTypes.METRICS_BASED_ALERT, selection: AlertTypes.METRICS_BASED_ALERT,

View File

@ -17,6 +17,10 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
function handleRedirection(option: AlertTypes): void { function handleRedirection(option: AlertTypes): void {
let url = ''; let url = '';
switch (option) { 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: case AlertTypes.METRICS_BASED_ALERT:
url = url =
'https://signoz.io/docs/alerts-management/metrics-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples'; 'https://signoz.io/docs/alerts-management/metrics-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples';

View File

@ -7,9 +7,11 @@ import {
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
import { import {
AlertDef, AlertDef,
defaultAlgorithm,
defaultCompareOp, defaultCompareOp,
defaultEvalWindow, defaultEvalWindow,
defaultMatchType, defaultMatchType,
defaultSeasonality,
} from 'types/api/alerts/def'; } from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
@ -46,6 +48,8 @@ export const alertDefaults: AlertDef = {
}, },
op: defaultCompareOp, op: defaultCompareOp,
matchType: defaultMatchType, matchType: defaultMatchType,
algorithm: defaultAlgorithm,
seasonality: defaultSeasonality,
}, },
labels: { labels: {
severity: 'warning', severity: 'warning',
@ -145,6 +149,7 @@ export const exceptionAlertDefaults: AlertDef = {
}; };
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = { export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
[AlertTypes.ANOMALY_BASED_ALERT]: alertDefaults,
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults, [AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults, [AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults, [AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,

View File

@ -2,7 +2,7 @@ import { Form, Row } from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V4 } from 'constants/app'; import { ENTITY_VERSION_V4 } from 'constants/app';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import FormAlertRules from 'container/FormAlertRules'; import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import history from 'lib/history'; import history from 'lib/history';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -45,6 +45,7 @@ function CreateRules(): JSX.Element {
const onSelectType = (typ: AlertTypes): void => { const onSelectType = (typ: AlertTypes): void => {
setAlertType(typ); setAlertType(typ);
switch (typ) { switch (typ) {
case AlertTypes.LOGS_BASED_ALERT: case AlertTypes.LOGS_BASED_ALERT:
setInitValues(logAlertDefaults); setInitValues(logAlertDefaults);
@ -55,13 +56,37 @@ function CreateRules(): JSX.Element {
case AlertTypes.EXCEPTIONS_BASED_ALERT: case AlertTypes.EXCEPTIONS_BASED_ALERT:
setInitValues(exceptionAlertDefaults); setInitValues(exceptionAlertDefaults);
break; break;
case AlertTypes.ANOMALY_BASED_ALERT:
setInitValues({
...alertDefaults,
version: version || ENTITY_VERSION_V4,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
});
break;
default: default:
setInitValues({ setInitValues({
...alertDefaults, ...alertDefaults,
version: version || ENTITY_VERSION_V4, 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()}`; const generatedUrl = `${location.pathname}?${queryParams.toString()}`;
history.replace(generatedUrl); history.replace(generatedUrl);
}; };

View File

@ -7,18 +7,16 @@ function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
const [formInstance] = Form.useForm(); const [formInstance] = Form.useForm();
return ( return (
<div style={{ marginTop: '1rem' }}> <FormAlertRules
<FormAlertRules alertType={
alertType={ initialValue.alertType
initialValue.alertType ? (initialValue.alertType as AlertTypes)
? (initialValue.alertType as AlertTypes) : AlertTypes.METRICS_BASED_ALERT
: AlertTypes.METRICS_BASED_ALERT }
} formInstance={formInstance}
formInstance={formInstance} initialValue={initialValue}
initialValue={initialValue} ruleId={ruleId}
ruleId={ruleId} />
/>
</div>
); );
} }

View File

@ -96,7 +96,7 @@ function BasicInfo({
return ( return (
<> <>
<StepHeading> {t('alert_form_step3')} </StepHeading> <StepHeading> {t('alert_form_step4')} </StepHeading>
<FormContainer> <FormContainer>
<Form.Item <Form.Item
label={t('field_severity')} label={t('field_severity')}

View File

@ -0,0 +1,26 @@
.alert-chart-container {
height: 57vh;
width: 100%;
.threshold-alert-uplot-chart-container {
height: calc(100% - 24px);
}
.ant-card-body {
padding: 12px;
}
.anomaly-alert-evaluation-view-loading-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.anomaly-alert-evaluation-view-error-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}

View File

@ -1,8 +1,11 @@
import './ChartPreview.styles.scss';
import { InfoCircleOutlined } from '@ant-design/icons'; import { InfoCircleOutlined } from '@ant-design/icons';
import Spinner from 'components/Spinner'; import Spinner from 'components/Spinner';
import { DEFAULT_ENTITY_VERSION } from 'constants/app'; import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
import GridPanelSwitch from 'container/GridPanelSwitch'; import GridPanelSwitch from 'container/GridPanelSwitch';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories'; import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
@ -34,6 +37,7 @@ import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData'; import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange'; import { getTimeRange } from 'utils/getTimeRange';
import { AlertDetectionTypes } from '..';
import { ChartContainer, FailedMessageContainer } from './styles'; import { ChartContainer, FailedMessageContainer } from './styles';
import { getThresholdLabel } from './utils'; import { getThresholdLabel } from './utils';
@ -141,6 +145,7 @@ function ChartPreview({
selectedInterval, selectedInterval,
minTime, minTime,
maxTime, maxTime,
alertDef?.ruleType,
], ],
retry: false, retry: false,
enabled: canQuery, enabled: canQuery,
@ -163,8 +168,6 @@ function ChartPreview({
queryResponse.data.payload.data.result = sortedSeriesData; queryResponse.data.payload.data.result = sortedSeriesData;
} }
const chartData = getUPlotChartData(queryResponse?.data?.payload);
const containerDimensions = useResizeObserver(graphRef); const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
@ -202,7 +205,10 @@ function ChartPreview({
id: 'alert_legend_widget', id: 'alert_legend_widget',
yAxisUnit, yAxisUnit,
apiResponse: queryResponse?.data?.payload, apiResponse: queryResponse?.data?.payload,
dimensions: containerDimensions, dimensions: {
height: containerDimensions?.height ? containerDimensions.height - 48 : 0,
width: containerDimensions?.width,
},
minTimeScale, minTimeScale,
maxTimeScale, maxTimeScale,
isDarkMode, isDarkMode,
@ -245,36 +251,55 @@ function ChartPreview({
], ],
); );
const chartData = getUPlotChartData(queryResponse?.data?.payload);
const isAnomalyDetectionAlert =
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
const chartDataAvailable =
chartData && !queryResponse.isError && !queryResponse.isLoading;
return ( return (
<ChartContainer> <div className="alert-chart-container" ref={graphRef}>
{headline} <ChartContainer>
{headline}
<div ref={graphRef} style={{ height: '100%' }}> <div className="threshold-alert-uplot-chart-container">
{queryResponse.isLoading && ( {queryResponse.isLoading && (
<Spinner size="large" tip="Loading..." height="100%" /> <Spinner size="large" tip="Loading..." height="100%" />
)} )}
{(queryResponse?.isError || queryResponse?.error) && ( {(queryResponse?.isError || queryResponse?.error) && (
<FailedMessageContainer color="red" title="Failed to refresh the chart"> <FailedMessageContainer color="red" title="Failed to refresh the chart">
<InfoCircleOutlined />{' '} <InfoCircleOutlined />
{queryResponse.error.message || t('preview_chart_unexpected_error')} {queryResponse.error.message || t('preview_chart_unexpected_error')}
</FailedMessageContainer> </FailedMessageContainer>
)} )}
{chartData && !queryResponse.isError && !queryResponse.isLoading && ( {chartDataAvailable && !isAnomalyDetectionAlert && (
<GridPanelSwitch <GridPanelSwitch
options={options} options={options}
panelType={graphType} panelType={graphType}
data={chartData} data={chartData}
name={name || 'Chart Preview'} name={name || 'Chart Preview'}
panelData={ panelData={
queryResponse.data?.payload?.data?.newResult?.data?.result || [] queryResponse.data?.payload?.data?.newResult?.data?.result || []
} }
query={query || initialQueriesMap.metrics} query={query || initialQueriesMap.metrics}
yAxisUnit={yAxisUnit} yAxisUnit={yAxisUnit}
/> />
)} )}
</div>
</ChartContainer> {chartDataAvailable &&
isAnomalyDetectionAlert &&
queryResponse?.data?.payload?.data?.resultType === 'anomaly' && (
<AnomalyAlertEvaluationView
data={queryResponse?.data?.payload}
yAxisUnit={yAxisUnit}
/>
)}
</div>
</ChartContainer>
</div>
); );
} }

View File

@ -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 { .info-help-btns {
display: grid; display: grid;
grid-template-columns: auto auto; grid-template-columns: auto auto;

View File

@ -222,7 +222,7 @@ function QuerySection({
}; };
return ( return (
<> <>
<StepHeading> {t('alert_form_step1')}</StepHeading> <StepHeading> {t('alert_form_step2')}</StepHeading>
<FormContainer> <FormContainer>
<div>{renderTabs(alertType)}</div> <div>{renderTabs(alertType)}</div>
{renderQuerySection(currentTab)} {renderQuerySection(currentTab)}

View File

@ -0,0 +1,6 @@
.rule-definition {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}

View File

@ -1,3 +1,5 @@
import './RuleOptions.styles.scss';
import { import {
Checkbox, Checkbox,
Collapse, Collapse,
@ -18,14 +20,17 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AlertDef, AlertDef,
defaultAlgorithm,
defaultCompareOp, defaultCompareOp,
defaultEvalWindow, defaultEvalWindow,
defaultFrequency, defaultFrequency,
defaultMatchType, defaultMatchType,
defaultSeasonality,
} from 'types/api/alerts/def'; } from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
import { popupContainer } from 'utils/selectPopupContainer'; import { popupContainer } from 'utils/selectPopupContainer';
import { AlertDetectionTypes } from '.';
import { import {
FormContainer, FormContainer,
InlineSelect, InlineSelect,
@ -43,6 +48,8 @@ function RuleOptions({
const { t } = useTranslation('alerts'); const { t } = useTranslation('alerts');
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
const { ruleType } = alertDef;
const handleMatchOptChange = (value: string | unknown): void => { const handleMatchOptChange = (value: string | unknown): void => {
const m = (value as string) || alertDef.condition?.matchType; const m = (value as string) || alertDef.condition?.matchType;
setAlertDef({ setAlertDef({
@ -86,8 +93,19 @@ function RuleOptions({
> >
<Select.Option value="1">{t('option_above')}</Select.Option> <Select.Option value="1">{t('option_above')}</Select.Option>
<Select.Option value="2">{t('option_below')}</Select.Option> <Select.Option value="2">{t('option_below')}</Select.Option>
<Select.Option value="3">{t('option_equal')}</Select.Option>
<Select.Option value="4">{t('option_notequal')}</Select.Option> {/* hide equal and not eqaul in case of analmoy based alert */}
{ruleType !== 'anomaly_rule' && (
<>
<Select.Option value="3">{t('option_equal')}</Select.Option>
<Select.Option value="4">{t('option_notequal')}</Select.Option>
</>
)}
{ruleType === 'anomaly_rule' && (
<Select.Option value="5">{t('option_above_below')}</Select.Option>
)}
</InlineSelect> </InlineSelect>
); );
@ -101,9 +119,14 @@ function RuleOptions({
> >
<Select.Option value="1">{t('option_atleastonce')}</Select.Option> <Select.Option value="1">{t('option_atleastonce')}</Select.Option>
<Select.Option value="2">{t('option_allthetimes')}</Select.Option> <Select.Option value="2">{t('option_allthetimes')}</Select.Option>
<Select.Option value="3">{t('option_onaverage')}</Select.Option>
<Select.Option value="4">{t('option_intotal')}</Select.Option> {ruleType !== 'anomaly_rule' && (
<Select.Option value="5">{t('option_last')}</Select.Option> <>
<Select.Option value="3">{t('option_onaverage')}</Select.Option>
<Select.Option value="4">{t('option_intotal')}</Select.Option>
<Select.Option value="5">{t('option_last')}</Select.Option>
</>
)}
</InlineSelect> </InlineSelect>
); );
@ -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 renderEvalWindows = (): JSX.Element => (
<InlineSelect <InlineSelect
getPopupContainer={popupContainer} getPopupContainer={popupContainer}
@ -146,6 +191,32 @@ function RuleOptions({
</InlineSelect> </InlineSelect>
); );
const renderAlgorithms = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultAlgorithm}
style={{ minWidth: '120px' }}
value={alertDef.condition.algorithm}
onChange={onChangeAlgorithm}
>
<Select.Option value="standard">Standard</Select.Option>
</InlineSelect>
);
const renderSeasonality = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultSeasonality}
style={{ minWidth: '120px' }}
value={alertDef.condition.seasonality}
onChange={onChangeSeasonality}
>
<Select.Option value="hourly">Hourly</Select.Option>
<Select.Option value="daily">Daily</Select.Option>
<Select.Option value="weekly">Weekly</Select.Option>
</InlineSelect>
);
const renderThresholdRuleOpts = (): JSX.Element => ( const renderThresholdRuleOpts = (): JSX.Element => (
<Form.Item> <Form.Item>
<Typography.Text> <Typography.Text>
@ -166,6 +237,39 @@ function RuleOptions({
</Form.Item> </Form.Item>
); );
const renderAnomalyRuleOpts = (
onChange: InputNumberProps['onChange'],
): JSX.Element => (
<Form.Item>
<Typography.Text className="rule-definition">
{t('text_condition1_anomaly')}
<InlineSelect
getPopupContainer={popupContainer}
allowClear
showSearch
options={queryOptions}
placeholder={t('selected_query_placeholder')}
value={alertDef.condition.selectedQueryName}
onChange={onChangeSelectedQueryName}
/>
{t('text_condition3')} {renderEvalWindows()}
<Typography.Text>is</Typography.Text>
<InputNumber
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
onWheel={(e): void => e.currentTarget.blur()}
/>
<Typography.Text>deviations</Typography.Text>
{renderCompareOps()}
<Typography.Text>the predicted data</Typography.Text>
{renderMatchOpts()}
using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '}
seasonality
</Typography.Text>
</Form.Item>
);
const renderPromRuleOptions = (): JSX.Element => ( const renderPromRuleOptions = (): JSX.Element => (
<Form.Item> <Form.Item>
<Typography.Text> <Typography.Text>
@ -245,36 +349,46 @@ function RuleOptions({
return ( return (
<> <>
<StepHeading>{t('alert_form_step2')}</StepHeading> <StepHeading>{t('alert_form_step3')}</StepHeading>
<FormContainer> <FormContainer>
{queryCategory === EQueryType.PROM {queryCategory === EQueryType.PROM && renderPromRuleOptions()}
? renderPromRuleOptions() {queryCategory !== EQueryType.PROM &&
: renderThresholdRuleOpts()} ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<>{renderAnomalyRuleOpts(onChange)}</>
)}
{queryCategory !== EQueryType.PROM &&
ruleType === AlertDetectionTypes.THRESHOLD_ALERT &&
renderThresholdRuleOpts()}
<Space direction="vertical" size="large"> <Space direction="vertical" size="large">
<Space direction="horizontal" align="center"> {queryCategory !== EQueryType.PROM &&
<Form.Item noStyle name={['condition', 'target']}> ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<InputNumber <Space direction="horizontal" align="center">
addonBefore={t('field_threshold')} <Form.Item noStyle name={['condition', 'target']}>
value={alertDef?.condition?.target} <InputNumber
onChange={onChange} addonBefore={t('field_threshold')}
type="number" value={alertDef?.condition?.target}
onWheel={(e): void => e.currentTarget.blur()} onChange={onChange}
/> type="number"
</Form.Item> onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
<Form.Item noStyle>
<Select
getPopupContainer={popupContainer}
allowClear
showSearch
options={categorySelectOptions}
placeholder={t('field_unit')}
value={alertDef.condition.targetUnit}
onChange={onChangeAlertUnit}
/>
</Form.Item>
</Space>
)}
<Form.Item noStyle>
<Select
getPopupContainer={popupContainer}
allowClear
showSearch
options={categorySelectOptions}
placeholder={t('field_unit')}
value={alertDef.condition.targetUnit}
onChange={onChangeAlertUnit}
/>
</Form.Item>
</Space>
<Collapse> <Collapse>
<Collapse.Panel header={t('More options')} key="1"> <Collapse.Panel header={t('More options')} key="1">
<Space direction="vertical" size="large"> <Space direction="vertical" size="large">

View File

@ -3,7 +3,6 @@ import './FormAlertRules.styles.scss';
import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons'; import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Col,
FormInstance, FormInstance,
Modal, Modal,
SelectProps, SelectProps,
@ -13,8 +12,6 @@ import {
import saveAlertApi from 'api/alerts/save'; import saveAlertApi from 'api/alerts/save';
import testAlertApi from 'api/alerts/testAlert'; import testAlertApi from 'api/alerts/testAlert';
import logEvent from 'api/common/logEvent'; 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 { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { FeatureKeys } from 'constants/features'; import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
@ -33,6 +30,8 @@ import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEqual } from 'lodash-es'; 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query'; import { useQueryClient } from 'react-query';
@ -44,7 +43,11 @@ import {
defaultEvalWindow, defaultEvalWindow,
defaultMatchType, defaultMatchType,
} from 'types/api/alerts/def'; } 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 { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
@ -56,13 +59,16 @@ import {
ActionButton, ActionButton,
ButtonContainer, ButtonContainer,
MainFormContainer, MainFormContainer,
PanelContainer,
StepContainer, StepContainer,
StyledLeftContainer, StepHeading,
} from './styles'; } from './styles';
import UserGuide from './UserGuide';
import { getSelectedQueryOptions } from './utils'; import { getSelectedQueryOptions } from './utils';
export enum AlertDetectionTypes {
THRESHOLD_ALERT = 'threshold_rule',
ANOMALY_DETECTION_ALERT = 'anomaly_rule',
}
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
function FormAlertRules({ function FormAlertRules({
alertType, alertType,
@ -86,6 +92,7 @@ function FormAlertRules({
const { const {
currentQuery, currentQuery,
stagedQuery, stagedQuery,
handleSetQueryData,
handleRunQuery, handleRunQuery,
handleSetConfig, handleSetConfig,
initialDataSource, initialDataSource,
@ -108,6 +115,12 @@ function FormAlertRules({
const [alertDef, setAlertDef] = useState<AlertDef>(initialValue); const [alertDef, setAlertDef] = useState<AlertDef>(initialValue);
const [yAxisUnit, setYAxisUnit] = useState<string>(currentQuery.unit || ''); const [yAxisUnit, setYAxisUnit] = useState<string>(currentQuery.unit || '');
const alertTypeFromURL = urlQuery.get(QueryParams.ruleType);
const [detectionMethod, setDetectionMethod] = useState<string>(
AlertDetectionTypes.THRESHOLD_ALERT,
);
useEffect(() => { useEffect(() => {
if (!isEqual(currentQuery.unit, yAxisUnit)) { if (!isEqual(currentQuery.unit, yAxisUnit)) {
setYAxisUnit(currentQuery.unit || ''); setYAxisUnit(currentQuery.unit || '');
@ -138,6 +151,45 @@ function FormAlertRules({
useShareBuilderUrl(sq); 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(() => { useEffect(() => {
const broadcastToSpecificChannels = const broadcastToSpecificChannels =
(initialValue && (initialValue &&
@ -145,11 +197,22 @@ function FormAlertRules({
initialValue.preferredChannels.length > 0) || initialValue.preferredChannels.length > 0) ||
isNewRule; 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({ setAlertDef({
...initialValue, ...initialValue,
broadcastToAll: !broadcastToSpecificChannels, broadcastToAll: !broadcastToSpecificChannels,
ruleType,
}); });
}, [initialValue, isNewRule]);
setDetectionMethod(ruleType);
}, [initialValue, isNewRule, alertTypeFromURL]);
useEffect(() => { useEffect(() => {
// Set selectedQueryName based on the length of queryOptions // Set selectedQueryName based on the length of queryOptions
@ -300,12 +363,15 @@ function FormAlertRules({
const postableAlert: AlertDef = { const postableAlert: AlertDef = {
...alertDef, ...alertDef,
preferredChannels: alertDef.broadcastToAll ? [] : alertDef.preferredChannels, preferredChannels: alertDef.broadcastToAll ? [] : alertDef.preferredChannels,
alertType, alertType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertTypes.METRICS_BASED_ALERT
: alertType,
source: window?.location.toString(), source: window?.location.toString(),
ruleType: ruleType:
currentQuery.queryType === EQueryType.PROM currentQuery.queryType === EQueryType.PROM
? 'promql_rule' ? 'promql_rule'
: 'threshold_rule', : alertDef.ruleType,
condition: { condition: {
...alertDef.condition, ...alertDef.condition,
compositeQuery: { 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; return postableAlert;
}; };
@ -585,63 +657,97 @@ function FormAlertRules({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
function handleRedirection(option: AlertTypes): void { const tabs = [
let url = ''; {
switch (option) { value: AlertDetectionTypes.THRESHOLD_ALERT,
case AlertTypes.METRICS_BASED_ALERT: label: 'Threshold Alert',
url = },
'https://signoz.io/docs/alerts-management/metrics-based-alerts/?utm_source=product&utm_medium=alert-creation-page#examples'; {
break; value: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
case AlertTypes.LOGS_BASED_ALERT: label: 'Anomaly Detection 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: const handleDetectionMethodChange = (value: any): void => {
url = setAlertDef((def) => ({
'https://signoz.io/docs/alerts-management/trace-based-alerts/?utm_source=product&utm_medium=alert-creation-page#examples'; ...def,
break; ruleType: value,
case AlertTypes.EXCEPTIONS_BASED_ALERT: }));
url =
'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page#examples'; setDetectionMethod(value);
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');
}
return ( return (
<> <>
{Element} {Element}
<PanelContainer id="top"> <div id="top">
<StyledLeftContainer flex="5 1 600px" md={18}> <div className="overview-header">
<MainFormContainer <div className="alert-type-container">
initialValues={initialValue} {isNewRule && (
layout="vertical" <Typography.Title level={5} className="alert-type-title">
form={formInstance} <BellDot size={14} />
className="main-container"
> {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'}
</Typography.Title>
)}
</div>
<Button className="periscope-btn" icon={<ExternalLink size={14} />}>
Alert Setup Guide
</Button>
</div>
<MainFormContainer
initialValues={initialValue}
layout="vertical"
form={formInstance}
className="main-container"
>
<div className="chart-preview-container">
{currentQuery.queryType === EQueryType.QUERY_BUILDER && {currentQuery.queryType === EQueryType.QUERY_BUILDER &&
renderQBChartPreview()} renderQBChartPreview()}
{currentQuery.queryType === EQueryType.PROM && {currentQuery.queryType === EQueryType.PROM &&
renderPromAndChQueryChartPreview()} renderPromAndChQueryChartPreview()}
{currentQuery.queryType === EQueryType.CLICKHOUSE && {currentQuery.queryType === EQueryType.CLICKHOUSE &&
renderPromAndChQueryChartPreview()} renderPromAndChQueryChartPreview()}
</div>
<StepContainer> <StepContainer>
<BuilderUnitsFilter <BuilderUnitsFilter
onChange={onUnitChangeHandler} onChange={onUnitChangeHandler}
yAxisUnit={yAxisUnit} yAxisUnit={yAxisUnit}
/> />
</StepContainer> </StepContainer>
<div className="steps-container">
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT && (
<div className="detection-method-container">
<StepHeading> {t('alert_form_step1')}</StepHeading>
<Tabs2
key={detectionMethod}
tabs={tabs}
initialSelectedTab={detectionMethod}
onSelectTab={handleDetectionMethodChange}
/>
<div className="detection-method-description">
{detectionMethod === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? t('anomaly_detection_alert_desc')
: t('threshold_alert_desc')}
</div>
</div>
)}
<QuerySection <QuerySection
queryCategory={currentQuery.queryType} queryCategory={currentQuery.queryType}
@ -662,79 +768,49 @@ function FormAlertRules({
/> />
{renderBasicInfo()} {renderBasicInfo()}
<ButtonContainer> </div>
<Tooltip title={isAlertAvailableToSave ? MESSAGE.ALERT : ''}> <ButtonContainer>
<ActionButton <Tooltip title={isAlertAvailableToSave ? MESSAGE.ALERT : ''}>
loading={loading || false}
type="primary"
onClick={onSaveHandler}
icon={<SaveOutlined />}
disabled={
isAlertNameMissing ||
isAlertAvailableToSave ||
!isChannelConfigurationValid ||
queryStatus === 'error'
}
>
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
</Tooltip>
<ActionButton <ActionButton
loading={loading || false} loading={loading || false}
type="primary"
onClick={onSaveHandler}
icon={<SaveOutlined />}
disabled={ disabled={
isAlertNameMissing || isAlertNameMissing ||
isAlertAvailableToSave ||
!isChannelConfigurationValid || !isChannelConfigurationValid ||
queryStatus === 'error' queryStatus === 'error'
} }
type="default"
onClick={onTestRuleHandler}
> >
{' '} {isNewRule ? t('button_createrule') : t('button_savechanges')}
{t('button_testrule')}
</ActionButton> </ActionButton>
<ActionButton </Tooltip>
disabled={loading || false}
type="default" <ActionButton
onClick={onCancelHandler} loading={loading || false}
> disabled={
{ruleId === 0 && t('button_cancelchanges')} isAlertNameMissing ||
{ruleId > 0 && t('button_discard')} !isChannelConfigurationValid ||
</ActionButton> queryStatus === 'error'
</ButtonContainer>
</MainFormContainer>
</StyledLeftContainer>
<Col flex="1 1 300px">
<UserGuide queryType={currentQuery.queryType} />
<div className="info-help-btns">
<Button
style={{ height: 32 }}
onClick={(): void =>
handleRedirection(alertDef?.alertType as AlertTypes)
} }
className="doc-redirection-btn" type="default"
onClick={onTestRuleHandler}
> >
Check an example alert {' '}
</Button> {t('button_testrule')}
<LaunchChatSupport </ActionButton>
attributes={{ <ActionButton
alert: alertDef?.alert, disabled={loading || false}
alertType: alertDef?.alertType, type="default"
id: ruleId, onClick={onCancelHandler}
ruleType: alertDef?.ruleType, >
state: (alertDef as any)?.state, {ruleId === 0 && t('button_cancelchanges')}
panelType, {ruleId > 0 && t('button_discard')}
screen: isRuleCreated ? 'Edit Alert' : 'New Alert', </ActionButton>
}} </ButtonContainer>
className="facing-issue-btn" </MainFormContainer>
eventName="Alert: Facing Issues in alert" </div>
buttonText="Need help with this alert?"
message={alertHelpMessage(alertDef, ruleId)}
onHoverText="Click here to get help with this alert"
/>
</div>
</Col>
</PanelContainer>
</> </>
); );
} }

View File

@ -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'; import styled from 'styled-components';
const { TextArea } = Input; const { TextArea } = Input;
const { Item } = Form; const { Item } = Form;
export const PanelContainer = styled(Row)`
flex-wrap: nowrap;
`;
export const StyledLeftContainer = styled(Col)` export const StyledLeftContainer = styled(Col)`
&&& { &&& {
margin-right: 1rem; margin-right: 1rem;

View File

@ -33,7 +33,7 @@ export default function Function({
handleDeleteFunction, handleDeleteFunction,
}: FunctionProps): JSX.Element { }: FunctionProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { showInput } = queryFunctionsTypesConfig[funcData.name]; const { showInput, disabled } = queryFunctionsTypesConfig[funcData.name];
let functionValue; let functionValue;
@ -62,6 +62,7 @@ export default function Function({
<Select <Select
className={cx('query-function-name-selector', showInput ? 'showInput' : '')} className={cx('query-function-name-selector', showInput ? 'showInput' : '')}
value={funcData.name} value={funcData.name}
disabled={disabled}
style={{ minWidth: '100px' }} style={{ minWidth: '100px' }}
onChange={(value): void => { onChange={(value): void => {
handleUpdateFunctionName(funcData, index, value); handleUpdateFunctionName(funcData, index, value);

View File

@ -33,7 +33,6 @@ export async function GetMetricQueryRange(
headers, headers,
); );
if (response.statusCode >= 400) { if (response.statusCode >= 400) {
let error = `API responded with ${response.statusCode} - ${response.error} status: ${response.message}`; let error = `API responded with ${response.statusCode} - ${response.error} status: ${response.message}`;
if (response.body && !isEmpty(response.body)) { if (response.body && !isEmpty(response.body)) {
@ -71,6 +70,19 @@ export async function GetMetricQueryRange(
}, },
); );
} }
if (response.payload?.data?.newResult?.data?.resultType === 'anomaly') {
response.payload.data.newResult.data.result = response.payload.data.newResult.data.result.map(
(queryData) => {
if (legendMap[queryData.queryName]) {
queryData.legend = legendMap[queryData.queryName];
}
return queryData;
},
);
}
return response; return response;
} }

View File

@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-identical-functions */
import { import {
MetricRangePayloadProps, MetricRangePayloadProps,
MetricRangePayloadV3, MetricRangePayloadV3,
@ -12,8 +13,8 @@ export const convertNewDataToOld = (
result.forEach((item) => { result.forEach((item) => {
if (item.series) { if (item.series) {
item.series.forEach((serie) => { item.series.forEach((series) => {
const values: QueryData['values'] = serie.values.reduce< const values: QueryData['values'] = series.values.reduce<
QueryData['values'] QueryData['values']
>((acc, currentInfo) => { >((acc, currentInfo) => {
const renderValues: [number, string] = [ const renderValues: [number, string] = [
@ -23,16 +24,87 @@ export const convertNewDataToOld = (
return [...acc, renderValues]; return [...acc, renderValues];
}, []); }, []);
const result: QueryData = { const result: QueryData = {
metric: serie.labels, metric: series.labels,
values, values,
queryName: item.queryName, queryName: `${item.queryName}`,
};
oldResult.push(result);
});
}
if (item.predictedSeries) {
item.predictedSeries.forEach((series) => {
const values: QueryData['values'] = series.values.reduce<
QueryData['values']
>((acc, currentInfo) => {
const renderValues: [number, string] = [
currentInfo.timestamp / 1000,
currentInfo.value,
];
return [...acc, renderValues];
}, []);
const result: QueryData = {
metric: series.labels,
values,
queryName: `${item.queryName}`,
};
oldResult.push(result);
});
}
if (item.upperBoundSeries) {
item.upperBoundSeries.forEach((series) => {
const values: QueryData['values'] = series.values.reduce<
QueryData['values']
>((acc, currentInfo) => {
const renderValues: [number, string] = [
currentInfo.timestamp / 1000,
currentInfo.value,
];
return [...acc, renderValues];
}, []);
const result: QueryData = {
metric: series.labels,
values,
queryName: `${item.queryName}`,
};
oldResult.push(result);
});
}
if (item.lowerBoundSeries) {
item.lowerBoundSeries.forEach((series) => {
const values: QueryData['values'] = series.values.reduce<
QueryData['values']
>((acc, currentInfo) => {
const renderValues: [number, string] = [
currentInfo.timestamp / 1000,
currentInfo.value,
];
return [...acc, renderValues];
}, []);
const result: QueryData = {
metric: series.labels,
values,
queryName: `${item.queryName}`,
}; };
oldResult.push(result); oldResult.push(result);
}); });
} }
}); });
const oldResultType = resultType; const oldResultType = resultType;
// TODO: fix it later for using only v3 version of api // TODO: fix it later for using only v3 version of api

View File

@ -163,6 +163,8 @@ export const getUPlotChartOptions = ({
const stackBarChart = stackChart && isUndefined(hiddenGraph); const stackBarChart = stackChart && isUndefined(hiddenGraph);
const isAnomalyRule = apiResponse?.data?.newResult?.data?.result[0].isAnomaly;
const series = getStackedSeries(apiResponse?.data?.result || []); const series = getStackedSeries(apiResponse?.data?.result || []);
const bands = stackBarChart ? getBands(series) : null; const bands = stackBarChart ? getBands(series) : null;
@ -251,11 +253,14 @@ export const getUPlotChartOptions = ({
hooks: { hooks: {
draw: [ draw: [
(u): void => { (u): void => {
if (isAnomalyRule) {
return;
}
thresholds?.forEach((threshold) => { thresholds?.forEach((threshold) => {
if (threshold.thresholdValue !== undefined) { if (threshold.thresholdValue !== undefined) {
const { ctx } = u; const { ctx } = u;
ctx.save(); ctx.save();
const yPos = u.valToPos( const yPos = u.valToPos(
convertValue( convertValue(
threshold.thresholdValue, threshold.thresholdValue,
@ -265,30 +270,22 @@ export const getUPlotChartOptions = ({
'y', 'y',
true, true,
); );
ctx.strokeStyle = threshold.thresholdColor || 'red'; ctx.strokeStyle = threshold.thresholdColor || 'red';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.setLineDash([10, 5]); ctx.setLineDash([10, 5]);
ctx.beginPath(); ctx.beginPath();
const plotLeft = u.bbox.left; // left edge of the plot area const plotLeft = u.bbox.left; // left edge of the plot area
const plotRight = plotLeft + u.bbox.width; // right edge of the plot area const plotRight = plotLeft + u.bbox.width; // right edge of the plot area
ctx.moveTo(plotLeft, yPos); ctx.moveTo(plotLeft, yPos);
ctx.lineTo(plotRight, yPos); ctx.lineTo(plotRight, yPos);
ctx.stroke(); ctx.stroke();
// Text configuration // Text configuration
if (threshold.thresholdLabel) { if (threshold.thresholdLabel) {
const text = threshold.thresholdLabel; const text = threshold.thresholdLabel;
const textX = plotRight - ctx.measureText(text).width - 20; const textX = plotRight - ctx.measureText(text).width - 20;
const canvasHeight = ctx.canvas.height; const canvasHeight = ctx.canvas.height;
const yposHeight = canvasHeight - yPos; const yposHeight = canvasHeight - yPos;
const isHeightGreaterThan90Percent = canvasHeight * 0.9 < yposHeight; const isHeightGreaterThan90Percent = canvasHeight * 0.9 < yposHeight;
// Adjust textY based on the condition // Adjust textY based on the condition
let textY; let textY;
if (isHeightGreaterThan90Percent) { if (isHeightGreaterThan90Percent) {
@ -299,7 +296,6 @@ export const getUPlotChartOptions = ({
ctx.fillStyle = threshold.thresholdColor || 'red'; ctx.fillStyle = threshold.thresholdColor || 'red';
ctx.fillText(text, textX, textY); ctx.fillText(text, textX, textY);
} }
ctx.restore(); ctx.restore();
} }
}); });

View File

@ -1,3 +1,5 @@
import getLabelName from 'lib/getLabelName';
import { colors } from 'lib/getRandomColor';
import { cloneDeep, isUndefined } from 'lodash-es'; import { cloneDeep, isUndefined } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery'; import { QueryData } from 'types/api/widgets/getQuery';
@ -20,7 +22,7 @@ function getXAxisTimestamps(seriesList: QueryData[]): number[] {
function fillMissingXAxisTimestamps(timestampArr: number[], data: any[]): any { function fillMissingXAxisTimestamps(timestampArr: number[], data: any[]): any {
// Generate a set of all timestamps in the range // Generate a set of all timestamps in the range
const allTimestampsSet = new Set(timestampArr); const allTimestampsSet = new Set(timestampArr);
const processedData = JSON.parse(JSON.stringify(data)); const processedData = cloneDeep(data);
// Fill missing timestamps with null values // Fill missing timestamps with null values
processedData.forEach((entry: { values: (number | null)[][] }) => { processedData.forEach((entry: { values: (number | null)[][] }) => {
@ -90,3 +92,70 @@ export const getUPlotChartData = (
: yAxisValuesArr), : yAxisValuesArr),
]; ];
}; };
const processAnomalyDetectionData = (
anomalyDetectionData: any,
): Record<string, { data: number[][]; color: string }> => {
if (!anomalyDetectionData) {
return {};
}
const processedData: Record<
string,
{ data: number[][]; color: string; legendLabel: string }
> = {};
for (
let queryIndex = 0;
queryIndex < anomalyDetectionData.length;
queryIndex++
) {
const {
series,
predictedSeries,
upperBoundSeries,
lowerBoundSeries,
queryName,
legend,
} = anomalyDetectionData[queryIndex];
for (let index = 0; index < series?.length; index++) {
const label = getLabelName(
series[index].labels,
queryName || '', // query
legend || '',
);
const objKey = `${queryName}-${label}`;
processedData[objKey] = {
data: [
series[index].values.map((v: { timestamp: number }) => v.timestamp / 1000),
series[index].values.map((v: { value: number }) => v.value),
predictedSeries[index].values.map((v: { value: number }) => v.value),
upperBoundSeries[index].values.map((v: { value: number }) => v.value),
lowerBoundSeries[index].values.map((v: { value: number }) => v.value),
],
color: colors[index],
legendLabel: label,
};
}
}
return processedData;
};
export const getUplotChartDataForAnomalyDetection = (
apiResponse?: MetricRangePayloadProps,
): Record<
string,
{
[x: string]: any;
data: number[][];
color: string;
}
> => {
const anomalyDetectionData = apiResponse?.data?.newResult?.data?.result;
return processAnomalyDetectionData(anomalyDetectionData);
};

View File

@ -233,6 +233,43 @@ GetYAxisScale): { auto?: boolean; range?: uPlot.Scale.Range } => {
return { auto: false, range: [min, max] }; return { auto: false, range: [min, max] };
}; };
function getMinMax(data: any): { minValue: number; maxValue: number } {
// Exclude the first array
const arrays = data.slice(1);
// Flatten the array and convert all elements to float
const flattened = arrays.flat().map(Number);
// Get min and max, with fallback of 0 for min
const minValue = flattened.length ? Math.min(...flattened) : 0;
const maxValue = Math.max(...flattened);
return { minValue, maxValue };
}
export const getYAxisScaleForAnomalyDetection = ({
seriesData,
selectedSeries,
initialData,
}: {
seriesData: any;
selectedSeries: string | null;
initialData: any;
yAxisUnit?: string;
}): { auto?: boolean; range?: uPlot.Scale.Range } => {
if (!selectedSeries && !initialData) {
return { auto: true };
}
const selectedSeriesData = selectedSeries
? seriesData[selectedSeries]?.data
: initialData;
const { minValue, maxValue } = getMinMax(selectedSeriesData);
return { auto: false, range: [minValue, maxValue] };
};
export type GetYAxisScale = { export type GetYAxisScale = {
thresholds?: ThresholdProps[]; thresholds?: ThresholdProps[];
series?: QueryDataV3[]; series?: QueryDataV3[];

View File

@ -11,8 +11,8 @@
gap: 10px; gap: 10px;
color: var(--text-vanilla-400); color: var(--text-vanilla-400);
background: var(--bg-ink-400); background: var(--bg-ink-400);
font-size: 14px; font-size: 13px;
line-height: 20px; line-height: 18px;
letter-spacing: -0.07px; letter-spacing: -0.07px;
padding: 6px 24px; padding: 6px 24px;
border-color: var(--bg-slate-400); border-color: var(--bg-slate-400);

View File

@ -1,5 +1,6 @@
// this list must exactly match with the backend // this list must exactly match with the backend
export enum AlertTypes { export enum AlertTypes {
ANOMALY_BASED_ALERT = 'ANOMALY_BASED_ALERT',
METRICS_BASED_ALERT = 'METRIC_BASED_ALERT', METRICS_BASED_ALERT = 'METRIC_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT', LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT', TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',

View File

@ -12,6 +12,10 @@ export const defaultFrequency = '1m0s';
// default compare op: above // default compare op: above
export const defaultCompareOp = '1'; export const defaultCompareOp = '1';
export const defaultAlgorithm = 'standard';
export const defaultSeasonality = 'hourly';
export interface AlertDef { export interface AlertDef {
id?: number; id?: number;
alertType?: string; alertType?: string;
@ -40,6 +44,8 @@ export interface RuleCondition {
absentFor?: number | undefined; absentFor?: number | undefined;
requireMinPoints?: boolean | undefined; requireMinPoints?: boolean | undefined;
requiredNumPoints?: number | undefined; requiredNumPoints?: number | undefined;
algorithm?: string;
seasonality?: string;
} }
export interface Labels { export interface Labels {
[key: string]: string; [key: string]: string;

View File

@ -8,6 +8,10 @@ export interface PayloadProps {
export type ListItem = { timestamp: string; data: Omit<ILog, 'timestamp'> }; export type ListItem = { timestamp: string; data: Omit<ILog, 'timestamp'> };
export interface QueryData { export interface QueryData {
lowerBoundSeries?: [number, string][];
upperBoundSeries?: [number, string][];
predictedSeries?: [number, string][];
anomalyScores?: [number, string][];
metric: { metric: {
[key: string]: string; [key: string]: string;
}; };
@ -34,6 +38,11 @@ export interface QueryDataV3 {
quantity?: number; quantity?: number;
unitPrice?: number; unitPrice?: number;
unit?: string; unit?: string;
lowerBoundSeries?: SeriesItem[] | null;
upperBoundSeries?: SeriesItem[] | null;
predictedSeries?: SeriesItem[] | null;
anomalyScores?: SeriesItem[] | null;
isAnomaly?: boolean;
} }
export interface Props { export interface Props {

View File

@ -153,6 +153,7 @@ export enum LogsAggregatorOperator {
} }
export enum QueryFunctionsTypes { export enum QueryFunctionsTypes {
ANOMALY = 'anomaly',
CUTOFF_MIN = 'cutOffMin', CUTOFF_MIN = 'cutOffMin',
CUTOFF_MAX = 'cutOffMax', CUTOFF_MAX = 'cutOffMax',
CLAMP_MIN = 'clampMin', CLAMP_MIN = 'clampMin',