feat: tooltip plugin to show series data in tooltip (#6194)

* feat: tooltip plugin to show series data in tooltip

* feat: send anomaly function as last function

* feat: default z_score_threshold to 3

* fix: anomaly function not applied on initial load

* feat: maintain select alert type on reload

* feat: maintain select alert type on reload

* chore: update events to handle anomaly alert interactions
This commit is contained in:
Yunus M 2024-10-23 19:06:38 +05:30 committed by GitHub
parent 347868c18b
commit 9419f56e95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 543 additions and 86 deletions

View File

@ -3,10 +3,6 @@ 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',

View File

@ -63,6 +63,16 @@
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
.anomaly-alert-evaluation-view-series-list-item-color {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-flex;
margin-right: 8px;
vertical-align: middle;
}
cursor: pointer; cursor: pointer;
} }
@ -108,3 +118,63 @@
} }
} }
} }
.uplot-tooltip {
background-color: rgba(0, 0, 0, 0.9);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
color: #ddd;
font-size: 13px;
line-height: 1.4;
padding: 8px 12px;
pointer-events: none;
position: absolute;
z-index: 100;
max-height: 500px;
width: 280px;
overflow-y: auto;
display: none; /* Hide tooltip by default */
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
.uplot-tooltip-title {
font-weight: bold;
margin-bottom: 4px;
}
.uplot-tooltip-series {
display: flex;
gap: 4px;
padding: 4px 0px;
align-items: center;
}
.uplot-tooltip-series-name {
margin-right: 4px;
}
.uplot-tooltip-band {
font-style: italic;
color: #666;
}
.uplot-tooltip-marker {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}

View File

@ -13,6 +13,8 @@ import { LineChart } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import uPlot from 'uplot'; import uPlot from 'uplot';
import tooltipPlugin from './tooltipPlugin';
function UplotChart({ function UplotChart({
data, data,
options, options,
@ -149,10 +151,38 @@ function AnomalyAlertEvaluationView({
const options = { const options = {
width: dimensions.width, width: dimensions.width,
height: dimensions.height - 36, height: dimensions.height - 36,
plugins: [bandsPlugin], plugins: [bandsPlugin, tooltipPlugin(isDarkMode)],
focus: { focus: {
alpha: 0.3, alpha: 0.3,
}, },
legend: {
show: true,
live: false,
isolate: true,
},
cursor: {
lock: false,
focus: {
prox: 1e6,
bias: 1,
},
points: {
size: (
u: { series: { [x: string]: { points: { size: number } } } },
seriesIdx: string | number,
): number => u.series[seriesIdx].points.size * 3,
width: (u: any, seriesIdx: any, size: number): number => size / 4,
stroke: (
u: {
series: {
[x: string]: { points: { stroke: (arg0: any, arg1: any) => any } };
};
},
seriesIdx: string | number,
): string => `${u.series[seriesIdx].points.stroke(u, seriesIdx)}90`,
fill: (): string => '#fff',
},
},
series: [ series: [
{ {
label: 'Time', label: 'Time',
@ -165,6 +195,7 @@ function AnomalyAlertEvaluationView({
width: 2, width: 2,
show: true, show: true,
paths: _spline, paths: _spline,
spanGaps: true,
}, },
{ {
label: `Predicted Value`, label: `Predicted Value`,
@ -173,18 +204,29 @@ function AnomalyAlertEvaluationView({
dash: [2, 2], dash: [2, 2],
show: true, show: true,
paths: _spline, paths: _spline,
spanGaps: true,
}, },
{ {
label: `Upper Band`, label: `Upper Band`,
stroke: 'transparent', stroke: 'transparent',
show: false, show: true,
paths: _spline, paths: _spline,
spanGaps: true,
points: {
show: false,
size: 1,
},
}, },
{ {
label: `Lower Band`, label: `Lower Band`,
stroke: 'transparent', stroke: 'transparent',
show: false, show: true,
paths: _spline, paths: _spline,
spanGaps: true,
points: {
show: false,
size: 1,
},
}, },
] ]
: allSeries.map((seriesKey) => ({ : allSeries.map((seriesKey) => ({
@ -193,11 +235,13 @@ function AnomalyAlertEvaluationView({
width: 2, width: 2,
show: true, show: true,
paths: _spline, paths: _spline,
spanGaps: true,
}))), }))),
], ],
scales: { scales: {
x: { x: {
time: true, time: true,
spanGaps: true,
}, },
y: { y: {
...getYAxisScaleForAnomalyDetection({ ...getYAxisScaleForAnomalyDetection({
@ -211,9 +255,6 @@ function AnomalyAlertEvaluationView({
grid: { grid: {
show: true, show: true,
}, },
legend: {
show: true,
},
axes: getAxes(isDarkMode, yAxisUnit), axes: getAxes(isDarkMode, yAxisUnit),
}; };
@ -287,17 +328,24 @@ function AnomalyAlertEvaluationView({
)} )}
{filteredSeriesKeys.map((seriesKey) => ( {filteredSeriesKeys.map((seriesKey) => (
<Checkbox <div key={seriesKey}>
className="anomaly-alert-evaluation-view-series-list-item" <Checkbox
key={seriesKey} className="anomaly-alert-evaluation-view-series-list-item"
type="checkbox" key={seriesKey}
name="series" type="checkbox"
value={seriesKey} name="series"
checked={selectedSeries === seriesKey} value={seriesKey}
onChange={(): void => handleSeriesChange(seriesKey)} checked={selectedSeries === seriesKey}
> onChange={(): void => handleSeriesChange(seriesKey)}
{seriesKey} >
</Checkbox> <div
className="anomaly-alert-evaluation-view-series-list-item-color"
style={{ backgroundColor: seriesData[seriesKey].color }}
/>
{seriesKey}
</Checkbox>
</div>
))} ))}
{filteredSeriesKeys.length === 0 && ( {filteredSeriesKeys.length === 0 && (

View File

@ -0,0 +1,148 @@
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
const tooltipPlugin = (
isDarkMode: boolean,
): { hooks: { init: (u: any) => void } } => {
let tooltip: HTMLDivElement;
const tooltipLeftOffset = 10;
const tooltipTopOffset = 10;
let isMouseOverPlot = false;
function formatValue(value: string | number | Date): string | number | Date {
if (typeof value === 'string' && !Number.isNaN(parseFloat(value))) {
return parseFloat(value).toFixed(3);
}
if (typeof value === 'number') {
return value.toFixed(3);
}
if (value instanceof Date) {
return value.toLocaleString();
}
if (value == null) {
return 'N/A';
}
return String(value);
}
function updateTooltip(u: any, left: number, top: number): void {
const idx = u.posToIdx(left);
const xVal = u.data[0][idx];
if (xVal == null) {
tooltip.style.display = 'none';
return;
}
const xDate = new Date(xVal * 1000);
const formattedXDate = formatValue(xDate);
let tooltipContent = `<div class="uplot-tooltip-title">Time: ${formattedXDate}</div>`;
let mainValue;
let upperBand;
let lowerBand;
let color = null;
// Loop through all series (excluding the x-axis series)
for (let i = 1; i < u.series.length; i++) {
const series = u.series[i];
const yVal = u.data[i][idx];
const formattedYVal = formatValue(yVal);
color = generateColor(
series.label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
// Create the round marker for the series
const marker = `<span class="uplot-tooltip-marker" style="background-color: ${color};"></span>`;
if (series.label.toLowerCase().includes('upper band')) {
upperBand = formattedYVal;
} else if (series.label.toLowerCase().includes('lower band')) {
lowerBand = formattedYVal;
} else if (series.label.toLowerCase().includes('main series')) {
mainValue = formattedYVal;
} else {
tooltipContent += `
<div class="uplot-tooltip-series">
${marker}
<span class="uplot-tooltip-series-name">${series.label}:</span>
<span class="uplot-tooltip-series-value">${formattedYVal}</span>
</div>`;
}
}
// Add main value, upper band, and lower band to the tooltip
if (mainValue !== undefined) {
const marker = `<span class="uplot-tooltip-marker"></span>`;
tooltipContent += `
<div class="uplot-tooltip-series">
${marker}
<span class="uplot-tooltip-series-name">Main Series:</span>
<span class="uplot-tooltip-series-value">${mainValue}</span>
</div>`;
}
if (upperBand !== undefined) {
const marker = `<span class="uplot-tooltip-marker"></span>`;
tooltipContent += `
<div class="uplot-tooltip-series">
${marker}
<span class="uplot-tooltip-series-name">Upper Band:</span>
<span class="uplot-tooltip-series-value">${upperBand}</span>
</div>`;
}
if (lowerBand !== undefined) {
const marker = `<span class="uplot-tooltip-marker"></span>`;
tooltipContent += `
<div class="uplot-tooltip-series">
${marker}
<span class="uplot-tooltip-series-name">Lower Band:</span>
<span class="uplot-tooltip-series-value">${lowerBand}</span>
</div>`;
}
tooltip.innerHTML = tooltipContent;
tooltip.style.display = 'block';
tooltip.style.left = `${left + tooltipLeftOffset}px`;
tooltip.style.top = `${top + tooltipTopOffset}px`;
}
function init(u: any): void {
tooltip = document.createElement('div');
tooltip.className = 'uplot-tooltip';
tooltip.style.display = 'none';
u.over.appendChild(tooltip);
// Add event listeners
u.over.addEventListener('mouseenter', () => {
isMouseOverPlot = true;
});
u.over.addEventListener('mouseleave', () => {
isMouseOverPlot = false;
tooltip.style.display = 'none';
});
u.over.addEventListener('mousemove', (e: MouseEvent) => {
if (isMouseOverPlot) {
const rect = u.over.getBoundingClientRect();
const left = e.clientX - rect.left;
const top = e.clientY - rect.top;
updateTooltip(u, left, top);
}
});
}
return {
hooks: {
init,
},
};
};
export default tooltipPlugin;

View File

@ -4,6 +4,7 @@ import {
initialQueryPromQLData, initialQueryPromQLData,
PANEL_TYPES, PANEL_TYPES,
} from 'constants/queryBuilder'; } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
import { import {
AlertDef, AlertDef,
@ -58,6 +59,49 @@ export const alertDefaults: AlertDef = {
evalWindow: defaultEvalWindow, evalWindow: defaultEvalWindow,
}; };
export const anamolyAlertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V4,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
condition: {
compositeQuery: {
builderQueries: {
A: {
...initialQueryBuilderFormValuesMap.metrics,
functions: [
{
name: 'anomaly',
args: [],
namedArgs: { z_score_threshold: 3 },
},
],
},
},
promQueries: { A: initialQueryPromQLData },
chQueries: {
A: {
name: 'A',
query: ``,
legend: '',
disabled: false,
},
},
queryType: EQueryType.QUERY_BUILDER,
panelType: PANEL_TYPES.TIME_SERIES,
unit: undefined,
},
op: defaultCompareOp,
matchType: defaultMatchType,
algorithm: defaultAlgorithm,
seasonality: defaultSeasonality,
},
labels: {
severity: 'warning',
},
annotations: defaultAnnotations,
evalWindow: defaultEvalWindow,
};
export const logAlertDefaults: AlertDef = { export const logAlertDefaults: AlertDef = {
alertType: AlertTypes.LOGS_BASED_ALERT, alertType: AlertTypes.LOGS_BASED_ALERT,
condition: { condition: {
@ -149,7 +193,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.ANOMALY_BASED_ALERT]: anamolyAlertDefaults,
[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

@ -13,6 +13,7 @@ import { AlertDef } from 'types/api/alerts/def';
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config'; import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
import { import {
alertDefaults, alertDefaults,
anamolyAlertDefaults,
exceptionAlertDefaults, exceptionAlertDefaults,
logAlertDefaults, logAlertDefaults,
traceAlertDefaults, traceAlertDefaults,
@ -24,8 +25,12 @@ function CreateRules(): JSX.Element {
const location = useLocation(); const location = useLocation();
const queryParams = new URLSearchParams(location.search); const queryParams = new URLSearchParams(location.search);
const alertTypeFromURL = queryParams.get(QueryParams.ruleType);
const version = queryParams.get('version'); const version = queryParams.get('version');
const alertTypeFromParams = queryParams.get(QueryParams.alertType); const alertTypeFromParams =
alertTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? AlertTypes.ANOMALY_BASED_ALERT
: queryParams.get(QueryParams.alertType);
const compositeQuery = useGetCompositeQueryParam(); const compositeQuery = useGetCompositeQueryParam();
function getAlertTypeFromDataSource(): AlertTypes | null { function getAlertTypeFromDataSource(): AlertTypes | null {
@ -58,7 +63,7 @@ function CreateRules(): JSX.Element {
break; break;
case AlertTypes.ANOMALY_BASED_ALERT: case AlertTypes.ANOMALY_BASED_ALERT:
setInitValues({ setInitValues({
...alertDefaults, ...anamolyAlertDefaults,
version: version || ENTITY_VERSION_V4, version: version || ENTITY_VERSION_V4,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT, ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
}); });
@ -78,7 +83,10 @@ function CreateRules(): JSX.Element {
: typ, : typ,
); );
if (typ === AlertTypes.ANOMALY_BASED_ALERT) { if (
typ === AlertTypes.ANOMALY_BASED_ALERT ||
alertTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
) {
queryParams.set( queryParams.set(
QueryParams.ruleType, QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT, AlertDetectionTypes.ANOMALY_DETECTION_ALERT,

View File

@ -160,6 +160,15 @@ function RuleOptions({
}); });
}; };
const onChangeDeviation = (value: number): void => {
const target = value || alertDef.condition.target || 3;
setAlertDef({
...alertDef,
condition: { ...alertDef.condition, target: Number(target) },
});
};
const renderEvalWindows = (): JSX.Element => ( const renderEvalWindows = (): JSX.Element => (
<InlineSelect <InlineSelect
getPopupContainer={popupContainer} getPopupContainer={popupContainer}
@ -203,6 +212,28 @@ function RuleOptions({
</InlineSelect> </InlineSelect>
); );
const renderDeviationOpts = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={3}
style={{ minWidth: '120px' }}
value={alertDef.condition.target}
onChange={(value: number | unknown): void => {
if (typeof value === 'number') {
onChangeDeviation(value);
}
}}
>
<Select.Option value={1}>1</Select.Option>
<Select.Option value={2}>2</Select.Option>
<Select.Option value={3}>3</Select.Option>
<Select.Option value={4}>4</Select.Option>
<Select.Option value={5}>5</Select.Option>
<Select.Option value={6}>6</Select.Option>
<Select.Option value={7}>7</Select.Option>
</InlineSelect>
);
const renderSeasonality = (): JSX.Element => ( const renderSeasonality = (): JSX.Element => (
<InlineSelect <InlineSelect
getPopupContainer={popupContainer} getPopupContainer={popupContainer}
@ -237,39 +268,6 @@ 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>
@ -320,6 +318,32 @@ function RuleOptions({
}); });
}; };
const renderAnomalyRuleOpts = (): 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>
{renderDeviationOpts()}
<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 renderFrequency = (): JSX.Element => ( const renderFrequency = (): JSX.Element => (
<InlineSelect <InlineSelect
getPopupContainer={popupContainer} getPopupContainer={popupContainer}
@ -354,7 +378,7 @@ function RuleOptions({
{queryCategory === EQueryType.PROM && renderPromRuleOptions()} {queryCategory === EQueryType.PROM && renderPromRuleOptions()}
{queryCategory !== EQueryType.PROM && {queryCategory !== EQueryType.PROM &&
ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && ( ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<>{renderAnomalyRuleOpts(onChange)}</> <>{renderAnomalyRuleOpts()}</>
)} )}
{queryCategory !== EQueryType.PROM && {queryCategory !== EQueryType.PROM &&

View File

@ -39,6 +39,7 @@ 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';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
import { import {
@ -88,6 +89,8 @@ function FormAlertRules({
>((state) => state.globalTime); >((state) => state.globalTime);
const urlQuery = useUrlQuery(); const urlQuery = useUrlQuery();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
// In case of alert the panel types should always be "Graph" only // In case of alert the panel types should always be "Graph" only
const panelType = PANEL_TYPES.TIME_SERIES; const panelType = PANEL_TYPES.TIME_SERIES;
@ -120,9 +123,7 @@ function FormAlertRules({
const alertTypeFromURL = urlQuery.get(QueryParams.ruleType); const alertTypeFromURL = urlQuery.get(QueryParams.ruleType);
const [detectionMethod, setDetectionMethod] = useState<string>( const [detectionMethod, setDetectionMethod] = useState<string | null>(null);
AlertDetectionTypes.THRESHOLD_ALERT,
);
useEffect(() => { useEffect(() => {
if (!isEqual(currentQuery.unit, yAxisUnit)) { if (!isEqual(currentQuery.unit, yAxisUnit)) {
@ -154,11 +155,24 @@ function FormAlertRules({
useShareBuilderUrl(sq); useShareBuilderUrl(sq);
const handleDetectionMethodChange = (value: string): void => {
setAlertDef((def) => ({
...def,
ruleType: value,
}));
logEvent(`Alert: Detection method changed`, {
detectionMethod: value,
});
setDetectionMethod(value);
};
const updateFunctions = (data: IBuilderQuery): QueryFunctionProps[] => { const updateFunctions = (data: IBuilderQuery): QueryFunctionProps[] => {
const anomalyFunction = { const anomalyFunction = {
name: 'anomaly', name: 'anomaly',
args: [], args: [],
namedArgs: { z_score_threshold: 9 }, namedArgs: { z_score_threshold: alertDef.condition.target || 3 },
}; };
const functions = data.functions || []; const functions = data.functions || [];
@ -166,6 +180,19 @@ function FormAlertRules({
// Add anomaly if not already present // Add anomaly if not already present
if (!functions.some((func) => func.name === 'anomaly')) { if (!functions.some((func) => func.name === 'anomaly')) {
functions.push(anomalyFunction); functions.push(anomalyFunction);
} else {
const anomalyFuncIndex = functions.findIndex(
(func) => func.name === 'anomaly',
);
if (anomalyFuncIndex !== -1) {
const anomalyFunc = {
...functions[anomalyFuncIndex],
namedArgs: { z_score_threshold: alertDef.condition.target || 3 },
};
functions.splice(anomalyFuncIndex, 1);
functions.push(anomalyFunc);
}
} }
} else { } else {
// Remove anomaly if present // Remove anomaly if present
@ -178,6 +205,20 @@ function FormAlertRules({
return functions; return functions;
}; };
useEffect(() => {
const ruleType =
detectionMethod === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT;
queryParams.set(QueryParams.ruleType, ruleType);
const generatedUrl = `${location.pathname}?${queryParams.toString()}`;
history.replace(generatedUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detectionMethod]);
const updateFunctionsBasedOnAlertType = (): void => { const updateFunctionsBasedOnAlertType = (): void => {
for (let index = 0; index < currentQuery.builder.queryData.length; index++) { for (let index = 0; index < currentQuery.builder.queryData.length; index++) {
const queryData = currentQuery.builder.queryData[index]; const queryData = currentQuery.builder.queryData[index];
@ -191,7 +232,11 @@ function FormAlertRules({
useEffect(() => { useEffect(() => {
updateFunctionsBasedOnAlertType(); updateFunctionsBasedOnAlertType();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [detectionMethod, alertDef, currentQuery.builder.queryData.length]); }, [
detectionMethod,
alertDef.condition.target,
currentQuery.builder.queryData.length,
]);
useEffect(() => { useEffect(() => {
const broadcastToSpecificChannels = const broadcastToSpecificChannels =
@ -215,7 +260,8 @@ function FormAlertRules({
}); });
setDetectionMethod(ruleType); setDetectionMethod(ruleType);
}, [initialValue, isNewRule, alertTypeFromURL]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValue, isNewRule]);
useEffect(() => { useEffect(() => {
// Set selectedQueryName based on the length of queryOptions // Set selectedQueryName based on the length of queryOptions
@ -335,7 +381,11 @@ function FormAlertRules({
return false; return false;
} }
if (alertDef.condition?.target !== 0 && !alertDef.condition?.target) { if (
alertDef.ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT &&
alertDef.condition?.target !== 0 &&
!alertDef.condition?.target
) {
notifications.error({ notifications.error({
message: 'Error', message: 'Error',
description: t('target_missing'), description: t('target_missing'),
@ -493,6 +543,7 @@ function FormAlertRules({
queryType: currentQuery.queryType, queryType: currentQuery.queryType,
alertId: postableAlert?.id, alertId: postableAlert?.id,
alertName: postableAlert?.alert, alertName: postableAlert?.alert,
ruleType: postableAlert?.ruleType,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
@ -577,6 +628,7 @@ function FormAlertRules({
queryType: currentQuery.queryType, queryType: currentQuery.queryType,
status: statusResponse.status, status: statusResponse.status,
statusMessage: statusResponse.message, statusMessage: statusResponse.message,
ruleType: postableAlert.ruleType,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, isFormValid, memoizedPreparePostData, notifications]); }, [t, isFormValid, memoizedPreparePostData, notifications]);
@ -672,15 +724,6 @@ function FormAlertRules({
}, },
]; ];
const handleDetectionMethodChange = (value: any): void => {
setAlertDef((def) => ({
...def,
ruleType: value,
}));
setDetectionMethod(value);
};
const isAnomalyDetectionEnabled = const isAnomalyDetectionEnabled =
useFeatureFlag(FeatureKeys.ANOMALY_DETECTION)?.active || false; useFeatureFlag(FeatureKeys.ANOMALY_DETECTION)?.active || false;
@ -745,7 +788,7 @@ function FormAlertRules({
<Tabs2 <Tabs2
key={detectionMethod} key={detectionMethod}
tabs={tabs} tabs={tabs}
initialSelectedTab={detectionMethod} initialSelectedTab={detectionMethod || ''}
onSelectTab={handleDetectionMethodChange} onSelectTab={handleDetectionMethodChange}
/> />

View File

@ -13,7 +13,7 @@ import {
IBuilderQuery, IBuilderQuery,
QueryFunctionProps, QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData'; } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder';
interface FunctionProps { interface FunctionProps {
query: IBuilderQuery; query: IBuilderQuery;
@ -57,6 +57,13 @@ export default function Function({
? logsQueryFunctionOptions ? logsQueryFunctionOptions
: metricQueryFunctionOptions; : metricQueryFunctionOptions;
const disableRemoveFunction = funcData.name === QueryFunctionsTypes.ANOMALY;
if (funcData.name === QueryFunctionsTypes.ANOMALY) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
return ( return (
<Flex className="query-function"> <Flex className="query-function">
<Select <Select
@ -92,6 +99,7 @@ export default function Function({
<Button <Button
className="periscope-btn query-function-delete-btn" className="periscope-btn query-function-delete-btn"
disabled={disableRemoveFunction}
onClick={(): void => { onClick={(): void => {
handleDeleteFunction(funcData, index); handleDeleteFunction(funcData, index);
}} }}

View File

@ -92,6 +92,8 @@ export default function QueryFunctions({
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const hasAnomalyFunction = functions.some((func) => func.name === 'anomaly');
const handleAddNewFunction = (): void => { const handleAddNewFunction = (): void => {
const defaultFunctionStruct = const defaultFunctionStruct =
query.dataSource === DataSource.LOGS query.dataSource === DataSource.LOGS
@ -105,9 +107,22 @@ export default function QueryFunctions({
}, },
]; ];
setFunctions(updatedFunctionsArr); const functionsCopy = cloneDeep(updatedFunctionsArr);
onChange(updatedFunctionsArr); const anomalyFuncIndex = functionsCopy.findIndex(
(func) => func.name === 'anomaly',
);
if (anomalyFuncIndex !== -1) {
const anomalyFunc = functionsCopy[anomalyFuncIndex];
functionsCopy.splice(anomalyFuncIndex, 1);
functionsCopy.push(anomalyFunc);
}
setFunctions(functionsCopy);
onChange(functionsCopy);
}; };
const handleDeleteFunction = ( const handleDeleteFunction = (
@ -181,7 +196,9 @@ export default function QueryFunctions({
<Tooltip <Tooltip
title={ title={
functions && functions.length >= 3 ? ( functions && functions.length >= 3 ? (
'Functions are in early access. You can add a maximum of 3 function as of now.' `Functions are in early access. You can add a maximum of ${
hasAnomalyFunction ? 2 : 3
} function as of now.`
) : ( ) : (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
Add new function Add new function

View File

@ -1,9 +1,11 @@
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName'; 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';
import { generateColor } from './generateColor';
function getXAxisTimestamps(seriesList: QueryData[]): number[] { function getXAxisTimestamps(seriesList: QueryData[]): number[] {
const timestamps = new Set(); const timestamps = new Set();
@ -95,6 +97,7 @@ export const getUPlotChartData = (
const processAnomalyDetectionData = ( const processAnomalyDetectionData = (
anomalyDetectionData: any, anomalyDetectionData: any,
isDarkMode: boolean,
): Record<string, { data: number[][]; color: string }> => { ): Record<string, { data: number[][]; color: string }> => {
if (!anomalyDetectionData) { if (!anomalyDetectionData) {
return {}; return {};
@ -126,7 +129,8 @@ const processAnomalyDetectionData = (
legend || '', legend || '',
); );
const objKey = `${queryName}-${label}`; const objKey =
anomalyDetectionData.length > 1 ? `${queryName}-${label}` : label;
processedData[objKey] = { processedData[objKey] = {
data: [ data: [
@ -136,7 +140,10 @@ const processAnomalyDetectionData = (
upperBoundSeries[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), lowerBoundSeries[index].values.map((v: { value: number }) => v.value),
], ],
color: colors[index], color: generateColor(
objKey,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
),
legendLabel: label, legendLabel: label,
}; };
} }
@ -156,6 +163,6 @@ export const getUplotChartDataForAnomalyDetection = (
} }
> => { > => {
const anomalyDetectionData = apiResponse?.data?.newResult?.data?.result; const anomalyDetectionData = apiResponse?.data?.newResult?.data?.result;
const isDarkMode = true;
return processAnomalyDetectionData(anomalyDetectionData); return processAnomalyDetectionData(anomalyDetectionData, isDarkMode);
}; };

View File

@ -233,6 +233,47 @@ GetYAxisScale): { auto?: boolean; range?: uPlot.Scale.Range } => {
return { auto: false, range: [min, max] }; return { auto: false, range: [min, max] };
}; };
function adjustMinMax(
min: number,
max: number,
): {
adjustedMin: number;
adjustedMax: number;
} {
// Ensure min and max are valid
if (min === -Infinity && max === Infinity) {
return { adjustedMin: -Infinity, adjustedMax: Infinity };
}
const range = max - min;
const adjustment = range * 0.1;
let adjustedMin: number;
let adjustedMax: number;
// Handle the case for -Infinity
if (min === -Infinity) {
adjustedMin = -Infinity;
} else if (min === 0) {
adjustedMin = min - adjustment; // Special case for when min is 0
} else if (min < 0) {
// For negative min, add 10% of the range to bring closer to zero
adjustedMin = min - range * 0.1;
} else {
// For positive min, subtract 10% from min itself
adjustedMin = min - min * 0.1;
}
// Handle the case for Infinity
if (max === Infinity) {
adjustedMax = Infinity;
} else {
adjustedMax = max * 1.1; // Regular case for finite max
}
return { adjustedMin, adjustedMax };
}
function getMinMax(data: any): { minValue: number; maxValue: number } { function getMinMax(data: any): { minValue: number; maxValue: number } {
// Exclude the first array // Exclude the first array
const arrays = data.slice(1); const arrays = data.slice(1);
@ -244,7 +285,9 @@ function getMinMax(data: any): { minValue: number; maxValue: number } {
const minValue = flattened.length ? Math.min(...flattened) : 0; const minValue = flattened.length ? Math.min(...flattened) : 0;
const maxValue = Math.max(...flattened); const maxValue = Math.max(...flattened);
return { minValue, maxValue }; const { adjustedMin, adjustedMax } = adjustMinMax(minValue, maxValue);
return { minValue: adjustedMin, maxValue: adjustedMax };
} }
export const getYAxisScaleForAnomalyDetection = ({ export const getYAxisScaleForAnomalyDetection = ({

View File

@ -50,6 +50,7 @@ export type OrderByPayload = {
export interface QueryFunctionProps { export interface QueryFunctionProps {
name: string; name: string;
args: string[]; args: string[];
namedArgs?: Record<string, any>;
} }
// Type for query builder // Type for query builder