Skip to content

Commit

Permalink
[ML] Anomaly Detection: Adds actions menu to anomaly markers in Singl…
Browse files Browse the repository at this point in the history
…e Metric Viewer chart. (elastic#175556)

## Summary

- Adds the actions menu that's available in the anomaly table to anomaly
markers in Single Metric Viewer chart.
- Adds functional tests for the existing functionality to test the
action link from the anomaly table to Discover as well as the new
popover on anomaly markers in the Single Metric Viewer chart.


[ml-ad-smv-click-0002.webm](https://github.com/elastic/kibana/assets/230104/ff8e6c64-65a2-4557-8ed7-ba8e029642a5)

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
  • Loading branch information
walterra authored and fkanout committed Mar 4, 2024
1 parent 050763a commit 78039c4
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,8 @@ export class AnomaliesTableInternal extends Component {
};

unsetShowRuleEditorFlyoutFunction = () => {
const showRuleEditorFlyout = () => {};
this.setState({
showRuleEditorFlyout,
showRuleEditorFlyout: () => {},
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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 };

Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -766,15 +782,25 @@ 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.
dots
.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());

Expand All @@ -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) {
Expand All @@ -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);
})
Expand All @@ -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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) =>
Expand All @@ -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) =>
Expand Down Expand Up @@ -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 <div className="ml-timeseries-chart-react" ref={this.setRef.bind(this)} />;
return (
<>
<RuleEditorFlyout
setShowFunction={this.setShowRuleEditorFlyoutFunction}
unsetShowFunction={this.unsetShowRuleEditorFlyoutFunction}
/>
{this.state.popoverData !== null && (
<div
style={{
position: 'absolute',
marginLeft: this.state.popoverCoords[0],
marginTop: this.state.popoverCoords[1],
}}
>
<EuiPopover
isOpen={true}
closePopover={() => this.closePopover()}
panelPaddingSize="none"
anchorPosition="upLeft"
>
<LinksMenuUI
anomaly={this.state.popoverData}
bounds={this.props.bounds}
showMapsLink={false}
showViewSeriesLink={false}
isAggregatedData={this.props.tableData.interval !== 'second'}
interval={this.props.tableData.interval}
showRuleEditorFlyout={this.state.showRuleEditorFlyout}
onItemClick={() => this.closePopover()}
sourceIndicesWithGeoFields={this.props.sourceIndicesWithGeoFields}
/>
</EuiPopover>
</div>
)}
<div className="ml-timeseries-chart-react" ref={this.setRef.bind(this)} />
</>
);
}
}

Expand All @@ -1874,6 +1992,7 @@ export const TimeseriesChart = (props) => {
if (annotationProp === undefined) {
return null;
}

return (
<TimeseriesChartIntl
annotation={annotationProp}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ function getTimeseriesChartPropsMock() {
showModelBounds: true,
svgWidth: 1600,
timefilter: {},
tooltipService: {},
sourceIndicesWithGeoFields: {},
};
}

Expand All @@ -54,6 +56,6 @@ describe('TimeseriesChart', () => {

const wrapper = mountWithIntl(<TimeseriesChart {...props} />);

expect(wrapper.html()).toBe(`<div class="ml-timeseries-chart-react"></div>`);
expect(wrapper.html()).toBe('<div class="ml-timeseries-chart-react"></div>');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -30,6 +32,11 @@ interface TimeSeriesChartWithTooltipsProps {
chartProps: any;
lastRefresh: number;
contextAggregationInterval: any;
tableData?: {
anomalies: MlAnomaliesTableRecord[];
interval: string;
};
sourceIndicesWithGeoFields: SourceIndicesWithGeoFields;
}
export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> = ({
bounds,
Expand All @@ -43,6 +50,11 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
chartProps,
lastRefresh,
contextAggregationInterval,
tableData = {
anomalies: [],
interval: 'second',
},
sourceIndicesWithGeoFields,
}) => {
const { toasts: toastNotifications } = useNotifications();
const {
Expand Down Expand Up @@ -132,6 +144,8 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
showForecast={showForecast}
showModelBounds={showModelBounds}
tooltipService={tooltipService}
tableData={tableData}
sourceIndicesWithGeoFields={sourceIndicesWithGeoFields}
/>
)}
</MlTooltipComponent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,8 @@ export class TimeSeriesExplorer extends React.Component {
showForecast={showForecast}
showModelBounds={showModelBounds}
lastRefresh={lastRefresh}
tableData={tableData}
sourceIndicesWithGeoFields={sourceIndicesWithGeoFields}
/>
{focusAnnotationError !== undefined && (
<>
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 78039c4

Please sign in to comment.