diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.js b/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.js index a2a3aea5988aa..fdd855e80a6df 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.js @@ -38,6 +38,7 @@ import { import { MULTI_BUCKET_IMPACT } from '../../../../common/constants/multi_bucket_impact'; import { formatValue } from '../../formatters/format_value'; import { MAX_CHARS } from './anomalies_table_constants'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; const TIME_FIELD_NAME = 'timestamp'; @@ -130,7 +131,8 @@ function getDetailsItems(anomaly, examples, filter) { title: i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.functionTitle', { defaultMessage: 'function', }), - description: source.function !== 'metric' ? source.function : source.function_description, + description: + source.function !== ML_JOB_AGGREGATION.METRIC ? source.function : source.function_description, }); if (source.field_name !== undefined) { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_config_builder.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_config_builder.js index b75784c95c520..2ba6e38081e6e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_config_builder.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_config_builder.js @@ -13,6 +13,8 @@ import { parseInterval } from '../../../../common/util/parse_interval'; import { getEntityFieldList } from '../../../../common/util/anomaly_utils'; import { buildConfigFromDetector } from '../../util/chart_config_builder'; import { mlJobService } from '../../services/job_service'; +import { mlFunctionToESAggregation } from '../../../../common/util/job_utils'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; // Builds the chart configuration for the provided anomaly record, returning // an object with properties used for the display (series function and field, aggregation interval etc), @@ -48,6 +50,10 @@ export function buildConfig(record) { // define the metric series to be plotted. config.entityFields = getEntityFieldList(record); + if (record.function === ML_JOB_AGGREGATION.METRIC) { + config.metricFunction = mlFunctionToESAggregation(record.function_description); + } + // Build the tooltip data for the chart info icon, showing further details on what is being plotted. let functionLabel = config.metricFunction; if (config.metricFieldName !== undefined) { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js index b9634f0eac359..39166841a4e1b 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js @@ -46,8 +46,6 @@ const ML_TIME_FIELD_NAME = 'timestamp'; const USE_OVERALL_CHART_LIMITS = false; const MAX_CHARTS_PER_ROW = 4; -// callback(getDefaultChartsData()); - export const anomalyDataChange = function ( chartsContainerWidth, anomalyRecords, diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index e4cf43ac91727..9331fdc04b7bb 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -38,6 +38,7 @@ import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { useTimefilter } from '../../contexts/kibana'; +import { useToastNotificationService } from '../../services/toast_notification_service'; export const timeSeriesExplorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -88,6 +89,7 @@ export const TimeSeriesExplorerUrlStateManager: FC { + const toastNotificationService = useToastNotificationService(); const [appState, setAppState] = useUrlState('_a'); const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); @@ -293,6 +295,7 @@ export const TimeSeriesExplorerUrlStateManager: FC ({ latestMs: number, dateFormatTz: string, maxRecords: number, - maxExamples: number, - influencersFilterQuery: any + maxExamples?: number, + influencersFilterQuery?: any, + functionDescription?: string ) { const body = JSON.stringify({ jobIds, @@ -39,6 +40,7 @@ export const resultsApiProvider = (httpService: HttpService) => ({ maxRecords, maxExamples, influencersFilterQuery, + functionDescription, }); return httpService.http$({ diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index 7c2c28fe9385c..2869a7439614f 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -19,6 +19,7 @@ import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; import { MlApiServices } from '../ml_api_service'; import { CriteriaField } from './index'; +import { aggregationTypeTransform } from '../../../../common/util/anomaly_utils'; interface ResultResponse { success: boolean; @@ -347,9 +348,10 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { jobIds: string[], criteriaFields: CriteriaField[], threshold: any, - earliestMs: number, - latestMs: number, - maxResults: number | undefined + earliestMs: number | null, + latestMs: number | null, + maxResults: number | undefined, + functionDescription?: string ): Observable { const obj: RecordsForCriteria = { success: true, records: [] }; @@ -400,6 +402,19 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { }); }); + if (functionDescription !== undefined) { + const mlFunctionToPlotIfMetric = + functionDescription !== undefined + ? aggregationTypeTransform.toML(functionDescription) + : functionDescription; + + boolCriteria.push({ + term: { + function_description: mlFunctionToPlotIfMetric, + }, + }); + } + return mlApiServices.results .anomalySearch$( { diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index aae0cb51aa81d..962f384cf5b1b 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -76,6 +76,7 @@ export function resultsServiceProvider( criteriaFields: any[], earliestMs: number, latestMs: number, - intervalMs: number + intervalMs: number, + actualPlotFunctionIfMetric?: string ): Promise; }; diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 14a725c2e22b7..d053d69b4d1f2 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -12,6 +12,7 @@ import { ANOMALY_SWIM_LANE_HARD_LIMIT, SWIM_LANE_DEFAULT_PAGE_SIZE, } from '../../explorer/explorer_constants'; +import { aggregationTypeTransform } from '../../../../common/util/anomaly_utils'; /** * Service for carrying out Elasticsearch queries to obtain data for the Ml Results dashboards. @@ -1293,7 +1294,14 @@ export function resultsServiceProvider(mlApiServices) { // criteria, time range, and aggregation interval. // criteriaFields parameter must be an array, with each object in the array having 'fieldName' // 'fieldValue' properties. - getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, latestMs, intervalMs) { + getRecordMaxScoreByTime( + jobId, + criteriaFields, + earliestMs, + latestMs, + intervalMs, + actualPlotFunctionIfMetric + ) { return new Promise((resolve, reject) => { const obj = { success: true, @@ -1321,7 +1329,18 @@ export function resultsServiceProvider(mlApiServices) { }, }); }); + if (actualPlotFunctionIfMetric !== undefined) { + const mlFunctionToPlotIfMetric = + actualPlotFunctionIfMetric !== undefined + ? aggregationTypeTransform.toML(actualPlotFunctionIfMetric) + : actualPlotFunctionIfMetric; + mustCriteria.push({ + term: { + function_description: mlFunctionToPlotIfMetric, + }, + }); + } mlApiServices.results .anomalySearch( { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/index.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/index.ts new file mode 100644 index 0000000000000..b8247eb91e1f5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { PlotByFunctionControls } from './plot_function_controls'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx new file mode 100644 index 0000000000000..0356c20fecb9a --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx @@ -0,0 +1,56 @@ +/* + * 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 from 'react'; +import { EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const plotByFunctionOptions = [ + { + value: 'mean', + text: i18n.translate('xpack.ml.timeSeriesExplorer.plotByAvgOptionLabel', { + defaultMessage: 'mean', + }), + }, + { + value: 'min', + text: i18n.translate('xpack.ml.timeSeriesExplorer.plotByMinOptionLabel', { + defaultMessage: 'min', + }), + }, + { + value: 'max', + text: i18n.translate('xpack.ml.timeSeriesExplorer.plotByMaxOptionLabel', { + defaultMessage: 'max', + }), + }, +]; +export const PlotByFunctionControls = ({ + functionDescription, + setFunctionDescription, +}: { + functionDescription: undefined | string; + setFunctionDescription: (func: string) => void; +}) => { + if (functionDescription === undefined) return null; + return ( + + + setFunctionDescription(e.target.value)} + aria-label={i18n.translate('xpack.ml.timeSeriesExplorer.metricPlotByOptionLabel', { + defaultMessage: 'Pick function to plot by (min, max, or average) if metric function', + })} + /> + + + ); +}; 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 3169ecfd1bbc7..8df186c5c3c6e 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 @@ -1475,6 +1475,22 @@ class TimeseriesChartIntl extends Component { }); } + if (marker.metricFunction) { + tooltipData.push({ + label: i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.metricActualPlotFunctionLabel', + { + defaultMessage: 'function', + } + ), + value: marker.metricFunction, + seriesIdentifier: { + key: seriesKey, + }, + valueAccessor: 'metric_function', + }); + } + if (modelPlotEnabled === false) { // Show actual/typical when available except for rare detectors. // Rare detectors always have 1 as actual and the probability as typical. diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_criteria_fields.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_criteria_fields.ts new file mode 100644 index 0000000000000..f9775976206c2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_criteria_fields.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Updates criteria fields for API calls, e.g. getAnomaliesTableData + * @param detectorIndex + * @param entities + */ +export const getCriteriaFields = (detectorIndex: number, entities: Record) => { + // Only filter on the entity if the field has a value. + const nonBlankEntities = entities.filter( + (entity: { fieldValue: any }) => entity.fieldValue !== null + ); + return [ + { + fieldName: 'detector_index', + fieldValue: detectorIndex, + }, + ...nonBlankEntities, + ]; +}; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts new file mode 100644 index 0000000000000..029e4645cfe26 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts @@ -0,0 +1,62 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { mlResultsService } from '../services/results_service'; +import { ToastNotificationService } from '../services/toast_notification_service'; +import { getControlsForDetector } from './get_controls_for_detector'; +import { getCriteriaFields } from './get_criteria_fields'; +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { ML_JOB_AGGREGATION } from '../../../common/constants/aggregation_types'; + +/** + * Get the function description from the record with the highest anomaly score + */ +export const getFunctionDescription = async ( + { + selectedDetectorIndex, + selectedEntities, + selectedJobId, + selectedJob, + }: { + selectedDetectorIndex: number; + selectedEntities: Record; + selectedJobId: string; + selectedJob: CombinedJob; + }, + toastNotificationService: ToastNotificationService +) => { + // if the detector's function is metric, fetch the highest scoring anomaly record + // and set to plot the function_description (avg/min/max) of that record by default + if ( + selectedJob?.analysis_config?.detectors[selectedDetectorIndex]?.function !== + ML_JOB_AGGREGATION.METRIC + ) + return; + + const entityControls = getControlsForDetector( + selectedDetectorIndex, + selectedEntities, + selectedJobId + ); + const criteriaFields = getCriteriaFields(selectedDetectorIndex, entityControls); + try { + const resp = await mlResultsService + .getRecordsForCriteria([selectedJob.job_id], criteriaFields, 0, null, null, 1) + .toPromise(); + if (Array.isArray(resp?.records) && resp.records.length === 1) { + const highestScoringAnomaly = resp.records[0]; + return highestScoringAnomaly?.function_description; + } + } catch (error) { + toastNotificationService.displayErrorToast( + error, + i18n.translate('xpack.ml.timeSeriesExplorer.highestAnomalyScoreErrorToastTitle', { + defaultMessage: 'An error occurred getting record with the highest anomaly score', + }) + ); + } +}; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts index e43ba8c87083a..0d7abdab90be0 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts @@ -26,7 +26,8 @@ function getMetricData( entityFields: EntityField[], earliestMs: number, latestMs: number, - intervalMs: number + intervalMs: number, + esMetricFunction?: string ): Observable { if ( isModelPlotChartableForDetector(job, detectorIndex) && @@ -88,7 +89,7 @@ function getMetricData( chartConfig.datafeedConfig.indices, entityFields, chartConfig.datafeedConfig.query, - chartConfig.metricFunction, + esMetricFunction ?? chartConfig.metricFunction, chartConfig.metricFieldName, chartConfig.timeField, earliestMs, diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 720c1377d4035..e3b6e38f47bab 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -82,6 +82,9 @@ import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/ import { getControlsForDetector } from './get_controls_for_detector'; import { SeriesControls } from './components/series_controls'; import { TimeSeriesChartWithTooltips } from './components/timeseries_chart/timeseries_chart_with_tooltip'; +import { PlotByFunctionControls } from './components/plot_function_controls'; +import { aggregationTypeTransform } from '../../../common/util/anomaly_utils'; +import { getFunctionDescription } from './get_function_description'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -140,6 +143,8 @@ function getTimeseriesexplorerDefaultState() { zoomTo: undefined, zoomFromFocusLoaded: undefined, zoomToFocusLoaded: undefined, + // Sets function to plot by if original function is metric + functionDescription: undefined, }; } @@ -217,6 +222,12 @@ export class TimeSeriesExplorer extends React.Component { }); }; + setFunctionDescription = (selectedFuction) => { + this.setState({ + functionDescription: selectedFuction, + }); + }; + previousChartProps = {}; previousShowAnnotations = undefined; previousShowForecast = undefined; @@ -270,7 +281,7 @@ export class TimeSeriesExplorer extends React.Component { */ getFocusData(selection) { const { selectedJobId, selectedForecastId, selectedDetectorIndex } = this.props; - const { modelPlotEnabled } = this.state; + const { modelPlotEnabled, functionDescription } = this.state; const selectedJob = mlJobService.getJob(selectedJobId); const entityControls = this.getControlsForDetector(); @@ -292,6 +303,7 @@ export class TimeSeriesExplorer extends React.Component { entityControls.filter((entity) => entity.fieldValue !== null), searchBounds, selectedJob, + functionDescription, TIME_FIELD_NAME ); } @@ -322,6 +334,7 @@ export class TimeSeriesExplorer extends React.Component { tableInterval, tableSeverity, } = this.props; + const { functionDescription } = this.state; const selectedJob = mlJobService.getJob(selectedJobId); const entityControls = this.getControlsForDetector(); @@ -335,7 +348,10 @@ export class TimeSeriesExplorer extends React.Component { earliestMs, latestMs, dateFormatTz, - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, + undefined, + undefined, + functionDescription ) .pipe( map((resp) => { @@ -378,6 +394,24 @@ export class TimeSeriesExplorer extends React.Component { ); }; + getFunctionDescription = async () => { + const { selectedDetectorIndex, selectedEntities, selectedJobId } = this.props; + const selectedJob = mlJobService.getJob(selectedJobId); + + const functionDescriptionToPlot = await getFunctionDescription( + { + selectedDetectorIndex, + selectedEntities, + selectedJobId, + selectedJob, + }, + this.props.toastNotificationService + ); + if (!this.unmounted) { + this.setFunctionDescription(functionDescriptionToPlot); + } + }; + setForecastId = (forecastId) => { this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); }; @@ -392,13 +426,13 @@ export class TimeSeriesExplorer extends React.Component { zoom, } = this.props; - const { loadCounter: currentLoadCounter } = this.state; + const { loadCounter: currentLoadCounter, functionDescription } = this.state; const currentSelectedJob = mlJobService.getJob(selectedJobId); - if (currentSelectedJob === undefined) { return; } + const functionToPlotByIfMetric = aggregationTypeTransform.toES(functionDescription); this.contextChartSelectedInitCallDone = false; @@ -533,7 +567,8 @@ export class TimeSeriesExplorer extends React.Component { nonBlankEntities, searchBounds.min.valueOf(), searchBounds.max.valueOf(), - stateUpdate.contextAggregationInterval.asMilliseconds() + stateUpdate.contextAggregationInterval.asMilliseconds(), + functionToPlotByIfMetric ) .toPromise() .then((resp) => { @@ -556,7 +591,8 @@ export class TimeSeriesExplorer extends React.Component { this.getCriteriaFields(detectorIndex, entityControls), searchBounds.min.valueOf(), searchBounds.max.valueOf(), - stateUpdate.contextAggregationInterval.asMilliseconds() + stateUpdate.contextAggregationInterval.asMilliseconds(), + functionToPlotByIfMetric ) .then((resp) => { const fullRangeRecordScoreData = processRecordScoreResults(resp.results); @@ -687,7 +723,6 @@ export class TimeSeriesExplorer extends React.Component { if (detectorId !== selectedDetectorIndex) { appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorId); } - // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. mlFieldFormatService.populateFormats([jobId]).catch((err) => { console.log('Error populating field formats:', err); @@ -810,7 +845,7 @@ export class TimeSeriesExplorer extends React.Component { this.componentDidUpdate(); } - componentDidUpdate(previousProps) { + componentDidUpdate(previousProps, previousState) { if (previousProps === undefined || previousProps.selectedJobId !== this.props.selectedJobId) { this.contextChartSelectedInitCallDone = false; this.setState({ fullRefresh: false, loading: true }, () => { @@ -818,6 +853,15 @@ export class TimeSeriesExplorer extends React.Component { }); } + if ( + previousProps === undefined || + previousProps.selectedJobId !== this.props.selectedJobId || + previousProps.selectedDetectorIndex !== this.props.selectedDetectorIndex || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities) + ) { + this.getFunctionDescription(); + } + if ( previousProps === undefined || previousProps.selectedForecastId !== this.props.selectedForecastId @@ -840,7 +884,8 @@ export class TimeSeriesExplorer extends React.Component { !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || previousProps.selectedForecastId !== this.props.selectedForecastId || - previousProps.selectedJobId !== this.props.selectedJobId + previousProps.selectedJobId !== this.props.selectedJobId || + previousState.functionDescription !== this.state.functionDescription ) { const fullRefresh = previousProps === undefined || @@ -848,7 +893,8 @@ export class TimeSeriesExplorer extends React.Component { !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || previousProps.selectedForecastId !== this.props.selectedForecastId || - previousProps.selectedJobId !== this.props.selectedJobId; + previousProps.selectedJobId !== this.props.selectedJobId || + previousState.functionDescription !== this.state.functionDescription; this.loadSingleMetricData(fullRefresh); } @@ -919,8 +965,8 @@ export class TimeSeriesExplorer extends React.Component { zoomTo, zoomFromFocusLoaded, zoomToFocusLoaded, + functionDescription, } = this.state; - const chartProps = { modelPlotEnabled, contextChartData, @@ -939,7 +985,6 @@ export class TimeSeriesExplorer extends React.Component { zoomToFocusLoaded, autoZoomDuration, }; - const jobs = createTimeSeriesJobData(mlJobService.jobs); if (selectedDetectorIndex === undefined || mlJobService.getJob(selectedJobId) === undefined) { @@ -992,7 +1037,6 @@ export class TimeSeriesExplorer extends React.Component { )} - + {functionDescription && ( + + )} + {arePartitioningFieldsProvided && ( @@ -1014,7 +1068,6 @@ export class TimeSeriesExplorer extends React.Component { )} - {fullRefresh && loading === true && ( diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/get_focus_data.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/get_focus_data.ts index d1576be18d5bf..044e5dfd6fe13 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/get_focus_data.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/get_focus_data.ts @@ -26,6 +26,7 @@ import { mlForecastService } from '../../services/forecast_service'; import { mlFunctionToESAggregation } from '../../../../common/util/job_utils'; import { GetAnnotationsResponse } from '../../../../common/types/annotations'; import { ANNOTATION_EVENT_USER } from '../../../../common/constants/annotations'; +import { aggregationTypeTransform } from '../../../../common/util/anomaly_utils'; export interface Interval { asMilliseconds: () => number; @@ -51,8 +52,14 @@ export function getFocusData( modelPlotEnabled: boolean, nonBlankEntities: any[], searchBounds: any, - selectedJob: Job + selectedJob: Job, + functionDescription?: string | undefined ): Observable { + const esFunctionToPlotIfMetric = + functionDescription !== undefined + ? aggregationTypeTransform.toES(functionDescription) + : functionDescription; + return forkJoin([ // Query 1 - load metric data across selected time range. mlTimeSeriesSearchService.getMetricData( @@ -61,7 +68,8 @@ export function getFocusData( nonBlankEntities, searchBounds.min.valueOf(), searchBounds.max.valueOf(), - focusAggregationInterval.asMilliseconds() + focusAggregationInterval.asMilliseconds(), + esFunctionToPlotIfMetric ), // Query 2 - load all the records across selected time range for the chart anomaly markers. mlResultsService.getRecordsForCriteria( @@ -70,7 +78,8 @@ export function getFocusData( 0, searchBounds.min.valueOf(), searchBounds.max.valueOf(), - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, + functionDescription ), // Query 3 - load any scheduled events for the selected job. mlResultsService.getScheduledEventsByBucket( @@ -143,7 +152,8 @@ export function getFocusData( focusChartData, anomalyRecords, focusAggregationInterval, - modelPlotEnabled + modelPlotEnabled, + functionDescription ); focusChartData = processScheduledEventsForChart(focusChartData, scheduledEvents); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts index 1b7a740d90dde..4b101f888e4ea 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts @@ -16,7 +16,8 @@ export function processDataForFocusAnomalies( chartData: any, anomalyRecords: any, aggregationInterval: any, - modelPlotEnabled: any + modelPlotEnabled: any, + functionDescription: any ): any; export function processScheduledEventsForChart(chartData: any, scheduledEvents: any): any; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js index d24794382128d..5dc3a454e41e7 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js @@ -19,6 +19,7 @@ import { parseInterval } from '../../../../common/util/parse_interval'; import { getBoundsRoundedToInterval, getTimeBucketsFromCache } from '../../util/time_buckets'; import { CHARTS_POINT_TARGET, TIME_FIELD_NAME } from '../timeseriesexplorer_constants'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; // create new job objects based on standard job config objects // new job objects just contain job id, bucket span in seconds and a selected flag. @@ -100,7 +101,8 @@ export function processDataForFocusAnomalies( chartData, anomalyRecords, aggregationInterval, - modelPlotEnabled + modelPlotEnabled, + functionDescription ) { const timesToAddPointsFor = []; @@ -142,6 +144,12 @@ export function processDataForFocusAnomalies( // Look for a chart point with the same time as the record. // If none found, find closest time in chartData set. const recordTime = record[TIME_FIELD_NAME]; + if ( + record.function === ML_JOB_AGGREGATION.METRIC && + record.function_description !== functionDescription + ) + return; + const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); if (chartPoint !== undefined) { // If chart aggregation interval > bucket span, there may be more than @@ -160,6 +168,10 @@ export function processDataForFocusAnomalies( chartPoint.value = record.actual; } + if (record.function === ML_JOB_AGGREGATION.METRIC) { + chartPoint.value = Array.isArray(record.actual) ? record.actual[0] : record.actual; + } + chartPoint.actual = record.actual; chartPoint.typical = record.typical; } else { diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts index 53a35f6310978..a196f1034fdd3 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.ts +++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts @@ -54,7 +54,8 @@ export function resultsServiceProvider(mlClient: MlClient) { dateFormatTz: string, maxRecords: number = ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, maxExamples: number = DEFAULT_MAX_EXAMPLES, - influencersFilterQuery: any + influencersFilterQuery?: any, + functionDescription?: string ) { // Build the query to return the matching anomaly record results. // Add criteria for the time range, record score, plus any specified job IDs. @@ -102,6 +103,13 @@ export function resultsServiceProvider(mlClient: MlClient) { }, }); }); + if (functionDescription !== undefined) { + boolCriteria.push({ + term: { + function_description: functionDescription, + }, + }); + } if (influencersFilterQuery !== undefined) { boolCriteria.push(influencersFilterQuery); diff --git a/x-pack/plugins/ml/server/routes/results_service.ts b/x-pack/plugins/ml/server/routes/results_service.ts index ce892b227c04e..e708dd71043d0 100644 --- a/x-pack/plugins/ml/server/routes/results_service.ts +++ b/x-pack/plugins/ml/server/routes/results_service.ts @@ -36,6 +36,7 @@ function getAnomaliesTableData(mlClient: MlClient, payload: any) { maxRecords, maxExamples, influencersFilterQuery, + functionDescription, } = payload; return rs.getAnomaliesTableData( jobIds, @@ -48,7 +49,8 @@ function getAnomaliesTableData(mlClient: MlClient, payload: any) { dateFormatTz, maxRecords, maxExamples, - influencersFilterQuery + influencersFilterQuery, + functionDescription ); } diff --git a/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts index 5cd0ecdfbec90..30a9054c69238 100644 --- a/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts @@ -26,6 +26,7 @@ export const anomaliesTableDataSchema = schema.object({ maxRecords: schema.number(), maxExamples: schema.maybe(schema.number()), influencersFilterQuery: schema.maybe(schema.any()), + functionDescription: schema.maybe(schema.nullable(schema.string())), }); export const categoryDefinitionSchema = schema.object({