diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index 100b2afcc97ce..73163cb70ada9 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -98,7 +98,7 @@ class AnnotationsTableUI extends Component { if (dataCounts.processed_record_count > 0) { // Load annotations for the selected job. ml.annotations - .getAnnotations({ + .getAnnotations$({ jobIds: [job.job_id], earliestMs: null, latestMs: null, diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js index 11e196b1c8e3f..b19328f89fbe4 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.test.js @@ -24,7 +24,7 @@ jest.mock('../../../services/ml_api_service', () => { return { ml: { annotations: { - getAnnotations: jest.fn().mockReturnValue(mockAnnotations$), + getAnnotations$: jest.fn().mockReturnValue(mockAnnotations$), }, }, }; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index c3bdacde5abd8..f6889c9a6f24c 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -392,7 +392,7 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, return new Promise((resolve) => { ml.annotations - .getAnnotations({ + .getAnnotations$({ jobIds, earliestMs: timeRange.earliestMs, latestMs: timeRange.latestMs, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts index f9e19ba6f757e..d028bacb49a77 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts @@ -13,7 +13,7 @@ import { http, http$ } from '../http_service'; import { basePath } from './index'; export const annotations = { - getAnnotations(obj: { + getAnnotations$(obj: { jobIds: string[]; earliestMs: number; latestMs: number; @@ -30,6 +30,23 @@ export const annotations = { }); }, + getAnnotations(obj: { + jobIds: string[]; + earliestMs: number | null; + latestMs: number | null; + maxAnnotations: number; + fields?: FieldToBucket[]; + detectorIndex?: number; + entities?: any[]; + }) { + const body = JSON.stringify(obj); + return http({ + path: `${basePath()}/annotations`, + method: 'POST', + body, + }); + }, + indexAnnotation(obj: Annotation) { const body = JSON.stringify(obj); return http({ diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss index 4399327c55dca..0c38d8e7ca171 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer_annotations.scss @@ -76,3 +76,25 @@ $mlAnnotationRectDefaultFillOpacity: 0.05; .mlAnnotationHidden { display: none; } + +// context annotation marker +.mlContextAnnotationRect { + stroke: $euiColorFullShade; + stroke-width: $mlAnnotationBorderWidth; + stroke-opacity: $mlAnnotationRectDefaultStrokeOpacity; + transition: stroke-opacity $euiAnimSpeedFast; + + fill: $euiColorFullShade; + fill-opacity: $mlAnnotationRectDefaultFillOpacity; + transition: fill-opacity $euiAnimSpeedFast; + + shape-rendering: geometricPrecision; +} + +.mlContextAnnotationRect-isBlur { + stroke-opacity: $mlAnnotationRectDefaultStrokeOpacity / 2; + transition: stroke-opacity $euiAnimSpeedFast; + + fill-opacity: $mlAnnotationRectDefaultFillOpacity / 2; + transition: fill-opacity $euiAnimSpeedFast; +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts index 9a06f6d6b8e03..04b666b4fc684 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.d.ts @@ -6,6 +6,7 @@ import d3 from 'd3'; +import React from 'react'; import { Annotation } from '../../../../../common/types/annotations'; import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs'; import { ChartTooltipService } from '../../../components/chart_tooltip'; @@ -19,6 +20,33 @@ interface State { annotation: Annotation | null; } -export interface TimeseriesChart extends React.Component { +interface TimeseriesChartProps { + annotation: object; + autoZoomDuration: number; + bounds: object; + contextAggregationInterval: object; + contextChartData: any[]; + contextForecastData: any[]; + contextChartSelected: any; + detectorIndex: number; + focusAggregationInterval: object; + focusAnnotationData: Annotation[]; + focusChartData: any[]; + focusForecastData: any[]; + modelPlotEnabled: boolean; + renderFocusChartOnly: boolean; + selectedJob: CombinedJob; + showForecast: boolean; + showModelBounds: boolean; + svgWidth: number; + swimlaneData: any[]; + zoomFrom: object; + zoomTo: object; + zoomFromFocusLoaded: object; + zoomToFocusLoaded: object; + tooltipService: object; +} + +declare class TimeseriesChart extends React.Component { focusXScale: d3.scale.Ordinal<{}, number>; } 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 448d39db3e444..3169ecfd1bbc7 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 @@ -25,6 +25,7 @@ import { annotation$ } from '../../../services/annotations_service'; import { formatValue } from '../../../formatters/format_value'; import { LINE_CHART_ANOMALY_RADIUS, + ANNOTATION_SYMBOL_HEIGHT, MULTI_BUCKET_SYMBOL_SIZE, SCHEDULED_EVENT_SYMBOL_HEIGHT, drawLineChartDots, @@ -48,6 +49,7 @@ import { renderAnnotations, highlightFocusChartAnnotation, unhighlightFocusChartAnnotation, + ANNOTATION_MIN_WIDTH, } from './timeseries_chart_annotations'; const focusZoomPanelHeight = 25; @@ -57,6 +59,8 @@ const contextChartHeight = 60; const contextChartLineTopMargin = 3; const chartSpacing = 25; const swimlaneHeight = 30; +const ctxAnnotationMargin = 2; +const annotationHeight = ANNOTATION_SYMBOL_HEIGHT + ctxAnnotationMargin * 2; const margin = { top: 10, right: 10, bottom: 15, left: 40 }; const ZOOM_INTERVAL_OPTIONS = [ @@ -80,9 +84,16 @@ const anomalyGrayScale = d3.scale .domain([3, 25, 50, 75, 100]) .range(['#dce7ed', '#b0c5d6', '#b1a34e', '#b17f4e', '#c88686']); -function getSvgHeight() { +function getSvgHeight(showAnnotations) { + const adjustedAnnotationHeight = showAnnotations ? annotationHeight : 0; return ( - focusHeight + contextChartHeight + swimlaneHeight + chartSpacing + margin.top + margin.bottom + focusHeight + + contextChartHeight + + swimlaneHeight + + adjustedAnnotationHeight + + chartSpacing + + margin.top + + margin.bottom ); } @@ -225,7 +236,12 @@ class TimeseriesChartIntl extends Component { } componentDidUpdate(prevProps) { - if (this.props.renderFocusChartOnly === false || prevProps.svgWidth !== this.props.svgWidth) { + if ( + this.props.renderFocusChartOnly === false || + prevProps.svgWidth !== this.props.svgWidth || + prevProps.showAnnotations !== this.props.showAnnotations || + prevProps.annotationData !== this.props.annotationData + ) { this.renderChart(); this.drawContextChartSelection(); } @@ -246,6 +262,7 @@ class TimeseriesChartIntl extends Component { modelPlotEnabled, selectedJob, svgWidth, + showAnnotations, } = this.props; const createFocusChart = this.createFocusChart.bind(this); @@ -254,7 +271,7 @@ class TimeseriesChartIntl extends Component { const focusYAxis = this.focusYAxis; const focusYScale = this.focusYScale; - const svgHeight = getSvgHeight(); + const svgHeight = getSvgHeight(showAnnotations); // Clear any existing elements from the visualization, // then build the svg elements for the bubble chart. @@ -367,7 +384,13 @@ class TimeseriesChartIntl extends Component { // Draw each of the component elements. createFocusChart(focus, this.vizWidth, focusHeight); - drawContextElements(context, this.vizWidth, contextChartHeight, swimlaneHeight); + drawContextElements( + context, + this.vizWidth, + contextChartHeight, + swimlaneHeight, + annotationHeight + ); } contextChartInitialized = false; @@ -947,10 +970,19 @@ class TimeseriesChartIntl extends Component { } drawContextElements(cxtGroup, cxtWidth, cxtChartHeight, swlHeight) { - const { bounds, contextChartData, contextForecastData, modelPlotEnabled } = this.props; - + const { + bounds, + contextChartData, + contextForecastData, + modelPlotEnabled, + annotationData, + showAnnotations, + } = this.props; const data = contextChartData; + const showFocusChartTooltip = this.showFocusChartTooltip.bind(this); + const hideFocusChartTooltip = this.props.tooltipService.hide.bind(this.props.tooltipService); + this.contextXScale = d3.time .scale() .range([0, cxtWidth]) @@ -997,20 +1029,26 @@ class TimeseriesChartIntl extends Component { .domain([chartLimits.min, chartLimits.max]); const borders = cxtGroup.append('g').attr('class', 'axis'); + const brushChartHeight = showAnnotations + ? cxtChartHeight + swlHeight + annotationHeight + : cxtChartHeight + swlHeight; // Add borders left and right. + borders.append('line').attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', brushChartHeight); borders .append('line') - .attr('x1', 0) + .attr('x1', cxtWidth) .attr('y1', 0) - .attr('x2', 0) - .attr('y2', cxtChartHeight + swlHeight); + .attr('x2', cxtWidth) + .attr('y2', brushChartHeight); + + // Add bottom borders borders .append('line') - .attr('x1', cxtWidth) - .attr('y1', 0) + .attr('x1', 0) + .attr('y1', brushChartHeight) .attr('x2', cxtWidth) - .attr('y2', cxtChartHeight + swlHeight); + .attr('y2', brushChartHeight); // Add x axis. const timeBuckets = getTimeBucketsFromCache(); @@ -1065,6 +1103,61 @@ class TimeseriesChartIntl extends Component { cxtGroup.append('path').datum(data).attr('class', 'values-line').attr('d', contextValuesLine); drawLineChartDots(data, cxtGroup, contextValuesLine, 1); + // Add annotation markers to the context area + cxtGroup.append('g').classed('mlContextAnnotations', true); + + const [contextXRangeStart, contextXRangeEnd] = this.contextXScale.range(); + const ctxAnnotations = cxtGroup + .select('.mlContextAnnotations') + .selectAll('g.mlContextAnnotation') + .data(showAnnotations && annotationData ? annotationData : [], (d) => d._id || ''); + + ctxAnnotations.enter().append('g').classed('mlContextAnnotation', true); + + const ctxAnnotationRects = ctxAnnotations + .selectAll('.mlContextAnnotationRect') + .data((d) => [d]); + + ctxAnnotationRects + .enter() + .append('rect') + .attr('rx', ctxAnnotationMargin) + .attr('ry', ctxAnnotationMargin) + .on('mouseover', function (d) { + showFocusChartTooltip(d, this); + }) + .on('mouseout', () => hideFocusChartTooltip()) + .classed('mlContextAnnotationRect', true); + + ctxAnnotationRects + .attr('x', (d) => { + const date = moment(d.timestamp); + let xPos = this.contextXScale(date); + + if (xPos - ANNOTATION_SYMBOL_HEIGHT <= contextXRangeStart) { + xPos = 0; + } + if (xPos + ANNOTATION_SYMBOL_HEIGHT >= contextXRangeEnd) { + xPos = contextXRangeEnd - ANNOTATION_SYMBOL_HEIGHT; + } + + return xPos; + }) + .attr('y', cxtChartHeight + swlHeight + 2) + .attr('height', ANNOTATION_SYMBOL_HEIGHT) + .attr('width', (d) => { + const start = this.contextXScale(moment(d.timestamp)) + 1; + const end = + typeof d.end_timestamp !== 'undefined' + ? this.contextXScale(moment(d.end_timestamp)) - 1 + : start + ANNOTATION_MIN_WIDTH; + const width = Math.max(ANNOTATION_MIN_WIDTH, end - start); + return width; + }); + + ctxAnnotations.classed('mlAnnotationHidden', !showAnnotations); + ctxAnnotationRects.exit().remove(); + // Create the path elements for the forecast value line and bounds area. if (contextForecastData !== undefined) { cxtGroup diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts index 0b541d54ee7b3..bd86d07dcd8b7 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_annotations.ts @@ -90,7 +90,7 @@ const ANNOTATION_DEFAULT_LEVEL = 1; const ANNOTATION_LEVEL_HEIGHT = 28; const ANNOTATION_UPPER_RECT_MARGIN = 0; const ANNOTATION_UPPER_TEXT_MARGIN = -7; -const ANNOTATION_MIN_WIDTH = 2; +export const ANNOTATION_MIN_WIDTH = 2; const ANNOTATION_RECT_BORDER_RADIUS = 2; const ANNOTATION_TEXT_VERTICAL_OFFSET = 26; const ANNOTATION_TEXT_RECT_VERTICAL_OFFSET = 12; 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 new file mode 100644 index 0000000000000..89e7d292dbdf2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { MlTooltipComponent } from '../../../components/chart_tooltip'; +import { TimeseriesChart } from './timeseries_chart'; +import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs'; +import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../../../common/constants/search'; +import { extractErrorMessage } from '../../../../../common/util/errors'; +import { Annotation } from '../../../../../common/types/annotations'; +import { useMlKibana, useNotifications } from '../../../contexts/kibana'; +import { getBoundsRoundedToInterval } from '../../../util/time_buckets'; +import { ANNOTATION_EVENT_USER } from '../../../../../common/constants/annotations'; +import { getControlsForDetector } from '../../get_controls_for_detector'; + +interface TimeSeriesChartWithTooltipsProps { + bounds: any; + detectorIndex: number; + renderFocusChartOnly: boolean; + selectedJob: CombinedJob; + selectedEntities: Record; + showAnnotations: boolean; + showForecast: boolean; + showModelBounds: boolean; + chartProps: any; + lastRefresh: number; + contextAggregationInterval: any; +} +export const TimeSeriesChartWithTooltips: FC = ({ + bounds, + detectorIndex, + renderFocusChartOnly, + selectedJob, + selectedEntities, + showAnnotations, + showForecast, + showModelBounds, + chartProps, + lastRefresh, + contextAggregationInterval, +}) => { + const { toasts: toastNotifications } = useNotifications(); + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const [annotationData, setAnnotationData] = useState([]); + + const showAnnotationErrorToastNotification = useCallback((error?: string) => { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.ml.timeSeriesExplorer.mlSingleMetricViewerChart.annotationsErrorTitle', + { + defaultMessage: 'An error occurred fetching annotations', + } + ), + ...(error ? { text: extractErrorMessage(error) } : {}), + }); + }, []); + + useEffect(() => { + let unmounted = false; + const entities = getControlsForDetector(detectorIndex, selectedEntities, selectedJob.job_id); + const nonBlankEntities = Array.isArray(entities) + ? entities.filter((entity) => entity.fieldValue !== null) + : undefined; + const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, false); + + /** + * Loads the full list of annotations for job without any aggs or time boundaries + * used to indicate existence of annotations that are beyond the selected time + * in the time series brush area + */ + const loadAnnotations = async (jobId: string) => { + try { + const resp = await mlApiServices.annotations.getAnnotations({ + jobIds: [jobId], + earliestMs: searchBounds.min.valueOf(), + latestMs: searchBounds.max.valueOf(), + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + fields: [ + { + field: 'event', + missing: ANNOTATION_EVENT_USER, + }, + ], + detectorIndex, + entities: nonBlankEntities, + }); + if (!unmounted) { + if (Array.isArray(resp.annotations[jobId])) { + setAnnotationData(resp.annotations[jobId]); + } + } + } catch (error) { + showAnnotationErrorToastNotification(error); + } + }; + + loadAnnotations(selectedJob.job_id); + + return () => { + unmounted = true; + }; + }, [ + selectedJob.job_id, + detectorIndex, + lastRefresh, + selectedEntities, + bounds, + contextAggregationInterval, + ]); + + return ( +
+ + {(tooltipService) => ( + + )} + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index cb66b8d53e660..530ba567ed9f7 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -6,7 +6,7 @@ import { FC } from 'react'; -import { getDateFormatTz, TimeRangeBounds } from '../explorer/explorer_utils'; +import { TimeRangeBounds } from '../explorer/explorer_utils'; declare const TimeSeriesExplorer: FC<{ appStateHandler: (action: string, payload: any) => void; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 5e452dab2f883..720c1377d4035 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -47,12 +47,10 @@ import { import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; -import { MlTooltipComponent } from '../components/chart_tooltip'; import { ForecastingModal } from './components/forecasting_modal/forecasting_modal'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { SelectInterval } from '../components/controls/select_interval/select_interval'; import { SelectSeverity } from '../components/controls/select_severity/select_severity'; -import { TimeseriesChart } from './components/timeseries_chart/timeseries_chart'; import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_no_chart_data'; import { TimeSeriesExplorerPage } from './timeseriesexplorer_page'; @@ -83,6 +81,7 @@ import { import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; import { getControlsForDetector } from './get_controls_for_detector'; import { SeriesControls } from './components/series_controls'; +import { TimeSeriesChartWithTooltips } from './components/timeseries_chart/timeseries_chart_with_tooltip'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -175,6 +174,7 @@ export class TimeSeriesExplorer extends React.Component { this.resizeRef.current !== null ? this.resizeRef.current.offsetWidth - containerPadding : 0, }); }; + unmounted = false; /** * Subject for listening brush time range selection. @@ -877,6 +877,7 @@ export class TimeSeriesExplorer extends React.Component { componentWillUnmount() { this.subscriptions.unsubscribe(); this.resizeChecker.destroy(); + this.unmounted = true; } render() { @@ -957,7 +958,6 @@ export class TimeSeriesExplorer extends React.Component { isEqual(this.previousChartProps.focusForecastData, chartProps.focusForecastData) && isEqual(this.previousChartProps.focusChartData, chartProps.focusChartData) && isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) && - this.previousShowAnnotations === showAnnotations && this.previousShowForecast === showForecast && this.previousShowModelBounds === showModelBounds && this.props.previousRefresh === lastRefresh @@ -966,7 +966,6 @@ export class TimeSeriesExplorer extends React.Component { } this.previousChartProps = chartProps; - this.previousShowAnnotations = showAnnotations; this.previousShowForecast = showForecast; this.previousShowModelBounds = showModelBounds; @@ -1134,23 +1133,19 @@ export class TimeSeriesExplorer extends React.Component { )} -
- - {(tooltipService) => ( - - )} - -
+ {focusAnnotationError !== undefined && ( <>