diff --git a/frontend/src/constants/queryFunctionOptions.ts b/frontend/src/constants/queryFunctionOptions.ts index df0ad0ed0e..0069646c40 100644 --- a/frontend/src/constants/queryFunctionOptions.ts +++ b/frontend/src/constants/queryFunctionOptions.ts @@ -3,10 +3,6 @@ import { QueryFunctionsTypes } from 'types/common/queryBuilder'; import { SelectOption } from 'types/common/select'; export const metricQueryFunctionOptions: SelectOption[] = [ - { - value: QueryFunctionsTypes.ANOMALY, - label: 'Anomaly', - }, { value: QueryFunctionsTypes.CUTOFF_MIN, label: 'Cut Off Min', diff --git a/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.styles.scss b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.styles.scss index a21e769487..32f42edf6a 100644 --- a/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.styles.scss +++ b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.styles.scss @@ -63,6 +63,16 @@ flex-direction: row; 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; } @@ -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; +} diff --git a/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.tsx b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.tsx index 93f8a19208..13f654fd10 100644 --- a/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.tsx +++ b/frontend/src/container/AnomalyAlertEvaluationView/AnomalyAlertEvaluationView.tsx @@ -13,6 +13,8 @@ import { LineChart } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import uPlot from 'uplot'; +import tooltipPlugin from './tooltipPlugin'; + function UplotChart({ data, options, @@ -149,10 +151,38 @@ function AnomalyAlertEvaluationView({ const options = { width: dimensions.width, height: dimensions.height - 36, - plugins: [bandsPlugin], + plugins: [bandsPlugin, tooltipPlugin(isDarkMode)], focus: { 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: [ { label: 'Time', @@ -165,6 +195,7 @@ function AnomalyAlertEvaluationView({ width: 2, show: true, paths: _spline, + spanGaps: true, }, { label: `Predicted Value`, @@ -173,18 +204,29 @@ function AnomalyAlertEvaluationView({ dash: [2, 2], show: true, paths: _spline, + spanGaps: true, }, { label: `Upper Band`, stroke: 'transparent', - show: false, + show: true, paths: _spline, + spanGaps: true, + points: { + show: false, + size: 1, + }, }, { label: `Lower Band`, stroke: 'transparent', - show: false, + show: true, paths: _spline, + spanGaps: true, + points: { + show: false, + size: 1, + }, }, ] : allSeries.map((seriesKey) => ({ @@ -193,11 +235,13 @@ function AnomalyAlertEvaluationView({ width: 2, show: true, paths: _spline, + spanGaps: true, }))), ], scales: { x: { time: true, + spanGaps: true, }, y: { ...getYAxisScaleForAnomalyDetection({ @@ -211,9 +255,6 @@ function AnomalyAlertEvaluationView({ grid: { show: true, }, - legend: { - show: true, - }, axes: getAxes(isDarkMode, yAxisUnit), }; @@ -287,17 +328,24 @@ function AnomalyAlertEvaluationView({ )} {filteredSeriesKeys.map((seriesKey) => ( - handleSeriesChange(seriesKey)} - > - {seriesKey} - +
+ handleSeriesChange(seriesKey)} + > +
+ + {seriesKey} + +
))} {filteredSeriesKeys.length === 0 && ( diff --git a/frontend/src/container/AnomalyAlertEvaluationView/tooltipPlugin.ts b/frontend/src/container/AnomalyAlertEvaluationView/tooltipPlugin.ts new file mode 100644 index 0000000000..6d32dbee35 --- /dev/null +++ b/frontend/src/container/AnomalyAlertEvaluationView/tooltipPlugin.ts @@ -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 = `
Time: ${formattedXDate}
`; + + 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 = ``; + + 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 += ` +
+ ${marker} + ${series.label}: + ${formattedYVal} +
`; + } + } + + // Add main value, upper band, and lower band to the tooltip + if (mainValue !== undefined) { + const marker = ``; + tooltipContent += ` +
+ ${marker} + Main Series: + ${mainValue} +
`; + } + if (upperBand !== undefined) { + const marker = ``; + tooltipContent += ` +
+ ${marker} + Upper Band: + ${upperBand} +
`; + } + if (lowerBand !== undefined) { + const marker = ``; + tooltipContent += ` +
+ ${marker} + Lower Band: + ${lowerBand} +
`; + } + + 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; diff --git a/frontend/src/container/CreateAlertRule/defaults.ts b/frontend/src/container/CreateAlertRule/defaults.ts index 9393c7bc1a..23e6b3f77d 100644 --- a/frontend/src/container/CreateAlertRule/defaults.ts +++ b/frontend/src/container/CreateAlertRule/defaults.ts @@ -4,6 +4,7 @@ import { initialQueryPromQLData, PANEL_TYPES, } from 'constants/queryBuilder'; +import { AlertDetectionTypes } from 'container/FormAlertRules'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertDef, @@ -58,6 +59,49 @@ export const alertDefaults: AlertDef = { 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 = { alertType: AlertTypes.LOGS_BASED_ALERT, condition: { @@ -149,7 +193,7 @@ export const exceptionAlertDefaults: AlertDef = { }; export const ALERTS_VALUES_MAP: Record = { - [AlertTypes.ANOMALY_BASED_ALERT]: alertDefaults, + [AlertTypes.ANOMALY_BASED_ALERT]: anamolyAlertDefaults, [AlertTypes.METRICS_BASED_ALERT]: alertDefaults, [AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults, [AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults, diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx index 55c4fdc090..96e605b9b0 100644 --- a/frontend/src/container/CreateAlertRule/index.tsx +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -13,6 +13,7 @@ import { AlertDef } from 'types/api/alerts/def'; import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config'; import { alertDefaults, + anamolyAlertDefaults, exceptionAlertDefaults, logAlertDefaults, traceAlertDefaults, @@ -24,8 +25,12 @@ function CreateRules(): JSX.Element { const location = useLocation(); const queryParams = new URLSearchParams(location.search); + const alertTypeFromURL = queryParams.get(QueryParams.ruleType); 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(); function getAlertTypeFromDataSource(): AlertTypes | null { @@ -58,7 +63,7 @@ function CreateRules(): JSX.Element { break; case AlertTypes.ANOMALY_BASED_ALERT: setInitValues({ - ...alertDefaults, + ...anamolyAlertDefaults, version: version || ENTITY_VERSION_V4, ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT, }); @@ -78,7 +83,10 @@ function CreateRules(): JSX.Element { : typ, ); - if (typ === AlertTypes.ANOMALY_BASED_ALERT) { + if ( + typ === AlertTypes.ANOMALY_BASED_ALERT || + alertTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT + ) { queryParams.set( QueryParams.ruleType, AlertDetectionTypes.ANOMALY_DETECTION_ALERT, diff --git a/frontend/src/container/FormAlertRules/RuleOptions.tsx b/frontend/src/container/FormAlertRules/RuleOptions.tsx index 9ab9a66678..969e34b958 100644 --- a/frontend/src/container/FormAlertRules/RuleOptions.tsx +++ b/frontend/src/container/FormAlertRules/RuleOptions.tsx @@ -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 renderDeviationOpts = (): JSX.Element => ( + { + if (typeof value === 'number') { + onChangeDeviation(value); + } + }} + > + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + ); + const renderSeasonality = (): JSX.Element => ( ); - const renderAnomalyRuleOpts = ( - onChange: InputNumberProps['onChange'], - ): JSX.Element => ( - - - {t('text_condition1_anomaly')} - - {t('text_condition3')} {renderEvalWindows()} - is - e.currentTarget.blur()} - /> - deviations - {renderCompareOps()} - the predicted data - {renderMatchOpts()} - using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '} - seasonality - - - ); - const renderPromRuleOptions = (): JSX.Element => ( @@ -320,6 +318,32 @@ function RuleOptions({ }); }; + const renderAnomalyRuleOpts = (): JSX.Element => ( + + + {t('text_condition1_anomaly')} + + {t('text_condition3')} {renderEvalWindows()} + is + {renderDeviationOpts()} + deviations + {renderCompareOps()} + the predicted data + {renderMatchOpts()} + using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '} + seasonality + + + ); + const renderFrequency = (): JSX.Element => ( {renderAnomalyRuleOpts(onChange)} + <>{renderAnomalyRuleOpts()} )} {queryCategory !== EQueryType.PROM && diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 2b2a232368..9b33198ff7 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -39,6 +39,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from 'react-query'; import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { AppState } from 'store/reducers'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { @@ -88,6 +89,8 @@ function FormAlertRules({ >((state) => state.globalTime); const urlQuery = useUrlQuery(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); // In case of alert the panel types should always be "Graph" only const panelType = PANEL_TYPES.TIME_SERIES; @@ -120,9 +123,7 @@ function FormAlertRules({ const alertTypeFromURL = urlQuery.get(QueryParams.ruleType); - const [detectionMethod, setDetectionMethod] = useState( - AlertDetectionTypes.THRESHOLD_ALERT, - ); + const [detectionMethod, setDetectionMethod] = useState(null); useEffect(() => { if (!isEqual(currentQuery.unit, yAxisUnit)) { @@ -154,11 +155,24 @@ function FormAlertRules({ 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 anomalyFunction = { name: 'anomaly', args: [], - namedArgs: { z_score_threshold: 9 }, + namedArgs: { z_score_threshold: alertDef.condition.target || 3 }, }; const functions = data.functions || []; @@ -166,6 +180,19 @@ function FormAlertRules({ // Add anomaly if not already present if (!functions.some((func) => func.name === 'anomaly')) { 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 { // Remove anomaly if present @@ -178,6 +205,20 @@ function FormAlertRules({ 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 => { for (let index = 0; index < currentQuery.builder.queryData.length; index++) { const queryData = currentQuery.builder.queryData[index]; @@ -191,7 +232,11 @@ function FormAlertRules({ useEffect(() => { updateFunctionsBasedOnAlertType(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [detectionMethod, alertDef, currentQuery.builder.queryData.length]); + }, [ + detectionMethod, + alertDef.condition.target, + currentQuery.builder.queryData.length, + ]); useEffect(() => { const broadcastToSpecificChannels = @@ -215,7 +260,8 @@ function FormAlertRules({ }); setDetectionMethod(ruleType); - }, [initialValue, isNewRule, alertTypeFromURL]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValue, isNewRule]); useEffect(() => { // Set selectedQueryName based on the length of queryOptions @@ -335,7 +381,11 @@ function FormAlertRules({ 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({ message: 'Error', description: t('target_missing'), @@ -493,6 +543,7 @@ function FormAlertRules({ queryType: currentQuery.queryType, alertId: postableAlert?.id, alertName: postableAlert?.alert, + ruleType: postableAlert?.ruleType, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -577,6 +628,7 @@ function FormAlertRules({ queryType: currentQuery.queryType, status: statusResponse.status, statusMessage: statusResponse.message, + ruleType: postableAlert.ruleType, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [t, isFormValid, memoizedPreparePostData, notifications]); @@ -672,15 +724,6 @@ function FormAlertRules({ }, ]; - const handleDetectionMethodChange = (value: any): void => { - setAlertDef((def) => ({ - ...def, - ruleType: value, - })); - - setDetectionMethod(value); - }; - const isAnomalyDetectionEnabled = useFeatureFlag(FeatureKeys.ANOMALY_DETECTION)?.active || false; @@ -745,7 +788,7 @@ function FormAlertRules({ diff --git a/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx b/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx index 1611e36444..d74a6f6ea4 100644 --- a/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx +++ b/frontend/src/container/QueryBuilder/components/QueryFunctions/Function.tsx @@ -13,7 +13,7 @@ import { IBuilderQuery, QueryFunctionProps, } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; +import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder'; interface FunctionProps { query: IBuilderQuery; @@ -57,6 +57,13 @@ export default function Function({ ? logsQueryFunctionOptions : metricQueryFunctionOptions; + const disableRemoveFunction = funcData.name === QueryFunctionsTypes.ANOMALY; + + if (funcData.name === QueryFunctionsTypes.ANOMALY) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; + } + return (