diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js index 9b023ae640c10..0edb008184aae 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js @@ -144,9 +144,8 @@ export class AnomaliesTableInternal extends Component { }; unsetShowRuleEditorFlyoutFunction = () => { - const showRuleEditorFlyout = () => {}; this.setState({ - showRuleEditorFlyout, + showRuleEditorFlyout: () => {}, }); }; diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/rule_editor_flyout.js b/x-pack/plugins/ml/public/application/components/rule_editor/rule_editor_flyout.js index 0e21de91dbbb9..abb7055b41a8f 100644 --- a/x-pack/plugins/ml/public/application/components/rule_editor/rule_editor_flyout.js +++ b/x-pack/plugins/ml/public/application/components/rule_editor/rule_editor_flyout.js @@ -83,11 +83,13 @@ class RuleEditorFlyoutUI extends Component { } componentDidMount() { - this.toastNotificationService = toastNotificationServiceProvider( - this.props.kibana.services.notifications.toasts - ); - if (typeof this.props.setShowFunction === 'function') { - this.props.setShowFunction(this.showFlyout); + if (this.props.kibana.services.notifications) { + this.toastNotificationService = toastNotificationServiceProvider( + this.props.kibana.services.notifications.toasts + ); + if (typeof this.props.setShowFunction === 'function') { + this.props.setShowFunction(this.showFlyout); + } } } @@ -480,7 +482,7 @@ class RuleEditorFlyoutUI extends Component { }; render() { - const docsUrl = this.props.kibana.services.docLinks.links.ml.customRules; + const docsUrl = this.props.kibana.services.docLinks?.links.ml.customRules; const { isFlyoutVisible, job, diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index a66a5f0efece2..b459f0bffcee0 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -17,6 +17,8 @@ import { isEqual, reduce, each, get } from 'lodash'; import d3 from 'd3'; import moment from 'moment'; +import { EuiPopover } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { getFormattedSeverityScore, getSeverityWithLow } from '@kbn/ml-anomaly-utils'; import { formatHumanReadableDateTimeSeconds } from '@kbn/ml-date-utils'; @@ -52,6 +54,9 @@ import { } from './timeseries_chart_annotations'; import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context'; +import { LinksMenuUI } from '../../../components/anomalies_table/links_menu'; +import { RuleEditorFlyout } from '../../../components/rule_editor'; + const focusZoomPanelHeight = 25; const focusChartHeight = 310; const focusHeight = focusZoomPanelHeight + focusChartHeight; @@ -60,6 +65,7 @@ const contextChartLineTopMargin = 3; const chartSpacing = 25; const swimlaneHeight = 30; const ctxAnnotationMargin = 2; +const popoverMenuOffset = 28; const annotationHeight = ANNOTATION_SYMBOL_HEIGHT + ctxAnnotationMargin * 2; const margin = { top: 10, right: 10, bottom: 15, left: 40 }; @@ -123,11 +129,18 @@ class TimeseriesChartIntl extends Component { zoomFromFocusLoaded: PropTypes.object, zoomToFocusLoaded: PropTypes.object, tooltipService: PropTypes.object.isRequired, + tableData: PropTypes.object, + sourceIndicesWithGeoFields: PropTypes.object.isRequired, }; rowMouseenterSubscriber = null; rowMouseleaveSubscriber = null; + constructor(props) { + super(props); + this.state = { popoverData: null, popoverCoords: [0, 0], showRuleEditorFlyout: () => {} }; + } + componentWillUnmount() { const element = d3.select(this.rootNode); element.html(''); @@ -206,7 +219,10 @@ class TimeseriesChartIntl extends Component { const highlightFocusChartAnomaly = this.highlightFocusChartAnomaly.bind(this); const boundHighlightFocusChartAnnotation = highlightFocusChartAnnotation.bind(this); function tableRecordMousenterListener({ record, type = 'anomaly' }) { - if (type === 'anomaly') { + // do not display tooltips if the action popover is active + if (this.state.popoverData !== null) { + return; + } else if (type === 'anomaly') { highlightFocusChartAnomaly(record); } else if (type === 'annotation') { boundHighlightFocusChartAnnotation(record); @@ -217,7 +233,7 @@ class TimeseriesChartIntl extends Component { const boundUnhighlightFocusChartAnnotation = unhighlightFocusChartAnnotation.bind(this); function tableRecordMouseleaveListener({ record, type = 'anomaly' }) { if (type === 'anomaly') { - unhighlightFocusChartAnomaly(record); + unhighlightFocusChartAnomaly(); } else { boundUnhighlightFocusChartAnnotation(record); } @@ -594,8 +610,8 @@ class TimeseriesChartIntl extends Component { const data = focusChartData; const contextYScale = this.contextYScale; + const showAnomalyPopover = this.showAnomalyPopover.bind(this); const showFocusChartTooltip = this.showFocusChartTooltip.bind(this); - const hideFocusChartTooltip = this.props.tooltipService.hide.bind(this.props.tooltipService); const focusChart = d3.select('.focus-chart'); @@ -766,6 +782,8 @@ class TimeseriesChartIntl extends Component { ) ); + const that = this; + // Remove dots that are no longer needed i.e. if number of chart points has decreased. dots.exit().remove(); // Create any new dots that are needed i.e. if number of chart points has increased. @@ -773,8 +791,16 @@ class TimeseriesChartIntl extends Component { .enter() .append('circle') .attr('r', LINE_CHART_ANOMALY_RADIUS) + .on('click', function (d) { + d3.event.preventDefault(); + if (d.anomalyScore === undefined) return; + showAnomalyPopover(d, this); + }) .on('mouseover', function (d) { - showFocusChartTooltip(d, this); + // Show the tooltip only if the actions menu isn't active + if (that.state.popoverData === null) { + showFocusChartTooltip(d, this); + } }) .on('mouseout', () => this.props.tooltipService.hide()); @@ -786,6 +812,7 @@ class TimeseriesChartIntl extends Component { .attr('cy', (d) => { return this.focusYScale(d.value); }) + .attr('data-test-subj', (d) => (d.anomalyScore !== undefined ? 'mlAnomalyMarker' : undefined)) .attr('class', (d) => { let markerClass = 'metric-value'; if (d.anomalyScore !== undefined) { @@ -810,6 +837,11 @@ class TimeseriesChartIntl extends Component { .enter() .append('path') .attr('d', d3.svg.symbol().size(MULTI_BUCKET_SYMBOL_SIZE).type('cross')) + .on('click', function (d) { + d3.event.preventDefault(); + if (d.anomalyScore === undefined) return; + showAnomalyPopover(d, this); + }) .on('mouseover', function (d) { showFocusChartTooltip(d, this); }) @@ -821,6 +853,7 @@ class TimeseriesChartIntl extends Component { 'transform', (d) => `translate(${this.focusXScale(d.date)}, ${this.focusYScale(d.value)})` ) + .attr('data-test-subj', 'mlAnomalyMarker') .attr('class', (d) => `anomaly-marker multi-bucket ${getSeverityWithLow(d.anomalyScore).id}`); // Add rectangular markers for any scheduled events. @@ -1479,6 +1512,37 @@ class TimeseriesChartIntl extends Component { this.setContextBrushExtent(new Date(from), new Date(to)); } + showAnomalyPopover(marker, circle) { + const anomalyTime = marker.date.getTime(); + + // The table items could be aggregated, so we have to find the item + // that has the closest timestamp to the selected anomaly from the chart. + const tableItem = this.props.tableData.anomalies.reduce((closestItem, currentItem) => { + const closestItemDelta = Math.abs(anomalyTime - closestItem.source.timestamp); + const currentItemDelta = Math.abs(anomalyTime - currentItem.source.timestamp); + return currentItemDelta < closestItemDelta ? currentItem : closestItem; + }, this.props.tableData.anomalies[0]); + + if (tableItem) { + // Overwrite the timestamp of the possibly aggregated table item with the + // timestamp of the anomaly clicked in the chart so we're able to pick + // the right baseline and deviation time ranges for Log Rate Analysis. + tableItem.source.timestamp = anomalyTime; + + // Calculate the relative coordinates of the clicked anomaly marker + // so we're able to position the popover actions menu above it. + const dotRect = circle.getBoundingClientRect(); + const rootRect = this.rootNode.getBoundingClientRect(); + const x = Math.round(dotRect.x + dotRect.width / 2 - rootRect.x); + const y = Math.round(dotRect.y + dotRect.height / 2 - rootRect.y) - popoverMenuOffset; + + // Hide any active tooltip + this.props.tooltipService.hide(); + // Set the popover state to enable the actions menu + this.setState({ popoverData: tableItem, popoverCoords: [x, y] }); + } + } + showFocusChartTooltip(marker, circle) { const { modelPlotEnabled } = this.props; @@ -1818,6 +1882,7 @@ class TimeseriesChartIntl extends Component { .append('path') .attr('d', d3.svg.symbol().size(MULTI_BUCKET_SYMBOL_SIZE).type('cross')) .attr('transform', (d) => `translate(${focusXScale(d.date)}, ${focusYScale(d.value)})`) + .attr('data-test-subj', 'mlAnomalyMarker') .attr( 'class', (d) => @@ -1830,6 +1895,7 @@ class TimeseriesChartIntl extends Component { .attr('r', LINE_CHART_ANOMALY_RADIUS) .attr('cx', (d) => focusXScale(d.date)) .attr('cy', (d) => focusYScale(d.value)) + .attr('data-test-subj', 'mlAnomalyMarker') .attr( 'class', (d) => @@ -1862,8 +1928,60 @@ class TimeseriesChartIntl extends Component { this.rootNode = componentNode; } + closePopover() { + this.setState({ popoverData: null, popoverCoords: [0, 0] }); + } + + setShowRuleEditorFlyoutFunction = (func) => { + this.setState({ + showRuleEditorFlyout: func, + }); + }; + + unsetShowRuleEditorFlyoutFunction = () => { + this.setState({ + showRuleEditorFlyout: () => {}, + }); + }; + render() { - return
; + return ( + <> + + {this.state.popoverData !== null && ( +
+ this.closePopover()} + panelPaddingSize="none" + anchorPosition="upLeft" + > + this.closePopover()} + sourceIndicesWithGeoFields={this.props.sourceIndicesWithGeoFields} + /> + +
+ )} +
+ + ); } } @@ -1874,6 +1992,7 @@ export const TimeseriesChart = (props) => { if (annotationProp === undefined) { return null; } + return ( { const wrapper = mountWithIntl(); - expect(wrapper.html()).toBe(`
`); + expect(wrapper.html()).toBe('
'); }); }); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx index af42229d8ac79..66da1e4222887 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx @@ -8,6 +8,7 @@ import React, { FC, useEffect, useState, useCallback, useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { extractErrorMessage } from '@kbn/ml-error-utils'; +import type { MlAnomaliesTableRecord } from '@kbn/ml-anomaly-utils'; import { MlTooltipComponent } from '../../../components/chart_tooltip'; import { TimeseriesChart } from './timeseries_chart'; import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs'; @@ -17,6 +18,7 @@ import { useMlKibana, useNotifications } from '../../../contexts/kibana'; import { getBoundsRoundedToInterval } from '../../../util/time_buckets'; import { getControlsForDetector } from '../../get_controls_for_detector'; import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context'; +import { SourceIndicesWithGeoFields } from '../../../explorer/explorer_utils'; interface TimeSeriesChartWithTooltipsProps { bounds: any; @@ -30,6 +32,11 @@ interface TimeSeriesChartWithTooltipsProps { chartProps: any; lastRefresh: number; contextAggregationInterval: any; + tableData?: { + anomalies: MlAnomaliesTableRecord[]; + interval: string; + }; + sourceIndicesWithGeoFields: SourceIndicesWithGeoFields; } export const TimeSeriesChartWithTooltips: FC = ({ bounds, @@ -43,6 +50,11 @@ export const TimeSeriesChartWithTooltips: FC = chartProps, lastRefresh, contextAggregationInterval, + tableData = { + anomalies: [], + interval: 'second', + }, + sourceIndicesWithGeoFields, }) => { const { toasts: toastNotifications } = useNotifications(); const { @@ -132,6 +144,8 @@ export const TimeSeriesChartWithTooltips: FC = showForecast={showForecast} showModelBounds={showModelBounds} tooltipService={tooltipService} + tableData={tableData} + sourceIndicesWithGeoFields={sourceIndicesWithGeoFields} /> )} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index b7b8b7fe6e77b..757f4cb06543e 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1218,6 +1218,8 @@ export class TimeSeriesExplorer extends React.Component { showForecast={showForecast} showModelBounds={showModelBounds} lastRefresh={lastRefresh} + tableData={tableData} + sourceIndicesWithGeoFields={sourceIndicesWithGeoFields} /> {focusAnnotationError !== undefined && ( <> @@ -1316,7 +1318,7 @@ export class TimeSeriesExplorer extends React.Component { bounds={bounds} tableData={tableData} filter={this.tableFilter} - sourceIndicesWithGeoFields={sourceIndicesWithGeoFields} + sourceIndicesWithGeoFields={this.state.sourceIndicesWithGeoFields} selectedJobs={[ { id: selectedJob.job_id, diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/anomaly_explorer.ts index a6e867676d10f..76bed212eebde 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/anomaly_explorer.ts @@ -543,6 +543,39 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); }); + + describe('Use anomaly table action to view in Discover', function () { + beforeEach(async () => { + await ml.navigation.navigateToAnomalyExplorer( + testData.jobConfig.job_id, + { + from: '2016-02-07T00%3A00%3A00.000Z', + to: '2016-02-11T23%3A59%3A54.000Z', + }, + () => elasticChart.setNewChartUiDebugFlag(true) + ); + + await ml.commonUI.waitForMlLoadingIndicatorToDisappear(); + await ml.commonUI.waitForDatePickerIndicatorLoaded(); + await ml.swimLane.waitForSwimLanesToLoad(); + }); + + it('should render the anomaly table', async () => { + await ml.testExecution.logTestStep('displays the anomalies table'); + await ml.anomaliesTable.assertTableExists(); + + await ml.testExecution.logTestStep('anomalies table is not empty'); + await ml.anomaliesTable.assertTableNotEmpty(); + }); + + it('should click the Discover action in the anomaly table', async () => { + await ml.anomaliesTable.assertAnomalyActionsMenuButtonExists(0); + await ml.anomaliesTable.scrollRowIntoView(0); + await ml.anomaliesTable.assertAnomalyActionsMenuButtonEnabled(0, true); + await ml.anomaliesTable.assertAnomalyActionDiscoverButtonExists(0); + await ml.anomaliesTable.ensureAnomalyActionDiscoverButtonClicked(0); + }); + }); }); } }); diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/single_metric_viewer.ts b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/single_metric_viewer.ts index 1779589a5a0c1..c9ceb71459e4a 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_result_views/single_metric_viewer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_result_views/single_metric_viewer.ts @@ -90,6 +90,13 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('anomalies table is not empty'); await ml.anomaliesTable.assertTableNotEmpty(); }); + + it('should click on an anomaly marker', async () => { + await ml.singleMetricViewer.assertAnomalyMarkerExist(); + await ml.singleMetricViewer.openAnomalyMarkerActionsPopover(); + await ml.anomaliesTable.assertAnomalyActionDiscoverButtonExists(0); + await ml.anomaliesTable.ensureAnomalyActionDiscoverButtonClicked(0); + }); }); describe('with entity fields', function () { @@ -193,7 +200,9 @@ export default function ({ getService }: FtrProviderContext) { // Also sorting by name is enforced because the model plot is enabled // and anomalous only is disabled await ml.singleMetricViewer.assertEntityConfig('day_of_week', false, 'name', 'desc'); + }); + it('should render the singe metric viewer chart and anomaly table', async () => { await ml.testExecution.logTestStep('displays the chart'); await ml.singleMetricViewer.assertChartExist(); @@ -203,6 +212,14 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('anomalies table is not empty'); await ml.anomaliesTable.assertTableNotEmpty(); }); + + it('should click the Discover action in the anomaly table', async () => { + await ml.anomaliesTable.assertAnomalyActionsMenuButtonExists(0); + await ml.anomaliesTable.scrollRowIntoView(0); + await ml.anomaliesTable.assertAnomalyActionsMenuButtonEnabled(0, true); + await ml.anomaliesTable.assertAnomalyActionDiscoverButtonExists(0); + await ml.anomaliesTable.ensureAnomalyActionDiscoverButtonClicked(0); + }); }); }); } diff --git a/x-pack/test/functional/services/ml/anomalies_table.ts b/x-pack/test/functional/services/ml/anomalies_table.ts index 52eaf5715f673..c59221289f848 100644 --- a/x-pack/test/functional/services/ml/anomalies_table.ts +++ b/x-pack/test/functional/services/ml/anomalies_table.ts @@ -131,6 +131,24 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide ); }, + async assertAnomalyActionDiscoverButtonExists(rowIndex: number) { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.existOrFail('mlAnomaliesListRowAction_viewInDiscoverButton'); + }, + + async assertAnomalyActionDiscoverButtonNotExists(rowIndex: number) { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.missingOrFail('mlAnomaliesListRowAction_viewInDiscoverButton'); + }, + + async ensureAnomalyActionDiscoverButtonClicked(rowIndex: number) { + await retry.tryForTime(10 * 1000, async () => { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.click('mlAnomaliesListRowAction_viewInDiscoverButton'); + await testSubjects.existOrFail('discoverLayoutResizableContainer'); + }); + }, + async assertAnomalyActionLogRateAnalysisButtonExists(rowIndex: number) { await this.ensureAnomalyActionsMenuOpen(rowIndex); await testSubjects.existOrFail('mlAnomaliesListRowAction_runLogRateAnalysisButton'); diff --git a/x-pack/test/functional/services/ml/single_metric_viewer.ts b/x-pack/test/functional/services/ml/single_metric_viewer.ts index 29f1ded74deba..05ae2bd20cab7 100644 --- a/x-pack/test/functional/services/ml/single_metric_viewer.ts +++ b/x-pack/test/functional/services/ml/single_metric_viewer.ts @@ -73,6 +73,15 @@ export function MachineLearningSingleMetricViewerProvider( await testSubjects.existOrFail('mlSingleMetricViewerChart'); }, + async assertAnomalyMarkerExist() { + await testSubjects.existOrFail('mlAnomalyMarker'); + }, + + async openAnomalyMarkerActionsPopover() { + await testSubjects.click('mlAnomalyMarker'); + await testSubjects.existOrFail('mlAnomaliesListRowActionsMenu'); + }, + async assertAnnotationsExists(state: string) { await testSubjects.existOrFail(`mlAnomalyExplorerAnnotations ${state}`, { timeout: 30 * 1000,