/* * 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 React, { useState, useEffect } from 'react'; import moment from 'moment'; import Plotly, { PlotData } from 'plotly.js-dist'; import plotComponentFactory from 'react-plotly.js/factory'; import { get, isEmpty, uniq } from 'lodash'; import { EuiFlexItem, EuiFlexGroup, EuiLoadingChart, EuiText, EuiComboBox, EuiSuperSelect, EuiIconTip, EuiCallOut, } from '@elastic/eui'; import { euiPaletteWarm } from '@elastic/eui/lib/services'; import { useDelayedLoader } from '../../../hooks/useDelayedLoader'; import { Monitor, DateRange } from '../../../models/interfaces'; import { AlertsFlyout } from '../components/AlertsFlyout/AlertsFlyout'; import { getSampleAnomaliesHeatmapData, getSelectedHeatmapCellPlotData, updateHeatmapPlotData, HEATMAP_X_AXIS_DATE_FORMAT, AnomalyHeatmapSortType, sortHeatmapPlotData, filterHeatmapPlotDataByY, getEntityAnomaliesHeatmapData, getCategoryFieldOptions, } from '../utils/anomalyChartUtils'; import { MIN_IN_MILLI_SECS, ENTITY_LIST_DELIMITER, } from '../../../../server/utils/constants'; import { EntityAnomalySummaries, Entity, } from '../../../../server/models/interfaces'; import { HEATMAP_CHART_Y_AXIS_WIDTH } from '../utils/constants'; import { convertHeatmapCellEntityStringToEntityList, convertToCategoryFieldString, } from '../../utils/anomalyResultUtils'; interface AnomalyHeatmapChartProps { detectorId: string; detectorName: string; detectorTaskProgress?: number; isHistorical?: boolean; anomalies?: any[]; dateRange: DateRange; isLoading: boolean; monitor?: Monitor; detectorInterval?: number; unit?: string; onHeatmapCellSelected(cell: HeatmapCell | undefined): void; onDisplayOptionChanged?(option: HeatmapDisplayOption | undefined): void; heatmapDisplayOption?: HeatmapDisplayOption; entityAnomalySummaries?: EntityAnomalySummaries[]; isNotSample?: boolean; categoryField?: string[]; selectedCategoryFields?: any[]; handleCategoryFieldsChange(selectedOptions: any[]): void; resultIndex?: string; } export interface HeatmapCell { dateRange: DateRange; entityList: Entity[]; modelId?: string; } export interface HeatmapDisplayOption { sortType: AnomalyHeatmapSortType; entityOption: { label: string; value: number }; } const COMBINED_OPTIONS = { label: 'Combined options', options: [ { label: 'Top 10', value: 10 }, { label: 'Top 20', value: 20 }, { label: 'Top 30', value: 30 }, ], }; export const INITIAL_HEATMAP_DISPLAY_OPTION = { sortType: AnomalyHeatmapSortType.SEVERITY, entityOption: COMBINED_OPTIONS.options[0], } as HeatmapDisplayOption; export const AnomalyHeatmapChart = React.memo( (props: AnomalyHeatmapChartProps) => { const showLoader = useDelayedLoader(props.isLoading); const HEATMAP_ID = 'HEATMAP_DIV_ID'; const CELL_HEIGHT = 40; const SORT_BY_FIELD_OPTIONS = [ { value: AnomalyHeatmapSortType.SEVERITY, inputDisplay: 'By severity', }, { value: AnomalyHeatmapSortType.OCCURRENCE, inputDisplay: 'By occurrence', }, ]; const PlotComponent = plotComponentFactory(Plotly); const getViewEntityOptions = (inputHeatmapData: PlotData[]) => { let individualEntities = []; if (!isEmpty(inputHeatmapData)) { //@ts-ignore individualEntities = inputHeatmapData[0].y.filter( //@ts-ignore (entityListAsString) => entityListAsString && entityListAsString.trim().length > 0 ); } const individualEntityOptions = [] as any[]; //@ts-ignore individualEntities.forEach((entityListAsString: string) => { individualEntityOptions.push({ label: entityListAsString.replace(ENTITY_LIST_DELIMITER, ', '), }); }); return [ getViewableCombinedOptions( COMBINED_OPTIONS, props.heatmapDisplayOption?.entityOption ), { label: 'Individual entities', options: individualEntityOptions.reverse(), }, ]; }; const getViewableCombinedOptions = ( existingOptions: any, selectedCombinedOption: any | undefined ) => { if (!selectedCombinedOption) { return existingOptions; } return { label: existingOptions.label, options: uniq([selectedCombinedOption, ...existingOptions.options]), }; }; // We store and update heatmap data in 2 ways, depending on if it's in a sample scenario or not. // If sample data, we get heatmap data from the raw sample anomalies themselves. // If non-sample data, we get heatmap data from the anomaly summaries via props. // The summaries come from aggregate query results directly against the result data; this isn't possible // in the sample data scenario, where we can only retrieve sampled raw data and no aggregate buckets/summaries, // hence needing to handle that in post-processing via getSampleAnomaliesHeatmapData const [originalHeatmapData, setOriginalHeatmapData] = useState( props.isNotSample ? // use anomaly summary data in case of realtime or historical result getEntityAnomaliesHeatmapData( props.dateRange, get(props, 'entityAnomalySummaries', []), get(props, 'heatmapDisplayOption.entityOption.value', 0) ) : // use raw anomalies data in case of sample result getSampleAnomaliesHeatmapData( props.anomalies, props.dateRange, AnomalyHeatmapSortType.SEVERITY, COMBINED_OPTIONS.options[0].value ) ); const [heatmapData, setHeatmapData] = useState(originalHeatmapData); const [sortByFieldValue, setSortByFieldValue] = useState( props.isNotSample ? get( props, 'heatmapDisplayOption.sortType', SORT_BY_FIELD_OPTIONS[0].value ) : SORT_BY_FIELD_OPTIONS[0].value ); const [currentViewOptions, setCurrentViewOptions] = useState([ getViewEntityOptions(originalHeatmapData)[0].options[0], ]); const [entityViewOptions, setEntityViewOptions] = useState( getViewEntityOptions(originalHeatmapData) ); const [showAlertsFlyout, setShowAlertsFlyout] = useState(false); const [numEntities, setNumEntities] = useState( originalHeatmapData[0].y.length ); const hasAnomalyInHeatmap = (): boolean => { for (let entityAnomaliesOccurence of heatmapData[0].text) { //@ts-ignore const sum = entityAnomaliesOccurence.reduce((a, b) => { return a + b; }); if (sum > 0) { return true; } } return false; }; // Custom hook to refresh all of the heatmap data when running a historical task // // TODO: this hook is actually getting updated more often than just when there // is a change to the props.detectorTaskProgress. This is due to a remounting // issue that is triggering this after every AnomaliesChart re-render. More details here: // https://github.com/opensearch-project/anomaly-detection-dashboards-plugin/issues/90 useEffect(() => { if (props.isHistorical) { const updateHeatmapPlotData = props.isNotSample ? getEntityAnomaliesHeatmapData( props.dateRange, get(props, 'entityAnomalySummaries', []), get(props, 'heatmapDisplayOption.entityOption.value', 0) ) : getSampleAnomaliesHeatmapData( props.anomalies, props.dateRange, sortByFieldValue, get( currentViewOptions[0], 'value', get(COMBINED_OPTIONS.options[0], 'value') ) ); setOriginalHeatmapData(updateHeatmapPlotData); setHeatmapData(updateHeatmapPlotData); setNumEntities(updateHeatmapPlotData[0].y.length); setEntityViewOptions(getViewEntityOptions(updateHeatmapPlotData)); } }, [props.detectorTaskProgress]); const handleHeatmapClick = (event: Plotly.PlotMouseEvent) => { const selectedCellIndices = get(event, 'points[0].pointIndex', []); const selectedEntityString = get(event, 'points[0].customdata', ''); if (!isEmpty(selectedCellIndices)) { let anomalyCount = get(event, 'points[0].text', 0); if ( anomalyCount === 0 || (heatmapData.length > 1 && //@ts-ignore heatmapData[1].z[selectedCellIndices[0]][selectedCellIndices[1]] != null) ) { // in the case of re-clicking on selected cell const noCellSelectedHeatmapData = updateHeatmapPlotData( heatmapData[0], { opacity: 1, } ); setHeatmapData([noCellSelectedHeatmapData]); props.onHeatmapCellSelected(undefined); } else { const transparentHeatmapData = updateHeatmapPlotData(heatmapData[0], { opacity: 0.3, }); const selectedHeatmapData = getSelectedHeatmapCellPlotData( heatmapData[0], selectedCellIndices[1], selectedCellIndices[0] ); setHeatmapData([transparentHeatmapData, ...selectedHeatmapData]); const selectedStartDate = moment( //@ts-ignore heatmapData[0].x[selectedCellIndices[1]], HEATMAP_X_AXIS_DATE_FORMAT ).valueOf(); const selectedEndDate = selectedStartDate + get(selectedHeatmapData, '[0].cellTimeInterval', MIN_IN_MILLI_SECS); props.onHeatmapCellSelected({ dateRange: { startDate: selectedStartDate, endDate: selectedEndDate, }, entityList: convertHeatmapCellEntityStringToEntityList(selectedEntityString), } as HeatmapCell); } } }; const getColorPaletteFlexItem = (hexCode: string) => { return ( ); }; const handleViewEntityOptionsChange = (selectedViewOptions: any[]) => { props.onHeatmapCellSelected(undefined); if (isEmpty(selectedViewOptions)) { // when `clear` is hit for combo box setCurrentViewOptions([COMBINED_OPTIONS.options[0]]); if (props.isNotSample && props.onDisplayOptionChanged) { props.onDisplayOptionChanged({ sortType: sortByFieldValue, entityOption: COMBINED_OPTIONS.options[0], }); } else { const displayTopEntityNum = get(COMBINED_OPTIONS.options[0], 'value'); const updateHeatmapPlotData = props.isNotSample ? getEntityAnomaliesHeatmapData( props.dateRange, get(props, 'entityAnomalySummaries', []), displayTopEntityNum ) : getSampleAnomaliesHeatmapData( props.anomalies, props.dateRange, sortByFieldValue, displayTopEntityNum ); setOriginalHeatmapData(updateHeatmapPlotData); setHeatmapData(updateHeatmapPlotData); setNumEntities(updateHeatmapPlotData[0].y.length); setEntityViewOptions(getViewEntityOptions(updateHeatmapPlotData)); } return; } const nonCombinedOptions = [] as any[]; for (let option of selectedViewOptions) { if (currentViewOptions.includes(option)) { if (!isCombinedViewEntityOption(option)) { nonCombinedOptions.push(option); } continue; } if (isCombinedViewEntityOption(option)) { // only allow 1 combined option setCurrentViewOptions([option]); if (props.isNotSample && props.onDisplayOptionChanged) { props.onDisplayOptionChanged({ sortType: sortByFieldValue, entityOption: option, }); } else { const displayTopEntityNum = get(option, 'value'); const updateHeatmapPlotData = props.isNotSample ? getEntityAnomaliesHeatmapData( props.dateRange, get(props, 'entityAnomalySummaries', []), displayTopEntityNum ) : getSampleAnomaliesHeatmapData( props.anomalies, props.dateRange, sortByFieldValue, displayTopEntityNum ); setOriginalHeatmapData(updateHeatmapPlotData); setHeatmapData(updateHeatmapPlotData); setNumEntities(updateHeatmapPlotData[0].y.length); setEntityViewOptions(getViewEntityOptions(updateHeatmapPlotData)); } return; } else { nonCombinedOptions.push(option); } } // work on individual entities selected setCurrentViewOptions(nonCombinedOptions); setNumEntities(nonCombinedOptions.length); const selectedYs = nonCombinedOptions.map((option) => get(option, 'label', '').replace(', ', ENTITY_LIST_DELIMITER) ); let selectedHeatmapData = filterHeatmapPlotDataByY( originalHeatmapData[0], selectedYs, sortByFieldValue ); selectedHeatmapData.opacity = 1; setHeatmapData([selectedHeatmapData]); }; const isCombinedViewEntityOption = (inputOption: any) => { const combinedOptionsLabels = COMBINED_OPTIONS.options.map((option) => get(option, 'label', '') ); return combinedOptionsLabels.includes(get(inputOption, 'label', '')); }; const handleSortByFieldChange = (value: any) => { setSortByFieldValue(value); props.onHeatmapCellSelected(undefined); if ( props.isNotSample && props.onDisplayOptionChanged && currentViewOptions.length === 1 && isCombinedViewEntityOption(currentViewOptions[0]) ) { props.onDisplayOptionChanged({ sortType: value, entityOption: currentViewOptions[0], }); return; } const sortedHeatmapData = sortHeatmapPlotData( heatmapData[0], value, numEntities ); const updatedHeatmapData = updateHeatmapPlotData(sortedHeatmapData, { opacity: 1, }); setHeatmapData([updatedHeatmapData]); }; return (

View by:

{/** * We don't support multi-category filtering on sample data currently. * Because of this, we remove the category field combo box for the sample charts, and * simply show the list of selected category fields in string format. */} {props.isNotSample ? ( props.handleCategoryFieldsChange(selectedOptions) } /> ) : (

{convertToCategoryFieldString( get(props, 'categoryField', [] as string[]), ',' )}

)} handleViewEntityOptionsChange(selectedOptions) } /> handleSortByFieldChange(value)} hasDividers />
Anomaly grade{' '} {euiPaletteWarm(5).map((hexCode) => { return getColorPaletteFlexItem(hexCode); })} 0.0 (None) (Critical) 1.0
{props.isLoading ? ( ) : ( label.length <= HEATMAP_CHART_Y_AXIS_WIDTH ? label : label.substring(0, HEATMAP_CHART_Y_AXIS_WIDTH - 3) + '...' ), }, margin: { l: 100, r: 0, t: 0, b: 50, pad: 2, }, }} config={{ responsive: true, displayModeBar: false, scrollZoom: false, displaylogo: false, }} onClick={handleHeatmapClick} divId={HEATMAP_ID} /> )}
{showAlertsFlyout ? ( setShowAlertsFlyout(false)} resultIndex={props.resultIndex} /> ) : null}
); } );