From 25f880ee43ea6f8cbe27e6a75d3ed5766c459b97 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 31 Mar 2020 16:29:00 +0200 Subject: [PATCH] [ML] Handle Empty Partition Field Values in Single Metric Viewer (#61649) * [ML] WIP support empty partition fields values * [ML] support empty field in anomaly table * [ML] remove comments * [ML] fix context chart * [ML] rename empty field label, render as italic * [ML] rename empty field label * [ML] fix focus chart * [ML] add time range capping for fields_service.ts * [ML] empty string labels in anomaly explorer --- .../components/entity_cell/entity_cell.js | 8 ++- .../application/explorer/explorer_swimlane.js | 6 +- .../results_service/results_service.js | 36 ++---------- .../entity_control/entity_control.tsx | 38 ++++++++---- .../timeseries_search_service.ts | 2 +- .../timeseriesexplorer/timeseriesexplorer.js | 21 +++---- .../models/fields_service/fields_service.ts | 58 +++++++++++++------ 7 files changed, 96 insertions(+), 73 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js index 02a9e569f28a4..d3917412bfb7b 100644 --- a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js +++ b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js @@ -10,6 +10,8 @@ import React from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EMPTY_FIELD_VALUE_LABEL } from '../../timeseriesexplorer/components/entity_control/entity_control'; +import { MLCATEGORY } from '../../../../common/constants/field_types'; function getAddFilter({ entityName, entityValue, filter }) { return ( @@ -68,7 +70,11 @@ export const EntityCell = function EntityCell({ filter, wrapText = false, }) { - const valueText = entityName !== 'mlcategory' ? entityValue : `mlcategory ${entityValue}`; + let valueText = entityValue === '' ? {EMPTY_FIELD_VALUE_LABEL} : entityValue; + if (entityName === MLCATEGORY) { + valueText = `${MLCATEGORY} ${valueText}`; + } + const textStyle = { maxWidth: '100%' }; const textWrapperClass = wrapText ? 'field-value-long' : 'field-value-short'; const shouldDisplayIcons = diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.js b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.js index e8cb8377a656d..d7333f00c89cd 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.js @@ -25,6 +25,7 @@ import { mlChartTooltipService } from '../components/chart_tooltip/chart_tooltip import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service'; import { DRAG_SELECT_ACTION } from './explorer_constants'; import { i18n } from '@kbn/i18n'; +import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; const SCSS = { mlDragselectDragging: 'mlDragselectDragging', @@ -309,6 +310,7 @@ export class ExplorerSwimlane extends React.Component { return function(lane) { const bucketScore = getBucketScore(lane, time); if (bucketScore !== 0) { + lane = lane === '' ? EMPTY_FIELD_VALUE_LABEL : lane; cellMouseover(this, lane, bucketScore, i, time); } }; @@ -376,7 +378,7 @@ export class ExplorerSwimlane extends React.Component { values: { label: mlEscape(label) }, }); } else { - return mlEscape(label); + return label === '' ? `${EMPTY_FIELD_VALUE_LABEL}` : mlEscape(label); } }) .on('click', () => { @@ -393,7 +395,7 @@ export class ExplorerSwimlane extends React.Component { { skipHeader: true }, { label: swimlaneData.fieldName, - value, + value: value === '' ? EMPTY_FIELD_VALUE_LABEL : value, seriesIdentifier: { key: value }, valueAccessor: 'fieldName', }, 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 4dec066a7f325..b7aa5edc88638 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 @@ -1259,39 +1259,13 @@ export function getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, lates }, { term: { job_id: jobId } }, ]; - const shouldCriteria = []; _.each(criteriaFields, criteria => { - if (criteria.fieldValue.length !== 0) { - mustCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - } else { - // Add special handling for blank entity field values, checking for either - // an empty string or the field not existing. - const emptyFieldCondition = { - bool: { - must: [ - { - term: {}, - }, - ], - }, - }; - emptyFieldCondition.bool.must[0].term[criteria.fieldName] = ''; - shouldCriteria.push(emptyFieldCondition); - shouldCriteria.push({ - bool: { - must_not: [ - { - exists: { field: criteria.fieldName }, - }, - ], - }, - }); - } + mustCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); }); ml.esSearch({ diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx index 8911ed53e74d0..7bb0b27472c88 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx @@ -16,6 +16,7 @@ import { EuiFormRow, EuiToolTip, } from '@elastic/eui'; +import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; export interface Entity { fieldName: string; @@ -29,15 +30,22 @@ interface EntityControlProps { isLoading: boolean; onSearchChange: (entity: Entity, queryTerm: string) => void; forceSelection: boolean; - options: EuiComboBoxOptionOption[]; + options: Array>; } interface EntityControlState { - selectedOptions: EuiComboBoxOptionOption[] | undefined; + selectedOptions: Array> | undefined; isLoading: boolean; - options: EuiComboBoxOptionOption[] | undefined; + options: Array> | undefined; } +export const EMPTY_FIELD_VALUE_LABEL = i18n.translate( + 'xpack.ml.timeSeriesExplorer.emptyPartitionFieldLabel.', + { + defaultMessage: '"" (empty string)', + } +); + export class EntityControl extends Component { inputRef: any; @@ -53,16 +61,18 @@ export class EntityControl extends Component> | undefined = selectedOptions; if ( - (selectedOptions === undefined && fieldValue.length > 0) || + (selectedOptions === undefined && fieldValue !== null) || (Array.isArray(selectedOptions) && // @ts-ignore - selectedOptions[0].label !== fieldValue && - fieldValue.length > 0) + selectedOptions[0].value !== fieldValue && + fieldValue !== null) ) { - selectedOptionsUpdate = [{ label: fieldValue }]; - } else if (Array.isArray(selectedOptions) && fieldValue.length === 0) { + selectedOptionsUpdate = [ + { label: fieldValue === '' ? EMPTY_FIELD_VALUE_LABEL : fieldValue, value: fieldValue }, + ]; + } else if (Array.isArray(selectedOptions) && fieldValue === null) { selectedOptionsUpdate = undefined; } @@ -84,14 +94,14 @@ export class EntityControl extends Component { + onChange = (selectedOptions: Array>) => { const options = selectedOptions.length > 0 ? selectedOptions : undefined; this.setState({ selectedOptions: options, }); const fieldValue = - Array.isArray(options) && options[0].label.length > 0 ? options[0].label : ''; + Array.isArray(options) && options[0].value !== null ? options[0].value : null; this.props.entityFieldValueChanged(this.props.entity, fieldValue); }; @@ -103,6 +113,11 @@ export class EntityControl extends Component { + const { label } = option; + return label === EMPTY_FIELD_VALUE_LABEL ? {label} : label; + }; + render() { const { entity, forceSelection } = this.props; const { isLoading, options, selectedOptions } = this.state; @@ -126,6 +141,7 @@ export class EntityControl extends Component ); 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 db5ff2ad91910..f973d41ad7754 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 @@ -128,7 +128,7 @@ function getChartDetails( obj.results.functionLabel = functionLabel; const blankEntityFields = _.filter(entityFields, entity => { - return entity.fieldValue.length === 0; + return entity.fieldValue === null; }); // Look to see if any of the entity fields have defined values diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 1a26540709f34..5e505757dd2aa 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -78,6 +78,7 @@ import { processRecordScoreResults, getFocusData, } from './timeseriesexplorer_utils'; +import { EMPTY_FIELD_VALUE_LABEL } from './components/entity_control/entity_control'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -94,7 +95,7 @@ function getEntityControlOptions(fieldValues) { fieldValues.sort(); return fieldValues.map(value => { - return { label: value }; + return { label: value === '' ? EMPTY_FIELD_VALUE_LABEL : value, value }; }); } @@ -192,7 +193,7 @@ export class TimeSeriesExplorer extends React.Component { getFieldNamesWithEmptyValues = () => { const latestEntityControls = this.getControlsForDetector(); return latestEntityControls - .filter(({ fieldValue }) => !fieldValue) + .filter(({ fieldValue }) => fieldValue === null) .map(({ fieldName }) => fieldName); }; @@ -249,7 +250,7 @@ export class TimeSeriesExplorer extends React.Component { if (operator === '+' && entity.fieldValue !== value) { resultValue = value; } else if (operator === '-' && entity.fieldValue === value) { - resultValue = ''; + resultValue = null; } else { return; } @@ -302,7 +303,7 @@ export class TimeSeriesExplorer extends React.Component { focusAggregationInterval, selectedForecastId, modelPlotEnabled, - entityControls.filter(entity => entity.fieldValue.length > 0), + entityControls.filter(entity => entity.fieldValue !== null), searchBounds, selectedJob, TIME_FIELD_NAME @@ -576,7 +577,7 @@ export class TimeSeriesExplorer extends React.Component { }; const nonBlankEntities = entityControls.filter(entity => { - return entity.fieldValue.length > 0; + return entity.fieldValue !== null; }); if ( @@ -739,7 +740,7 @@ export class TimeSeriesExplorer extends React.Component { const overFieldName = get(detector, 'over_field_name'); const byFieldName = get(detector, 'by_field_name'); if (partitionFieldName !== undefined) { - const partitionFieldValue = get(entitiesState, partitionFieldName, ''); + const partitionFieldValue = get(entitiesState, partitionFieldName, null); entities.push({ fieldType: 'partition_field', fieldName: partitionFieldName, @@ -747,7 +748,7 @@ export class TimeSeriesExplorer extends React.Component { }); } if (overFieldName !== undefined) { - const overFieldValue = get(entitiesState, overFieldName, ''); + const overFieldValue = get(entitiesState, overFieldName, null); entities.push({ fieldType: 'over_field', fieldName: overFieldName, @@ -761,7 +762,7 @@ export class TimeSeriesExplorer extends React.Component { // TODO - metric data can be filtered by this field, so should only exclude // from filter for the anomaly records. if (byFieldName !== undefined && overFieldName === undefined) { - const byFieldValue = get(entitiesState, byFieldName, ''); + const byFieldValue = get(entitiesState, byFieldName, null); entities.push({ fieldType: 'by_field', fieldName: byFieldName, fieldValue: byFieldValue }); } @@ -775,7 +776,7 @@ export class TimeSeriesExplorer extends React.Component { */ getCriteriaFields(detectorIndex, entities) { // Only filter on the entity if the field has a value. - const nonBlankEntities = entities.filter(entity => entity.fieldValue.length > 0); + const nonBlankEntities = entities.filter(entity => entity.fieldValue !== null); return [ { fieldName: 'detector_index', @@ -1150,7 +1151,7 @@ export class TimeSeriesExplorer extends React.Component { {entityControls.map(entity => { const entityKey = `${entity.fieldName}`; - const forceSelection = !hasEmptyFieldValues && !entity.fieldValue; + const forceSelection = !hasEmptyFieldValues && entity.fieldValue === null; hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection; return ( { if (!interval) { - throw new Error('Interval is required to retrieve max bucket cardinalities.'); + throw Boom.badRequest('Interval is required to retrieve max bucket cardinalities.'); } const aggregatableFields = await getAggregatableFields(index, fieldNames); @@ -243,12 +260,17 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } + const { start, end } = getSafeTimeRangeForInterval( + interval, + ...Object.values(getSafeTimeRange(earliestMs, latestMs)) + ); + const cachedValues = fieldsAggsCache.getValues( index, timeFieldName, - earliestMs, - latestMs, + start, + end, 'maxBucketCardinality', fieldNames ) ?? {}; @@ -260,8 +282,6 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return cachedValues; } - const { start, end } = getSafeTimeRange(earliestMs, latestMs, interval); - const mustCriteria = [ { range: { @@ -334,6 +354,10 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return obj; }, {} as { [field: string]: number }); + fieldsAggsCache.updateValues(index, timeFieldName, start, end, { + maxBucketCardinality: aggResult, + }); + return { ...cachedValues, ...aggResult,