/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ import { AnnotationDomainType, Axis, Chart, LineAnnotation, LineSeries, niceTimeFormatter, Position, RectAnnotation, RectAnnotationDatum, ScaleType, Settings, XYBrushArea, } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLoadingChart, EuiStat, EuiButtonGroup, EuiText, EuiCallOut, } from '@elastic/eui'; import { forEach, get } from 'lodash'; import moment from 'moment'; import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useDelayedLoader } from '../../../hooks/useDelayedLoader'; import { DateRange, Detector, Monitor, MonitorAlert, AnomalyData, Anomalies, } from '../../../models/interfaces'; import { AppState } from '../../../redux/reducers'; import { searchAlerts } from '../../../redux/reducers/alerting'; import { darkModeEnabled } from '../../../utils/opensearchDashboardsUtils'; import { filterWithDateRange, prepareDataForChart, getAnomalyDataRangeQuery, getHistoricalAggQuery, parseHistoricalAggregatedAnomalies, convertToEntityString, flattenData, generateAnomalyAnnotations, } from '../../utils/anomalyResultUtils'; import { AlertsFlyout } from '../components/AlertsFlyout/AlertsFlyout'; import { AlertsStat, AnomalyStatWithTooltip, } from '../components/AnomaliesStat/AnomalyStat'; import { convertAlerts, disabledHistoryAnnotations, generateAlertAnnotations, getAnomalyGradeWording, getAnomalyOccurrenceWording, getConfidenceWording, getLastAnomalyOccurrenceWording, } from '../utils/anomalyChartUtils'; import { ANOMALY_CHART_THEME, CHART_FIELDS, CHART_COLORS, INITIAL_ANOMALY_SUMMARY, } from '../utils/constants'; import { HeatmapCell } from './AnomalyHeatmapChart'; import { ANOMALY_AGG, MIN_END_TIME, MAX_END_TIME } from '../../utils/constants'; import { MAX_HISTORICAL_AGG_RESULTS } from '../../../utils/constants'; import { searchResults } from '../../../redux/reducers/anomalyResults'; import { DAY_IN_MILLI_SECS, WEEK_IN_MILLI_SECS, DETECTOR_STATE, } from '../../../../server/utils/constants'; import { ENTITY_COLORS } from '../../DetectorResults/utils/constants'; interface AnomalyDetailsChartProps { onDateRangeChange( startDate: number, endDate: number, dateRangeOption?: string ): void; onZoomRangeChange(startDate: number, endDate: number): void; anomalies: AnomalyData[][]; bucketizedAnomalies: boolean; anomalySummary: any; dateRange: DateRange; isLoading: boolean; showAlerts?: boolean; isNotSample?: boolean; anomalyGradeSeriesName: string; confidenceSeriesName: string; detector: Detector; monitor?: Monitor; isHCDetector?: boolean; isHistorical?: boolean; selectedHeatmapCell?: HeatmapCell; onDatePickerRangeChange?(startDate: number, endDate: number): void; openOutOfRangeCallOut?: boolean; } export const AnomalyDetailsChart = React.memo( (props: AnomalyDetailsChartProps) => { const dispatch = useDispatch(); const [showAlertsFlyout, setShowAlertsFlyout] = useState(false); const [alertAnnotations, setAlertAnnotations] = useState([]); const [isLoadingAlerts, setIsLoadingAlerts] = useState(false); const [totalAlerts, setTotalAlerts] = useState( undefined ); const [alerts, setAlerts] = useState([]); const [zoomRange, setZoomRange] = useState({ ...props.dateRange, }); const [zoomedAnomalies, setZoomedAnomalies] = useState( props.anomalies ); // Aggregated anomalies will always be a single time series (AnomalyData[]). // We don't support multiple time series of aggregated anomalies. const [aggregatedAnomalies, setAggregatedAnomalies] = useState< AnomalyData[] >([]); const [selectedAggId, setSelectedAggId] = useState( ANOMALY_AGG.RAW ); const [disabledAggsMap, setDisabledAggsMap] = useState<{ [key in ANOMALY_AGG]: boolean; }>({ raw: false, day: true, week: true, month: true, }); const anomalySummary = get( props, 'anomalySummary', INITIAL_ANOMALY_SUMMARY ); const DEFAULT_DATE_PICKER_RANGE = { start: moment().subtract(7, 'days').valueOf(), end: moment().valueOf(), }; const taskId = get(props, 'detector.taskId'); const taskState = get(props, 'detector.taskState'); const isRequestingAnomalyResults = useSelector( (state: AppState) => state.anomalyResults.requesting ); const resultIndex = get(props, 'detector.resultIndex', ''); const getAggregatedAnomalies = async () => { const anomalyDataRangeQuery = getAnomalyDataRangeQuery( zoomRange.startDate, zoomRange.endDate, taskId ); dispatch(searchResults(anomalyDataRangeQuery, resultIndex, true)) .then((response: any) => { // Only retrieve buckets that are in the anomaly results range. This is so // we don't show aggregate results for where there is no data at all const dataStartDate = get( response, `response.aggregations.${MIN_END_TIME}.value` ); const dataEndDate = get( response, `response.aggregations.${MAX_END_TIME}.value` ); const historicalAggQuery = getHistoricalAggQuery( dataStartDate, dataEndDate, taskId, selectedAggId ); dispatch(searchResults(historicalAggQuery, resultIndex, true)) .then((response: any) => { const aggregatedAnomalies = parseHistoricalAggregatedAnomalies( response, selectedAggId ); setAggregatedAnomalies(aggregatedAnomalies); }) .catch((e: any) => { console.error( `Error getting aggregated anomaly results for detector ${props.detector?.id}: `, e ); }); }) .catch((e: any) => { console.error( `Error getting aggregated anomaly results for detector ${props.detector?.id}: `, e ); }); }; useEffect(() => { setZoomRange(props.dateRange); }, [props.dateRange]); // Hook to check if any of the aggregation tabs should be disabled or not. // Currently support up to 10k results, which = 10k day/week/month aggregate results useEffect(() => { if (props.isHistorical) { const anomalyDataRangeQuery = getAnomalyDataRangeQuery( zoomRange.startDate, zoomRange.endDate, taskId ); dispatch(searchResults(anomalyDataRangeQuery, resultIndex, true)) .then((response: any) => { const dataStartDate = get( response, `response.aggregations.${MIN_END_TIME}.value` ); const dataEndDate = get( response, `response.aggregations.${MAX_END_TIME}.value` ); // Note that the monthly interval is approximate. 365 / 12 = 30.41 days, rounded up to 31 means // there will be <= 10k monthly-aggregate buckets const numDailyBuckets = (dataEndDate - dataStartDate) / DAY_IN_MILLI_SECS; const numWeeklyBuckets = (dataEndDate - dataStartDate) / WEEK_IN_MILLI_SECS; const numMonthlyBuckets = (dataEndDate - dataStartDate) / (31 * DAY_IN_MILLI_SECS); const newAggId = (numDailyBuckets > MAX_HISTORICAL_AGG_RESULTS && selectedAggId === ANOMALY_AGG.DAILY) || (numWeeklyBuckets > MAX_HISTORICAL_AGG_RESULTS && selectedAggId === ANOMALY_AGG.WEEKLY) || (numMonthlyBuckets > MAX_HISTORICAL_AGG_RESULTS && selectedAggId === ANOMALY_AGG.MONTHLY) ? ANOMALY_AGG.RAW : (selectedAggId as ANOMALY_AGG); setSelectedAggId(newAggId); setDisabledAggsMap({ raw: false, day: numDailyBuckets > MAX_HISTORICAL_AGG_RESULTS, week: numWeeklyBuckets > MAX_HISTORICAL_AGG_RESULTS, month: numMonthlyBuckets > MAX_HISTORICAL_AGG_RESULTS, }); }) .catch((e: any) => { console.error( `Error getting aggregated anomaly results for detector ${props.detector?.id}: `, e ); }); } }, [zoomRange]); useEffect(() => { if (selectedAggId !== ANOMALY_AGG.RAW) { getAggregatedAnomalies(); } }, [selectedAggId, zoomRange, props.anomalies]); useEffect(() => { // Aggregated anomalies are already formatted differently // in parseHistoricalAggregatedAnomalies(). Only raw anomalies // need to be formatted with prepareDataForChart(). const anomalies = selectedAggId === ANOMALY_AGG.RAW ? (prepareDataForChart(props.anomalies, zoomRange) as AnomalyData[][]) : [aggregatedAnomalies]; setZoomedAnomalies(anomalies); setTotalAlerts( filterWithDateRange(alerts, zoomRange, 'startTime').length ); }, [props.anomalies, zoomRange, aggregatedAnomalies, selectedAggId]); const handleZoomRangeChange = (start: number, end: number) => { // In the HC scenario, we only want to change the local zoom range. // We don't want to change the overall date range, since that would auto-de-select // any selected heatmap cell, and re-fetch results based on the new date range if (props.isHCDetector) { setZoomRange({ startDate: start, endDate: end, }); props.onZoomRangeChange(start, end); } else { props.onDateRangeChange(start, end); } }; useEffect(() => { async function getMonitorAlerts( monitorId: string, startDateTime: number, endDateTime: number ) { try { setIsLoadingAlerts(true); const result = await dispatch( searchAlerts(monitorId, startDateTime, endDateTime) ); setIsLoadingAlerts(false); setTotalAlerts(get(result, 'response.totalAlerts')); const monitorAlerts = convertAlerts(result); setAlerts(monitorAlerts); const annotations = generateAlertAnnotations(monitorAlerts); setAlertAnnotations(annotations); } catch (err) { console.error(`Failed to get alerts for monitor ${monitorId}`, err); setIsLoadingAlerts(false); } } if ( props.monitor && props.dateRange && // only load alert stats for non HC detector props.isHCDetector !== true ) { getMonitorAlerts( props.monitor.id, props.dateRange.startDate, props.dateRange.endDate ); } }, [props.monitor, props.dateRange.startDate, props.dateRange.endDate]); const anomalyChartTimeFormatter = niceTimeFormatter([ zoomRange.startDate, zoomRange.endDate, ]); const customAnomalyContributionTooltip = (details?: string) => { const anomaly = details ? JSON.parse(details) : undefined; const contributionData = get(anomaly, `contributions`, []); const featureData = get(anomaly, `features`, {}); let featureAttributionList = [] as any[]; if (Array.isArray(contributionData)) { contributionData.map((contribution: any) => { const featureName = get( get(featureData, contribution.feature_id, ''), 'name', '' ); const dataString = contribution.data * 100 + '%'; featureAttributionList.push(
{featureName}: {dataString}
); }); } else { for (const [, value] of Object.entries(contributionData)) { featureAttributionList.push(
{value.name}: {value.attribution}
); } } return (
Feature Contribution: {anomaly ? (


{featureAttributionList}

) : null}
); }; const generateContributionAnomalyAnnotations = ( anomalies: AnomalyData[][] ): any[][] => { let annotations = [] as any[]; anomalies.forEach((anomalyTimeSeries: AnomalyData[]) => { annotations.push( Array.isArray(anomalyTimeSeries) ? anomalyTimeSeries .filter((anomaly: AnomalyData) => anomaly.anomalyGrade > 0) .map((anomaly: AnomalyData) => ({ coordinates: { x0: anomaly.startTime, x1: anomaly.endTime + (anomaly.endTime - anomaly.startTime), }, details: `${JSON.stringify(anomaly)}`, })) : [] ); }); return annotations; }; const isLoading = props.isLoading || isLoadingAlerts || isRequestingAnomalyResults; const isInitializingHistorical = taskState === DETECTOR_STATE.INIT; const showLoader = useDelayedLoader(isLoading); const showAggregateResults = props.isHistorical && selectedAggId !== ANOMALY_AGG.RAW; const multipleTimeSeries = zoomedAnomalies.length > 1; return ( {props.openOutOfRangeCallOut ? ( {`Your selected dates are not in the range from when the detector last started streaming data (${moment(get(props, 'detector.enabledTime')).format( 'MM/DD/YYYY hh:mm A' )}).`} ) : null} {props.isHistorical ? null : ( )} {props.isHistorical ? ( ) : null} {props.isHistorical ? null : ( )} {props.isHistorical ? null : ( )} {props.showAlerts && !props.isHCDetector ? ( setShowAlertsFlyout(true)} totalAlerts={totalAlerts} isLoading={isLoading} /> ) : null} {props.isHistorical && !props.isHCDetector ? ( { setSelectedAggId(aggId as ANOMALY_AGG); }} /> ) : null}
{isLoading ? ( ) : ( { const start = get( brushArea, 'x.0', DEFAULT_DATE_PICKER_RANGE.start ); const end = get( brushArea, 'x.1', DEFAULT_DATE_PICKER_RANGE.end ); handleZoomRangeChange(start, end); if (props.onDatePickerRangeChange) { props.onDatePickerRangeChange(start, end); } }} theme={ANOMALY_CHART_THEME} xDomain={ showAggregateResults ? undefined : { min: zoomRange.startDate, max: zoomRange.endDate, } } /> {(props.isHCDetector && !props.selectedHeatmapCell) || props.isHistorical ? null : ( )} {alertAnnotations ? ( } /> ) : null} {showAggregateResults ? ( ) : ( )} { // If historical or multiple selected time series: don't show the confidence line chart } {zoomedAnomalies.map( (anomalySeries: AnomalyData[], index) => { if (props.isHistorical || multipleTimeSeries) { return null; } else { const seriesKey = props.isHCDetector ? `${ props.confidenceSeriesName // Extracting entity list from anomaly data } (${convertToEntityString( get(anomalySeries, '0.entity', []), ', ' )}` : props.confidenceSeriesName; return ( ); } } )} {zoomedAnomalies.map( (anomalySeries: AnomalyData[], index) => { const seriesKey = props.isHCDetector ? `${ props.anomalyGradeSeriesName // Extracting entity list from anomaly data } (${convertToEntityString( get(anomalySeries, '0.entity', []), ', ' )})` : props.anomalyGradeSeriesName; return ( ); } )} )}
{showAlertsFlyout ? ( setShowAlertsFlyout(false)} resultIndex={get(props.detector, 'resultIndex')} /> ) : null}
); } );