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_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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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, DataSource> = {
[AlertTypes.ANOMALY_BASED_ALERT]: DataSource.METRICS,
[AlertTypes.METRICS_BASED_ALERT]: DataSource.METRICS,
[AlertTypes.LOGS_BASED_ALERT]: DataSource.LOGS,
[AlertTypes.TRACES_BASED_ALERT]: DataSource.TRACES,

View File

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

View File

@ -3,6 +3,10 @@ import { QueryFunctionsTypes } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
export const metricQueryFunctionOptions: SelectOption<string, string>[] = [
{
value: QueryFunctionsTypes.ANOMALY,
label: 'Anomaly',
},
{
value: QueryFunctionsTypes.CUTOFF_MIN,
label: 'Cut Off Min',
@ -67,6 +71,10 @@ export const metricQueryFunctionOptions: SelectOption<string, string>[] = [
value: QueryFunctionsTypes.TIME_SHIFT,
label: 'Time Shift',
},
{
value: QueryFunctionsTypes.TIME_SHIFT,
label: 'Time Shift',
},
];
export const logsQueryFunctionOptions: SelectOption<string, string>[] = [
@ -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',

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';
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,

View File

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

View File

@ -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, AlertDef> = {
[AlertTypes.ANOMALY_BASED_ALERT]: alertDefaults,
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,

View File

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

View File

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

View File

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

View File

@ -222,7 +222,7 @@ function QuerySection({
};
return (
<>
<StepHeading> {t('alert_form_step1')}</StepHeading>
<StepHeading> {t('alert_form_step2')}</StepHeading>
<FormContainer>
<div>{renderTabs(alertType)}</div>
{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 {
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({
>
<Select.Option value="1">{t('option_above')}</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>
);
@ -101,9 +119,14 @@ function RuleOptions({
>
<Select.Option value="1">{t('option_atleastonce')}</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>
<Select.Option value="5">{t('option_last')}</Select.Option>
{ruleType !== 'anomaly_rule' && (
<>
<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>
);
@ -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 => (
<InlineSelect
getPopupContainer={popupContainer}
@ -146,6 +191,32 @@ function RuleOptions({
</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 => (
<Form.Item>
<Typography.Text>
@ -166,6 +237,39 @@ function RuleOptions({
</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 => (
<Form.Item>
<Typography.Text>
@ -245,36 +349,46 @@ function RuleOptions({
return (
<>
<StepHeading>{t('alert_form_step2')}</StepHeading>
<StepHeading>{t('alert_form_step3')}</StepHeading>
<FormContainer>
{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()}
<Space direction="vertical" size="large">
<Space direction="horizontal" align="center">
<Form.Item noStyle name={['condition', 'target']}>
<InputNumber
addonBefore={t('field_threshold')}
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
{queryCategory !== EQueryType.PROM &&
ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<Space direction="horizontal" align="center">
<Form.Item noStyle name={['condition', 'target']}>
<InputNumber
addonBefore={t('field_threshold')}
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
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.Panel header={t('More options')} key="1">
<Space direction="vertical" size="large">

View File

@ -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<AlertDef>(initialValue);
const [yAxisUnit, setYAxisUnit] = useState<string>(currentQuery.unit || '');
const alertTypeFromURL = urlQuery.get(QueryParams.ruleType);
const [detectionMethod, setDetectionMethod] = useState<string>(
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}
<PanelContainer id="top">
<StyledLeftContainer flex="5 1 600px" md={18}>
<MainFormContainer
initialValues={initialValue}
layout="vertical"
form={formInstance}
className="main-container"
>
<div id="top">
<div className="overview-header">
<div className="alert-type-container">
{isNewRule && (
<Typography.Title level={5} className="alert-type-title">
<BellDot size={14} />
{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 &&
renderQBChartPreview()}
{currentQuery.queryType === EQueryType.PROM &&
renderPromAndChQueryChartPreview()}
{currentQuery.queryType === EQueryType.CLICKHOUSE &&
renderPromAndChQueryChartPreview()}
</div>
<StepContainer>
<BuilderUnitsFilter
onChange={onUnitChangeHandler}
yAxisUnit={yAxisUnit}
/>
</StepContainer>
<StepContainer>
<BuilderUnitsFilter
onChange={onUnitChangeHandler}
yAxisUnit={yAxisUnit}
/>
</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
queryCategory={currentQuery.queryType}
@ -662,79 +768,49 @@ function FormAlertRules({
/>
{renderBasicInfo()}
<ButtonContainer>
<Tooltip title={isAlertAvailableToSave ? MESSAGE.ALERT : ''}>
<ActionButton
loading={loading || false}
type="primary"
onClick={onSaveHandler}
icon={<SaveOutlined />}
disabled={
isAlertNameMissing ||
isAlertAvailableToSave ||
!isChannelConfigurationValid ||
queryStatus === 'error'
}
>
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
</Tooltip>
</div>
<ButtonContainer>
<Tooltip title={isAlertAvailableToSave ? MESSAGE.ALERT : ''}>
<ActionButton
loading={loading || false}
type="primary"
onClick={onSaveHandler}
icon={<SaveOutlined />}
disabled={
isAlertNameMissing ||
isAlertAvailableToSave ||
!isChannelConfigurationValid ||
queryStatus === 'error'
}
type="default"
onClick={onTestRuleHandler}
>
{' '}
{t('button_testrule')}
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
<ActionButton
disabled={loading || false}
type="default"
onClick={onCancelHandler}
>
{ruleId === 0 && t('button_cancelchanges')}
{ruleId > 0 && t('button_discard')}
</ActionButton>
</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)
</Tooltip>
<ActionButton
loading={loading || false}
disabled={
isAlertNameMissing ||
!isChannelConfigurationValid ||
queryStatus === 'error'
}
className="doc-redirection-btn"
type="default"
onClick={onTestRuleHandler}
>
Check an example alert
</Button>
<LaunchChatSupport
attributes={{
alert: alertDef?.alert,
alertType: alertDef?.alertType,
id: ruleId,
ruleType: alertDef?.ruleType,
state: (alertDef as any)?.state,
panelType,
screen: isRuleCreated ? 'Edit Alert' : 'New Alert',
}}
className="facing-issue-btn"
eventName="Alert: Facing Issues in alert"
buttonText="Need help with this alert?"
message={alertHelpMessage(alertDef, ruleId)}
onHoverText="Click here to get help with this alert"
/>
</div>
</Col>
</PanelContainer>
{' '}
{t('button_testrule')}
</ActionButton>
<ActionButton
disabled={loading || false}
type="default"
onClick={onCancelHandler}
>
{ruleId === 0 && t('button_cancelchanges')}
{ruleId > 0 && t('button_discard')}
</ActionButton>
</ButtonContainer>
</MainFormContainer>
</div>
</>
);
}

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

View File

@ -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({
<Select
className={cx('query-function-name-selector', showInput ? 'showInput' : '')}
value={funcData.name}
disabled={disabled}
style={{ minWidth: '100px' }}
onChange={(value): void => {
handleUpdateFunctionName(funcData, index, value);

View File

@ -32,7 +32,6 @@ export async function GetMetricQueryRange(
signal,
headers,
);
if (response.statusCode >= 400) {
let error = `API responded with ${response.statusCode} - ${response.error} status: ${response.message}`;
@ -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;
}

View File

@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-identical-functions */
import {
MetricRangePayloadProps,
MetricRangePayloadV3,
@ -12,8 +13,8 @@ export const convertNewDataToOld = (
result.forEach((item) => {
if (item.series) {
item.series.forEach((serie) => {
const values: QueryData['values'] = serie.values.reduce<
item.series.forEach((series) => {
const values: QueryData['values'] = series.values.reduce<
QueryData['values']
>((acc, currentInfo) => {
const renderValues: [number, string] = [
@ -23,16 +24,87 @@ export const convertNewDataToOld = (
return [...acc, renderValues];
}, []);
const result: QueryData = {
metric: serie.labels,
metric: series.labels,
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);
});
}
});
const oldResultType = resultType;
// 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 isAnomalyRule = apiResponse?.data?.newResult?.data?.result[0].isAnomaly;
const series = getStackedSeries(apiResponse?.data?.result || []);
const bands = stackBarChart ? getBands(series) : null;
@ -251,11 +253,14 @@ export const getUPlotChartOptions = ({
hooks: {
draw: [
(u): void => {
if (isAnomalyRule) {
return;
}
thresholds?.forEach((threshold) => {
if (threshold.thresholdValue !== undefined) {
const { ctx } = u;
ctx.save();
const yPos = u.valToPos(
convertValue(
threshold.thresholdValue,
@ -265,30 +270,22 @@ export const getUPlotChartOptions = ({
'y',
true,
);
ctx.strokeStyle = threshold.thresholdColor || 'red';
ctx.lineWidth = 2;
ctx.setLineDash([10, 5]);
ctx.beginPath();
const plotLeft = u.bbox.left; // left edge of the plot area
const plotRight = plotLeft + u.bbox.width; // right edge of the plot area
ctx.moveTo(plotLeft, yPos);
ctx.lineTo(plotRight, yPos);
ctx.stroke();
// Text configuration
if (threshold.thresholdLabel) {
const text = threshold.thresholdLabel;
const textX = plotRight - ctx.measureText(text).width - 20;
const canvasHeight = ctx.canvas.height;
const yposHeight = canvasHeight - yPos;
const isHeightGreaterThan90Percent = canvasHeight * 0.9 < yposHeight;
// Adjust textY based on the condition
let textY;
if (isHeightGreaterThan90Percent) {
@ -299,7 +296,6 @@ export const getUPlotChartOptions = ({
ctx.fillStyle = threshold.thresholdColor || 'red';
ctx.fillText(text, textX, textY);
}
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 { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
@ -20,7 +22,7 @@ function getXAxisTimestamps(seriesList: QueryData[]): number[] {
function fillMissingXAxisTimestamps(timestampArr: number[], data: any[]): any {
// Generate a set of all timestamps in the range
const allTimestampsSet = new Set(timestampArr);
const processedData = JSON.parse(JSON.stringify(data));
const processedData = cloneDeep(data);
// Fill missing timestamps with null values
processedData.forEach((entry: { values: (number | null)[][] }) => {
@ -90,3 +92,70 @@ export const getUPlotChartData = (
: 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] };
};
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 = {
thresholds?: ThresholdProps[];
series?: QueryDataV3[];

View File

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

View File

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

View File

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

View File

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

View File

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