diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 1871e8925cb75..6d70566af1a64 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -23,6 +23,7 @@ import { loadAnomaliesTableData, loadFilteredTopInfluencers, loadTopInfluencers, + loadOverallAnnotations, AppStateSelectedCells, ExplorerJob, } from '../explorer_utils'; @@ -55,6 +56,10 @@ const memoize = any>(func: T, context?: any) => { return memoizeOne(wrapWithLastRefreshArg(func, context) as any, memoizeIsEqual); }; +const memoizedLoadOverallAnnotations = memoize( + loadOverallAnnotations +); + const memoizedLoadAnnotationsTableData = memoize( loadAnnotationsTableData ); @@ -149,9 +154,17 @@ const loadExplorerDataProvider = ( const dateFormatTz = getDateFormatTz(); + const interval = swimlaneBucketInterval.asSeconds(); + // First get the data where we have all necessary args at hand using forkJoin: // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues return forkJoin({ + overallAnnotations: memoizedLoadOverallAnnotations( + lastRefresh, + selectedJobs, + interval, + bounds + ), annotationsData: memoizedLoadAnnotationsTableData( lastRefresh, selectedCells, @@ -214,6 +227,7 @@ const loadExplorerDataProvider = ( tap(explorerService.setChartsDataLoading), mergeMap( ({ + overallAnnotations, anomalyChartRecords, influencers, overallState, @@ -271,6 +285,7 @@ const loadExplorerDataProvider = ( }), map(({ viewBySwimlaneState, filteredTopInfluencers }) => { return { + overallAnnotations, annotations: annotationsData, influencers: filteredTopInfluencers as any, loading: false, diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 37967d18dbbd9..38cb556aaf0d2 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -87,6 +87,7 @@ export const AnomalyTimeline: FC = React.memo( viewByPerPage, swimlaneLimit, loading, + overallAnnotations, } = explorerState; const menuItems = useMemo(() => { @@ -240,6 +241,7 @@ export const AnomalyTimeline: FC = React.memo( isLoading={loading} noDataWarning={} showTimeline={false} + annotationsData={overallAnnotations.annotationsData} /> @@ -257,6 +259,7 @@ export const AnomalyTimeline: FC = React.memo( }) } timeBuckets={timeBuckets} + showLegend={false} swimlaneData={viewBySwimlaneData as ViewBySwimLaneData} swimlaneType={SWIMLANE_TYPE.VIEW_BY} selection={selectedCells} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts index b410449218d02..ebab308b86027 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -110,6 +110,12 @@ declare interface SwimlaneBounds { latest: number; } +export declare const loadOverallAnnotations: ( + selectedJobs: ExplorerJob[], + interval: number, + bounds: TimeRangeBounds +) => Promise; + export declare const loadAnnotationsTableData: ( selectedCells: AppStateSelectedCells | undefined, selectedJobs: ExplorerJob[], 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 69bdac060a2dc..ecf347e6b142f 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -385,6 +385,57 @@ export function getViewBySwimlaneOptions({ }; } +export function loadOverallAnnotations(selectedJobs, interval, bounds) { + const jobIds = selectedJobs.map((d) => d.id); + const timeRange = getSelectionTimeRange(undefined, interval, bounds); + + return new Promise((resolve) => { + ml.annotations + .getAnnotations$({ + jobIds, + earliestMs: timeRange.earliestMs, + latestMs: timeRange.latestMs, + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + }) + .toPromise() + .then((resp) => { + if (resp.error !== undefined || resp.annotations === undefined) { + const errorMessage = extractErrorMessage(resp.error); + return resolve({ + annotationsData: [], + error: errorMessage !== '' ? errorMessage : undefined, + }); + } + + const annotationsData = []; + jobIds.forEach((jobId) => { + const jobAnnotations = resp.annotations[jobId]; + if (jobAnnotations !== undefined) { + annotationsData.push(...jobAnnotations); + } + }); + + return resolve({ + annotationsData: annotationsData + .sort((a, b) => { + return a.timestamp - b.timestamp; + }) + .map((d, i) => { + d.key = (i + 1).toString(); + return d; + }), + }); + }) + .catch((resp) => { + const errorMessage = extractErrorMessage(resp); + return resolve({ + annotationsData: [], + error: errorMessage !== '' ? errorMessage : undefined, + }); + }); + }); +} + export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) { const jobIds = selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index e9527b7c232e5..faab658740a70 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -27,6 +27,7 @@ import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; export interface ExplorerState { + overallAnnotations: AnnotationsTable; annotations: AnnotationsTable; anomalyChartsDataLoading: boolean; chartsData: ExplorerChartsData; @@ -65,6 +66,11 @@ function getDefaultIndexPattern() { export function getExplorerDefaultState(): ExplorerState { return { + overallAnnotations: { + error: undefined, + annotationsData: [], + aggregations: {}, + }, annotations: { error: undefined, annotationsData: [], diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_annotation_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_annotation_container.tsx new file mode 100644 index 0000000000000..686413ff0188b --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_annotation_container.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect } from 'react'; +import d3 from 'd3'; +import { scaleTime } from 'd3-scale'; +import { i18n } from '@kbn/i18n'; +import { formatHumanReadableDateTimeSeconds } from '../../../common/util/date_utils'; +import { AnnotationsTable } from '../../../common/types/annotations'; +import { ChartTooltipService } from '../components/chart_tooltip'; + +export const Y_AXIS_LABEL_WIDTH = 170; +export const Y_AXIS_LABEL_PADDING = 8; +export const Y_AXIS_LABEL_FONT_COLOR = '#6a717d'; +const ANNOTATION_CONTAINER_HEIGHT = 12; +const ANNOTATION_MARGIN = 2; +const ANNOTATION_MIN_WIDTH = 5; +const ANNOTATION_HEIGHT = ANNOTATION_CONTAINER_HEIGHT - 2 * ANNOTATION_MARGIN; + +interface SwimlaneAnnotationContainerProps { + chartWidth: number; + domain: { + min: number; + max: number; + }; + annotationsData?: AnnotationsTable['annotationsData']; + tooltipService: ChartTooltipService; +} + +export const SwimlaneAnnotationContainer: FC = ({ + chartWidth, + domain, + annotationsData, + tooltipService, +}) => { + const canvasRef = React.useRef(null); + + useEffect(() => { + if (canvasRef.current !== null && Array.isArray(annotationsData)) { + const chartElement = d3.select(canvasRef.current); + chartElement.selectAll('*').remove(); + + const dimensions = canvasRef.current.getBoundingClientRect(); + + const startingXPos = Y_AXIS_LABEL_WIDTH + 2 * Y_AXIS_LABEL_PADDING; + const endingXPos = dimensions.width - 2 * Y_AXIS_LABEL_PADDING - 4; + + const svg = chartElement + .append('svg') + .attr('width', '100%') + .attr('height', ANNOTATION_CONTAINER_HEIGHT); + + const xScale = scaleTime().domain([domain.min, domain.max]).range([startingXPos, endingXPos]); + + // Add Annotation y axis label + svg + .append('text') + .attr('text-anchor', 'end') + .attr('class', 'swimlaneAnnotationLabel') + .text( + i18n.translate('xpack.ml.explorer.swimlaneAnnotationLabel', { + defaultMessage: 'Annotations', + }) + ) + .attr('x', Y_AXIS_LABEL_WIDTH + Y_AXIS_LABEL_PADDING) + .attr('y', ANNOTATION_CONTAINER_HEIGHT) + .style('fill', Y_AXIS_LABEL_FONT_COLOR) + .style('font-size', '12px'); + + // Add border + svg + .append('rect') + .attr('x', startingXPos) + .attr('y', 0) + .attr('height', ANNOTATION_CONTAINER_HEIGHT) + .attr('width', endingXPos - startingXPos) + .style('stroke', '#cccccc') + .style('fill', 'none') + .style('stroke-width', 1); + + // Add annotation marker + annotationsData.forEach((d) => { + const annotationWidth = d.end_timestamp + ? xScale(Math.min(d.end_timestamp, domain.max)) - + Math.max(xScale(d.timestamp), startingXPos) + : 0; + + svg + .append('rect') + .classed('mlAnnotationRect', true) + .attr('x', d.timestamp >= domain.min ? xScale(d.timestamp) : startingXPos) + .attr('y', ANNOTATION_MARGIN) + .attr('height', ANNOTATION_HEIGHT) + .attr('width', Math.max(annotationWidth, ANNOTATION_MIN_WIDTH)) + .attr('rx', ANNOTATION_MARGIN) + .attr('ry', ANNOTATION_MARGIN) + .on('mouseover', function () { + const startingTime = formatHumanReadableDateTimeSeconds(d.timestamp); + const endingTime = + d.end_timestamp !== undefined + ? formatHumanReadableDateTimeSeconds(d.end_timestamp) + : undefined; + + const timeLabel = endingTime ? `${startingTime} - ${endingTime}` : startingTime; + + const tooltipData = [ + { + label: `${d.annotation}`, + seriesIdentifier: { + key: 'anomaly_timeline', + specId: d._id ?? `${d.annotation}-${d.timestamp}-label`, + }, + valueAccessor: 'label', + }, + { + label: `${timeLabel}`, + seriesIdentifier: { + key: 'anomaly_timeline', + specId: d._id ?? `${d.annotation}-${d.timestamp}-ts`, + }, + valueAccessor: 'time', + }, + ]; + if (d.partition_field_name !== undefined && d.partition_field_value !== undefined) { + tooltipData.push({ + label: `${d.partition_field_name}: ${d.partition_field_value}`, + seriesIdentifier: { + key: 'anomaly_timeline', + specId: d._id + ? `${d._id}-partition` + : `${d.partition_field_name}-${d.partition_field_value}-label`, + }, + valueAccessor: 'partition', + }); + } + // @ts-ignore we don't need all the fields for tooltip to show + tooltipService.show(tooltipData, this); + }) + .on('mouseout', () => tooltipService.hide()); + }); + } + }, [chartWidth, domain, annotationsData]); + + return
; +}; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index c108257094b6a..0f445a4872417 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -38,13 +38,20 @@ import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../common'; import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; import { mlEscape } from '../util/string_utils'; -import { FormattedTooltip } from '../components/chart_tooltip/chart_tooltip'; +import { FormattedTooltip, MlTooltipComponent } from '../components/chart_tooltip/chart_tooltip'; import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; import { getFormattedSeverityScore } from '../../../common/util/anomaly_utils'; import './_explorer.scss'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; import { useUiSettings } from '../contexts/kibana'; +import { + SwimlaneAnnotationContainer, + Y_AXIS_LABEL_WIDTH, + Y_AXIS_LABEL_PADDING, + Y_AXIS_LABEL_FONT_COLOR, +} from './swimlane_annotation_container'; +import { AnnotationsTable } from '../../../common/types/annotations'; declare global { interface Window { @@ -61,8 +68,10 @@ declare global { const RESIZE_THROTTLE_TIME_MS = 500; const CELL_HEIGHT = 30; const LEGEND_HEIGHT = 34; + const Y_AXIS_HEIGHT = 24; -export const SWIM_LANE_LABEL_WIDTH = 200; + +export const SWIM_LANE_LABEL_WIDTH = Y_AXIS_LABEL_WIDTH + 2 * Y_AXIS_LABEL_PADDING; export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData { return arg && arg.hasOwnProperty('cardinality'); @@ -125,6 +134,7 @@ export interface SwimlaneProps { filterActive?: boolean; maskAll?: boolean; timeBuckets: InstanceType; + showLegend?: boolean; swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; swimlaneType: SwimlaneType; selection?: AppStateSelectedCells; @@ -145,6 +155,7 @@ export interface SwimlaneProps { * Enables/disables timeline on the X-axis. */ showTimeline?: boolean; + annotationsData?: AnnotationsTable['annotationsData']; } /** @@ -168,6 +179,8 @@ export const SwimlaneContainer: FC = ({ timeBuckets, maskAll, showTimeline = true, + showLegend = true, + annotationsData, 'data-test-subj': dataTestSubj, }) => { const [chartWidth, setChartWidth] = useState(0); @@ -292,13 +305,14 @@ export const SwimlaneContainer: FC = ({ }, yAxisLabel: { visible: true, - width: 170, + width: Y_AXIS_LABEL_WIDTH, // eui color subdued - fill: `#6a717d`, - padding: 8, + fill: Y_AXIS_LABEL_FONT_COLOR, + padding: Y_AXIS_LABEL_PADDING, formatter: (laneLabel: string) => { return laneLabel === '' ? EMPTY_FIELD_VALUE_LABEL : laneLabel; }, + fontSize: 12, }, xAxisLabel: { visible: true, @@ -309,6 +323,7 @@ export const SwimlaneContainer: FC = ({ const scaledDateFormat = timeBuckets.getScaledDateFormat(); return moment(v).format(scaledDateFormat); }, + fontSize: 12, }, brushMask: { fill: isDarkTheme ? 'rgb(30,31,35,80%)' : 'rgb(247,247,247,50%)', @@ -354,6 +369,14 @@ export const SwimlaneContainer: FC = ({ [swimlaneData?.fieldName] ); + const xDomain = swimlaneData + ? { + min: swimlaneData.earliest * 1000, + max: swimlaneData.latest * 1000, + minInterval: swimlaneData.interval * 1000, + } + : undefined; + // A resize observer is required to compute the bucket span based on the chart width to fetch the data accordingly return ( @@ -372,77 +395,95 @@ export const SwimlaneContainer: FC = ({ }} grow={false} > -
- {showSwimlane && !isLoading && ( - - +
+ {showSwimlane && !isLoading && ( + + + + + + )} + + {isLoading && ( + - - - )} - - {isLoading && ( - - + + + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}} - /> - )} -
+ )} +
+ {swimlaneType === SWIMLANE_TYPE.OVERALL && + showSwimlane && + xDomain !== undefined && + !isLoading && ( + + {(tooltipService) => ( + + )} + + )} + {isPaginationVisible && ( 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 88c98b888f5e6..f3f9e935a92c7 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 @@ -19,9 +19,9 @@ export const annotations = { earliestMs: number; latestMs: number; maxAnnotations: number; - fields: FieldToBucket[]; - detectorIndex: number; - entities: any[]; + fields?: FieldToBucket[]; + detectorIndex?: number; + entities?: any[]; }) { const body = JSON.stringify(obj); return http$({ 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 3725f57eab026..9eb2390b4bf99 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 @@ -1135,7 +1135,7 @@ class TimeseriesChartIntl extends Component { .attr('y', cxtChartHeight + swlHeight + 2) .attr('height', ANNOTATION_SYMBOL_HEIGHT) .attr('width', (d) => { - const start = this.contextXScale(moment(d.timestamp)) + 1; + const start = Math.max(this.contextXScale(moment(d.timestamp)) + 1, contextXRangeStart); const end = typeof d.end_timestamp !== 'undefined' ? this.contextXScale(moment(d.end_timestamp)) - 1