diff --git a/x-pack/plugins/ml/common/types/anomalies.ts b/x-pack/plugins/ml/common/types/anomalies.ts index 703d74d1bd5e5..7182c8c47f981 100644 --- a/x-pack/plugins/ml/common/types/anomalies.ts +++ b/x-pack/plugins/ml/common/types/anomalies.ts @@ -74,3 +74,5 @@ export interface AnomalyCategorizerStatsDoc { log_time: number; timestamp: number; } + +export type EntityFieldType = 'partition_field' | 'over_field' | 'by_field'; diff --git a/x-pack/plugins/ml/common/types/storage.ts b/x-pack/plugins/ml/common/types/storage.ts new file mode 100644 index 0000000000000..885288b9389e1 --- /dev/null +++ b/x-pack/plugins/ml/common/types/storage.ts @@ -0,0 +1,38 @@ +/* + * 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 { EntityFieldType } from './anomalies'; + +export const ML_ENTITY_FIELDS_CONFIG = 'ml.singleMetricViewer.partitionFields'; + +export type PartitionFieldConfig = + | { + /** + * Relevant for jobs with enabled model plot. + * If true, entity values are based on records with anomalies. + * Otherwise aggregated from the model plot results. + */ + anomalousOnly: boolean; + /** + * Relevant for jobs with disabled model plot. + * If true, entity values are filtered by the active time range. + * If false, the lists consist of the values from all existing records. + */ + applyTimeRange: boolean; + sort: { + by: 'anomaly_score' | 'name'; + order: 'asc' | 'desc'; + }; + } + | undefined; + +export type PartitionFieldsConfig = + | Partial> + | undefined; + +export type MlStorage = Partial<{ + [ML_ENTITY_FIELDS_CONFIG]: PartitionFieldsConfig; +}> | null; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index f631361d06584..4a7550788db56 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -14,6 +14,7 @@ import { SecurityPluginSetup } from '../../../../../security/public'; import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public'; import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; import { MlServicesContext } from '../../app'; +import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; interface StartPlugins { data: DataPublicPluginStart; @@ -22,6 +23,10 @@ interface StartPlugins { share: SharePluginStart; } export type StartServices = CoreStart & - StartPlugins & { appName: string; kibanaVersion: string } & MlServicesContext; + StartPlugins & { + appName: string; + kibanaVersion: string; + storage: IStorageWrapper; + } & MlServicesContext; export const useMlKibana = () => useKibana(); export type MlKibanaReactContextValue = KibanaReactContextValue; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/use_storage.ts b/x-pack/plugins/ml/public/application/contexts/ml/use_storage.ts new file mode 100644 index 0000000000000..87aa6011e6be1 --- /dev/null +++ b/x-pack/plugins/ml/public/application/contexts/ml/use_storage.ts @@ -0,0 +1,32 @@ +/* + * 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 { useCallback, useState } from 'react'; +import { useMlKibana } from '../kibana'; + +/** + * Hook for accessing and changing a value in the storage. + * @param key - Storage key + * @param initValue + */ +export function useStorage(key: string, initValue?: T): [T, (value: T) => void] { + const { + services: { storage }, + } = useMlKibana(); + + const [val, setVal] = useState(storage.get(key) ?? initValue); + + const setStorage = useCallback((value: T): void => { + try { + storage.set(key, value); + setVal(value); + } catch (e) { + throw new Error('Unable to update storage with provided value'); + } + }, []); + + return [val, setStorage]; +} diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index 65bd4fb1eccc2..d98060114b066 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -11,6 +11,7 @@ import { basePath } from './index'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; import { JOB_ID, PARTITION_FIELD_VALUE } from '../../../../common/constants/anomalies'; import { PartitionFieldsDefinition } from '../results_service/result_service_rx'; +import { PartitionFieldsConfig } from '../../../../common/types/storage'; export const resultsApiProvider = (httpService: HttpService) => ({ getAnomaliesTableData( @@ -87,9 +88,17 @@ export const resultsApiProvider = (httpService: HttpService) => ({ searchTerm: Record, criteriaFields: Array<{ fieldName: string; fieldValue: any }>, earliestMs: number, - latestMs: number + latestMs: number, + fieldsConfig?: PartitionFieldsConfig ) { - const body = JSON.stringify({ jobId, searchTerm, criteriaFields, earliestMs, latestMs }); + const body = JSON.stringify({ + jobId, + searchTerm, + criteriaFields, + earliestMs, + latestMs, + fieldsConfig, + }); return httpService.http$({ path: `${basePath()}/results/partition_fields_values`, method: 'POST', 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 e1c322910e237..4df47d76a81dc 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 @@ -31,13 +31,13 @@ export interface MetricData extends ResultResponse { export interface FieldDefinition { /** - * Partition field name. + * Field name. */ name: string | number; /** - * Partitions field distinct values. + * Field distinct values. */ - values: any[]; + values: Array<{ value: any; maxRecordScore?: number }>; } type FieldTypes = 'partition_field' | 'over_field' | 'by_field'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer.scss b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer.scss index 45678f6e71c2e..5ea4e8bd7ffbd 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer.scss +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/_timeseriesexplorer.scss @@ -37,25 +37,6 @@ } } - .series-controls { - div.entity-controls { - display: inline-block; - padding-left: $euiSize; - - input.entity-input-blank { - border-color: $euiColorDanger; - } - - .entity-input { - width: 300px; - } - } - - button { - margin-left: $euiSizeXS; - } - } - .forecast-controls { float: right; } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_config.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_config.tsx new file mode 100644 index 0000000000000..4c2e277ae9138 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_config.tsx @@ -0,0 +1,227 @@ +/* + * 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, useMemo, useState } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHorizontalRule, + EuiIcon, + EuiPopover, + EuiRadioGroup, + EuiRadioGroupOption, + EuiSwitch, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Entity } from './entity_control'; +import { UiPartitionFieldConfig } from '../series_controls/series_controls'; +import { EntityFieldType } from '../../../../../common/types/anomalies'; + +interface EntityConfigProps { + entity: Entity; + isModelPlotEnabled: boolean; + config: UiPartitionFieldConfig; + onConfigChange: (fieldType: EntityFieldType, config: Partial) => void; +} + +export const EntityConfig: FC = ({ + entity, + isModelPlotEnabled, + config, + onConfigChange, +}) => { + const [isEntityConfigPopoverOpen, setIsEntityConfigPopoverOpen] = useState(false); + + const forceSortByName = isModelPlotEnabled && !config?.anomalousOnly; + + const sortOptions: EuiRadioGroupOption[] = useMemo(() => { + return [ + { + id: 'anomaly_score', + label: i18n.translate('xpack.ml.timeSeriesExplorer.sortByScoreLabel', { + defaultMessage: 'Anomaly score', + }), + disabled: forceSortByName, + }, + { + id: 'name', + label: i18n.translate('xpack.ml.timeSeriesExplorer.sortByNameLabel', { + defaultMessage: 'Name', + }), + }, + ]; + }, [isModelPlotEnabled, config]); + + const orderOptions: EuiRadioGroupOption[] = useMemo(() => { + return [ + { + id: 'asc', + label: i18n.translate('xpack.ml.timeSeriesExplorer.ascOptionsOrderLabel', { + defaultMessage: 'asc', + }), + }, + { + id: 'desc', + label: i18n.translate('xpack.ml.timeSeriesExplorer.descOptionsOrderLabel', { + defaultMessage: 'desc', + }), + }, + ]; + }, []); + + return ( + { + setIsEntityConfigPopoverOpen(!isEntityConfigPopoverOpen); + }} + data-test-subj={`mlSingleMetricViewerEntitySelectionConfigButton_${entity.fieldName}`} + /> + } + isOpen={isEntityConfigPopoverOpen} + closePopover={() => { + setIsEntityConfigPopoverOpen(false); + }} + > +
+ + } + > + { + onConfigChange(entity.fieldType, { + sort: { + order: config.sort.order, + by: id as UiPartitionFieldConfig['sort']['by'], + }, + }); + }} + compressed + data-test-subj={`mlSingleMetricViewerEntitySelectionConfigSortBy_${entity.fieldName}`} + /> + + + } + > + { + onConfigChange(entity.fieldType, { + sort: { + by: config.sort.by, + order: id as UiPartitionFieldConfig['sort']['order'], + }, + }); + }} + compressed + data-test-subj={`mlSingleMetricViewerEntitySelectionConfigOrder_${entity.fieldName}`} + /> + + + + + + + {isModelPlotEnabled ? ( + + } + checked={config.anomalousOnly} + onChange={(e) => { + const isAnomalousOnly = e.target.checked; + onConfigChange(entity.fieldType, { + anomalousOnly: isAnomalousOnly, + sort: { + order: config.sort.order, + by: config.sort.by, + }, + }); + }} + compressed + data-test-subj={`mlSingleMetricViewerEntitySelectionConfigAnomalousOnly_${entity.fieldName}`} + /> + ) : ( + + } + checked={config.applyTimeRange} + onChange={(e) => { + const applyTimeRange = e.target.checked; + onConfigChange(entity.fieldType, { + applyTimeRange, + }); + }} + compressed + data-test-subj={`mlSingleMetricViewerEntitySelectionConfigAnomalousOnly_${entity.fieldName}`} + /> + )} + + + + {isModelPlotEnabled && !config?.anomalousOnly ? ( + + } + > + + + ) : null} + + {!isModelPlotEnabled && !config?.applyTimeRange ? ( + + } + > + + + ) : null} + + +
+
+ ); +}; 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 e1323019d61db..c16b55ec256ce 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 @@ -9,27 +9,55 @@ import React, { Component } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox, EuiComboBoxOptionOption, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexItem, + EuiFormRow, + EuiHealth, + EuiHighlight, +} from '@elastic/eui'; +import { EntityFieldType } from '../../../../../common/types/anomalies'; +import { UiPartitionFieldConfig } from '../series_controls/series_controls'; +import { getSeverityColor } from '../../../../../common'; +import { EntityConfig } from './entity_config'; export interface Entity { fieldName: string; + fieldType: EntityFieldType; fieldValue: any; - fieldValues: any; + fieldValues?: any; } -interface EntityControlProps { +/** + * Configuration for entity field dropdown options + */ +export interface FieldConfig { + isAnomalousOnly: boolean; +} + +export type ComboBoxOption = EuiComboBoxOptionOption<{ + value: string | number; + maxRecordScore?: number; +}>; + +export interface EntityControlProps { entity: Entity; - entityFieldValueChanged: (entity: Entity, fieldValue: any) => void; + entityFieldValueChanged: (entity: Entity, fieldValue: string | number | null) => void; isLoading: boolean; onSearchChange: (entity: Entity, queryTerm: string) => void; + config: UiPartitionFieldConfig; + onConfigChange: (fieldType: EntityFieldType, config: Partial) => void; forceSelection: boolean; - options: Array>; + options: ComboBoxOption[]; + isModelPlotEnabled: boolean; } interface EntityControlState { - selectedOptions: Array> | undefined; + selectedOptions: ComboBoxOption[] | undefined; isLoading: boolean; - options: Array> | undefined; + options: ComboBoxOption[] | undefined; + isEntityConfigPopoverOpen: boolean; } export const EMPTY_FIELD_VALUE_LABEL = i18n.translate( @@ -46,6 +74,7 @@ export class EntityControl extends Component> | undefined = selectedOptions; + let selectedOptionsUpdate: ComboBoxOption[] | undefined = selectedOptions; if ( (selectedOptions === undefined && fieldValue !== null) || (Array.isArray(selectedOptions) && @@ -87,17 +116,36 @@ export class EntityControl extends Component>) => { + onChange = (selectedOptions: ComboBoxOption[]) => { const options = selectedOptions.length > 0 ? selectedOptions : undefined; this.setState({ selectedOptions: options, }); const fieldValue = - Array.isArray(options) && options[0].value !== null ? options[0].value : null; + Array.isArray(options) && options[0].value?.value !== null + ? options[0].value?.value ?? null + : null; this.props.entityFieldValueChanged(this.props.entity, fieldValue); }; + onManualInput = (inputValue: string) => { + const normalizedSearchValue = inputValue.trim().toLowerCase(); + if (!normalizedSearchValue) { + return; + } + const manualInputValue: ComboBoxOption = { + label: inputValue, + value: { + value: inputValue, + }, + }; + this.setState({ + selectedOptions: [manualInputValue], + }); + this.props.entityFieldValueChanged(this.props.entity, inputValue); + }; + onSearchChange = (searchValue: string) => { this.setState({ isLoading: true, @@ -106,13 +154,19 @@ export class EntityControl extends Component { - const { label } = option; - return label === EMPTY_FIELD_VALUE_LABEL ? {label} : label; + renderOption = (option: ComboBoxOption, searchValue: string) => { + const highlightedLabel = {option.label}; + return option.value?.maxRecordScore ? ( + + {highlightedLabel} + + ) : ( + highlightedLabel + ); }; render() { - const { entity, forceSelection } = this.props; + const { entity, forceSelection, isModelPlotEnabled, config, onConfigChange } = this.props; const { isLoading, options, selectedOptions } = this.state; const control = ( @@ -129,6 +183,10 @@ export class EntityControl extends Component + } /> ); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/index.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/index.ts new file mode 100644 index 0000000000000..a60b60fc83911 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_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 { SeriesControls } from './series_controls'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx new file mode 100644 index 0000000000000..55c7bcbef68be --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx @@ -0,0 +1,314 @@ +/* + * 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, useCallback, useEffect, useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSelectProps } from '@elastic/eui'; +import { debounce } from 'lodash'; +import { EntityControl } from '../entity_control'; +import { mlJobService } from '../../../services/job_service'; +import { Detector, JobId } from '../../../../../common/types/anomaly_detection_jobs'; +import { useMlKibana } from '../../../contexts/kibana'; +import { APP_STATE_ACTION } from '../../timeseriesexplorer_constants'; +import { + ComboBoxOption, + EMPTY_FIELD_VALUE_LABEL, + EntityControlProps, +} from '../entity_control/entity_control'; +import { getControlsForDetector } from '../../get_controls_for_detector'; +// @ts-ignore +import { getViewableDetectors } from '../../timeseriesexplorer'; +import { + ML_ENTITY_FIELDS_CONFIG, + PartitionFieldConfig, + PartitionFieldsConfig, +} from '../../../../../common/types/storage'; +import { useStorage } from '../../../contexts/ml/use_storage'; +import { EntityFieldType } from '../../../../../common/types/anomalies'; +import { FieldDefinition } from '../../../services/results_service/result_service_rx'; + +function getEntityControlOptions(fieldValues: FieldDefinition['values']): ComboBoxOption[] { + if (!Array.isArray(fieldValues)) { + return []; + } + + return fieldValues.map((value) => { + return { label: value.value === '' ? EMPTY_FIELD_VALUE_LABEL : value.value, value }; + }); +} + +export type UiPartitionFieldsConfig = Exclude; + +export type UiPartitionFieldConfig = Exclude; + +/** + * Provides default fields configuration. + */ +const getDefaultFieldConfig = ( + fieldTypes: EntityFieldType[], + isAnomalousOnly: boolean, + applyTimeRange: boolean +): UiPartitionFieldsConfig => { + return fieldTypes.reduce((acc, f) => { + acc[f] = { + applyTimeRange, + anomalousOnly: isAnomalousOnly, + sort: { by: 'anomaly_score', order: 'desc' }, + }; + return acc; + }, {} as UiPartitionFieldsConfig); +}; + +interface SeriesControlsProps { + selectedDetectorIndex: any; + selectedJobId: JobId; + bounds: any; + appStateHandler: Function; + selectedEntities: Record; +} + +/** + * Component for handling the detector and entities controls. + */ +export const SeriesControls: FC = ({ + bounds, + selectedDetectorIndex, + selectedJobId, + appStateHandler, + children, + selectedEntities, +}) => { + const { + services: { + mlServices: { + mlApiServices: { results: mlResultsService }, + }, + }, + } = useMlKibana(); + + const selectedJob = useMemo(() => mlJobService.getJob(selectedJobId), [selectedJobId]); + + const isModelPlotEnabled = !!selectedJob.model_plot_config?.enabled; + + const [entitiesLoading, setEntitiesLoading] = useState(false); + const [entityValues, setEntityValues] = useState>({}); + + const detectors: Array<{ + index: number; + detector_description: Detector['detector_description']; + }> = useMemo(() => { + return getViewableDetectors(selectedJob); + }, [selectedJob]); + + const entityControls = useMemo(() => { + return getControlsForDetector(selectedDetectorIndex, selectedEntities, selectedJobId); + }, [selectedDetectorIndex, selectedEntities, selectedJobId]); + + const [storageFieldsConfig, setStorageFieldsConfig] = useStorage( + ML_ENTITY_FIELDS_CONFIG + ); + + // Merge the default config with the one from the local storage + const resultFieldsConfig = useMemo(() => { + return { + ...getDefaultFieldConfig( + entityControls.map((v) => v.fieldType), + !storageFieldsConfig + ? true + : Object.values(storageFieldsConfig).some((v) => !!v?.anomalousOnly), + !storageFieldsConfig + ? true + : Object.values(storageFieldsConfig).some((v) => !!v?.applyTimeRange) + ), + ...(!storageFieldsConfig ? {} : storageFieldsConfig), + }; + }, [entityControls, storageFieldsConfig]); + + /** + * Loads available entity values. + * @param {Object} searchTerm - Search term for partition, e.g. { partition_field: 'partition' } + */ + const loadEntityValues = async (searchTerm = {}) => { + setEntitiesLoading(true); + + // Populate the entity input data lists with the values from the top records by score + // for the selected detector across the full time range. No need to pass through finish(). + const detectorIndex = selectedDetectorIndex; + + const fieldsConfig = resultFieldsConfig + ? Object.fromEntries( + Object.entries(resultFieldsConfig).filter(([k]) => + entityControls.some((v) => v.fieldType === k) + ) + ) + : undefined; + + const { + partition_field: partitionField, + over_field: overField, + by_field: byField, + } = await mlResultsService + .fetchPartitionFieldsValues( + selectedJob.job_id, + searchTerm, + [ + { + fieldName: 'detector_index', + fieldValue: detectorIndex, + }, + ], + bounds.min.valueOf(), + bounds.max.valueOf(), + fieldsConfig + ) + .toPromise(); + + const entityValuesUpdate: Record = {}; + entityControls.forEach((entity) => { + let fieldValues; + + if (partitionField?.name === entity.fieldName) { + fieldValues = partitionField.values; + } + if (overField?.name === entity.fieldName) { + fieldValues = overField.values; + } + if (byField?.name === entity.fieldName) { + fieldValues = byField.values; + } + entityValuesUpdate[entity.fieldName] = fieldValues; + }); + + setEntitiesLoading(false); + setEntityValues(entityValuesUpdate); + }; + + useEffect(() => { + loadEntityValues(); + }, [selectedJobId, selectedDetectorIndex, JSON.stringify(selectedEntities), resultFieldsConfig]); + + const entityFieldSearchChanged = debounce(async (entity, queryTerm) => { + await loadEntityValues({ + [entity.fieldType]: queryTerm, + }); + }, 500); + + const entityFieldValueChanged: EntityControlProps['entityFieldValueChanged'] = ( + entity, + fieldValue + ) => { + const resultEntities = { + ...entityControls.reduce((appStateEntities, appStateEntity) => { + appStateEntities[appStateEntity.fieldName] = appStateEntity.fieldValue; + return appStateEntities; + }, {} as Record), + [entity.fieldName]: fieldValue, + }; + + appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities); + }; + + const detectorIndexChangeHandler: EuiSelectProps['onChange'] = useCallback( + (e) => { + const id = e.target.value; + if (id !== undefined) { + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +id); + } + }, + [appStateHandler] + ); + + const detectorSelectOptions = detectors.map((d) => ({ + value: d.index, + text: d.detector_description, + })); + + const onFieldConfigChange: EntityControlProps['onConfigChange'] = useCallback( + (fieldType, config) => { + const updatedFieldConfig = { + ...(resultFieldsConfig[fieldType] ? resultFieldsConfig[fieldType] : {}), + ...config, + } as UiPartitionFieldConfig; + + const updatedResultConfig = { ...resultFieldsConfig }; + + if (resultFieldsConfig[fieldType]?.anomalousOnly !== updatedFieldConfig.anomalousOnly) { + // In case anomalous selector has been changed + // we need to change it for all the other fields + for (const c in updatedResultConfig) { + if (updatedResultConfig.hasOwnProperty(c)) { + updatedResultConfig[c as EntityFieldType]!.anomalousOnly = + updatedFieldConfig.anomalousOnly; + } + } + } + + if (resultFieldsConfig[fieldType]?.applyTimeRange !== updatedFieldConfig.applyTimeRange) { + // In case time range selector has been changed + // we need to change it for all the other fields + for (const c in updatedResultConfig) { + if (updatedResultConfig.hasOwnProperty(c)) { + updatedResultConfig[c as EntityFieldType]!.applyTimeRange = + updatedFieldConfig.applyTimeRange; + } + } + } + + setStorageFieldsConfig({ + ...updatedResultConfig, + [fieldType]: updatedFieldConfig, + }); + }, + [resultFieldsConfig, setStorageFieldsConfig] + ); + + /** Indicates if any of the previous controls is empty */ + let hasEmptyFieldValues = false; + + return ( +
+ + + + } + > + + + + {entityControls.map((entity) => { + const entityKey = `${entity.fieldName}`; + const forceSelection = !hasEmptyFieldValues && entity.fieldValue === null; + hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection; + return ( + + ); + })} + {children} + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts new file mode 100644 index 0000000000000..1461faf335db2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts @@ -0,0 +1,61 @@ +/* + * 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 { mlJobService } from '../services/job_service'; +import { Entity } from './components/entity_control/entity_control'; +import { JobId } from '../../../common/types/anomaly_detection_jobs'; + +/** + * Extracts entities from the detector configuration + */ +export function getControlsForDetector( + selectedDetectorIndex: number, + selectedEntities: Record, + selectedJobId: JobId +) { + const selectedJob = mlJobService.getJob(selectedJobId); + + const entities: Entity[] = []; + + if (selectedJob === undefined) { + return entities; + } + + // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. + const detectorIndex = selectedDetectorIndex; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + + const entitiesState = selectedEntities; + const partitionFieldName = detector?.partition_field_name; + const overFieldName = detector?.over_field_name; + const byFieldName = detector?.by_field_name; + if (partitionFieldName !== undefined) { + const partitionFieldValue = entitiesState?.[partitionFieldName] ?? null; + entities.push({ + fieldType: 'partition_field', + fieldName: partitionFieldName, + fieldValue: partitionFieldValue, + }); + } + if (overFieldName !== undefined) { + const overFieldValue = entitiesState?.[overFieldName] ?? null; + entities.push({ + fieldType: 'over_field', + fieldName: overFieldName, + fieldValue: overFieldValue, + }); + } + + // For jobs with by and over fields, don't add the 'by' field as this + // field will only be added to the top-level fields for record type results + // if it also an influencer over the bucket. + if (byFieldName !== undefined && overFieldName === undefined) { + const byFieldValue = entitiesState?.[byFieldName] ?? null; + entities.push({ fieldType: 'by_field', fieldName: byFieldName, fieldValue: byFieldValue }); + } + + return entities; +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 9b8764d3f9279..33f2637d302c4 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -8,7 +8,7 @@ * React component for rendering Single Metric Viewer. */ -import { debounce, each, find, get, has, isEqual } from 'lodash'; +import { each, find, get, has, isEqual } from 'lodash'; import moment from 'moment-timezone'; import { Subject, Subscription, forkJoin } from 'rxjs'; import { map, debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators'; @@ -25,7 +25,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiSelect, EuiSpacer, EuiPanel, EuiTitle, @@ -49,7 +48,6 @@ 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 { EntityControl } from './components/entity_control'; 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'; @@ -82,8 +80,9 @@ import { processRecordScoreResults, getFocusData, } from './timeseriesexplorer_utils'; -import { EMPTY_FIELD_VALUE_LABEL } from './components/entity_control/entity_control'; import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; +import { getControlsForDetector } from './get_controls_for_detector'; +import { SeriesControls } from './components/series_controls'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -92,19 +91,7 @@ const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionV defaultMessage: 'all', }); -function getEntityControlOptions(fieldValues) { - if (!Array.isArray(fieldValues)) { - return []; - } - - fieldValues.sort(); - - return fieldValues.map((value) => { - return { label: value === '' ? EMPTY_FIELD_VALUE_LABEL : value, value }; - }); -} - -function getViewableDetectors(selectedJob) { +export function getViewableDetectors(selectedJob) { const jobDetectors = selectedJob.analysis_config.detectors; const viewableDetectors = []; each(jobDetectors, (dtr, index) => { @@ -212,14 +199,6 @@ export class TimeSeriesExplorer extends React.Component { return fieldNamesWithEmptyValues.length === 0; }; - detectorIndexChangeHandler = (e) => { - const { appStateHandler } = this.props; - const id = e.target.value; - if (id !== undefined) { - appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +id); - } - }; - toggleShowAnnotationsHandler = () => { this.setState((prevState) => ({ showAnnotations: !prevState.showAnnotations, @@ -335,28 +314,6 @@ export class TimeSeriesExplorer extends React.Component { this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); }; - entityFieldValueChanged = (entity, fieldValue) => { - const { appStateHandler } = this.props; - const entityControls = this.getControlsForDetector(); - - const resultEntities = { - ...entityControls.reduce((appStateEntities, appStateEntity) => { - appStateEntities[appStateEntity.fieldName] = appStateEntity.fieldValue; - return appStateEntities; - }, {}), - [entity.fieldName]: fieldValue, - }; - - appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities); - }; - - entityFieldSearchChanged = debounce((entity, queryTerm) => { - const entityControls = this.getControlsForDetector(); - this.loadEntityValues(entityControls, { - [entity.fieldType]: queryTerm, - }); - }, 500); - loadAnomaliesTableData = (earliestMs, latestMs) => { const { dateFormatTz, @@ -421,59 +378,6 @@ export class TimeSeriesExplorer extends React.Component { ); }; - /** - * Loads available entity values. - * @param {Array} entities - Entity controls configuration - * @param {Object} searchTerm - Search term for partition, e.g. { partition_field: 'partition' } - */ - loadEntityValues = async (entities, searchTerm = {}) => { - this.setState({ entitiesLoading: true }); - - const { bounds, selectedJobId, selectedDetectorIndex } = this.props; - const selectedJob = mlJobService.getJob(selectedJobId); - - // Populate the entity input datalists with the values from the top records by score - // for the selected detector across the full time range. No need to pass through finish(). - const detectorIndex = selectedDetectorIndex; - - const { - partition_field: partitionField, - over_field: overField, - by_field: byField, - } = await mlResultsService - .fetchPartitionFieldsValues( - selectedJob.job_id, - searchTerm, - [ - { - fieldName: 'detector_index', - fieldValue: detectorIndex, - }, - ], - bounds.min.valueOf(), - bounds.max.valueOf() - ) - .toPromise(); - - const entityValues = {}; - entities.forEach((entity) => { - let fieldValues; - - if (partitionField?.name === entity.fieldName) { - fieldValues = partitionField.values; - } - if (overField?.name === entity.fieldName) { - fieldValues = overField.values; - } - if (byField?.name === entity.fieldName) { - fieldValues = byField.values; - } - entityValues[entity.fieldName] = fieldValues; - }); - - this.setState({ entitiesLoading: false, entityValues }); - }; - setForecastId = (forecastId) => { this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); }; @@ -728,50 +632,7 @@ export class TimeSeriesExplorer extends React.Component { */ getControlsForDetector = () => { const { selectedDetectorIndex, selectedEntities, selectedJobId } = this.props; - const selectedJob = mlJobService.getJob(selectedJobId); - - const entities = []; - - if (selectedJob === undefined) { - return entities; - } - - // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. - const detectorIndex = selectedDetectorIndex; - const detector = selectedJob.analysis_config.detectors[detectorIndex]; - - const entitiesState = selectedEntities; - const partitionFieldName = get(detector, 'partition_field_name'); - const overFieldName = get(detector, 'over_field_name'); - const byFieldName = get(detector, 'by_field_name'); - if (partitionFieldName !== undefined) { - const partitionFieldValue = get(entitiesState, partitionFieldName, null); - entities.push({ - fieldType: 'partition_field', - fieldName: partitionFieldName, - fieldValue: partitionFieldValue, - }); - } - if (overFieldName !== undefined) { - const overFieldValue = get(entitiesState, overFieldName, null); - entities.push({ - fieldType: 'over_field', - fieldName: overFieldName, - fieldValue: overFieldValue, - }); - } - - // For jobs with by and over fields, don't add the 'by' field as this - // field will only be added to the top-level fields for record type results - // if it also an influencer over the bucket. - // 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, null); - entities.push({ fieldType: 'by_field', fieldName: byFieldName, fieldValue: byFieldValue }); - } - - return entities; + return getControlsForDetector(selectedDetectorIndex, selectedEntities, selectedJobId); }; /** @@ -957,16 +818,6 @@ 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) - ) { - const entityControls = this.getControlsForDetector(); - this.loadEntityValues(entityControls); - } - if ( previousProps === undefined || previousProps.selectedForecastId !== this.props.selectedForecastId @@ -1044,7 +895,6 @@ export class TimeSeriesExplorer extends React.Component { contextChartData, contextForecastData, dataNotChartable, - entityValues, focusAggregationInterval, focusAnnotationError, focusAnnotationData, @@ -1100,10 +950,6 @@ export class TimeSeriesExplorer extends React.Component { const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues(); const arePartitioningFieldsProvided = this.arePartitioningFieldsProvided(); const detectors = getViewableDetectors(selectedJob); - const detectorSelectOptions = detectors.map((d) => ({ - value: d.index, - text: d.detector_description, - })); let renderFocusChartOnly = true; @@ -1124,12 +970,6 @@ export class TimeSeriesExplorer extends React.Component { this.previousShowForecast = showForecast; this.previousShowModelBounds = showModelBounds; - /** - * Indicates if any of the previous controls is empty. - * @type {boolean} - */ - let hasEmptyFieldValues = false; - return ( {fieldNamesWithEmptyValues.length > 0 && ( @@ -1154,53 +994,27 @@ export class TimeSeriesExplorer extends React.Component { )} -
- - - - + {arePartitioningFieldsProvided && ( + + + - {entityControls.map((entity) => { - const entityKey = `${entity.fieldName}`; - const forceSelection = !hasEmptyFieldValues && entity.fieldValue === null; - hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection; - return ( - - ); - })} - {arePartitioningFieldsProvided && ( - - - - - - )} - -
+ )} + diff --git a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts index eb2c6b461a9df..876125433d794 100644 --- a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts +++ b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts @@ -10,6 +10,8 @@ import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; import { PartitionFieldsType } from '../../../common/types/anomalies'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { CriteriaField } from './results_service'; +import { FieldConfig, FieldsConfig } from '../../routes/schemas/results_service_schema'; +import { Job } from '../../../common/types/anomaly_detection_jobs'; type SearchTerm = | { @@ -20,15 +22,25 @@ type SearchTerm = /** * Gets an object for aggregation query to retrieve field name and values. * @param fieldType - Field type + * @param isModelPlotSearch * @param query - Optional query string for partition value + * @param fieldConfig - Optional config for filtering and sorting * @returns {Object} */ -function getFieldAgg(fieldType: PartitionFieldsType, query?: string) { +function getFieldAgg( + fieldType: PartitionFieldsType, + isModelPlotSearch: boolean, + query?: string, + fieldConfig?: FieldConfig +) { const AGG_SIZE = 100; const fieldNameKey = `${fieldType}_name`; const fieldValueKey = `${fieldType}_value`; + const sortByField = + fieldConfig?.sort?.by === 'name' || isModelPlotSearch ? '_key' : 'maxRecordScore'; + return { [fieldNameKey]: { terms: { @@ -37,10 +49,31 @@ function getFieldAgg(fieldType: PartitionFieldsType, query?: string) { }, [fieldValueKey]: { filter: { - wildcard: { - [fieldValueKey]: { - value: query ? `*${query}*` : '*', - }, + bool: { + must: [ + ...(query + ? [ + { + wildcard: { + [fieldValueKey]: { + value: `*${query}*`, + }, + }, + }, + ] + : []), + ...(fieldConfig?.anomalousOnly + ? [ + { + range: { + record_score: { + gt: 0, + }, + }, + }, + ] + : []), + ], }, }, aggs: { @@ -48,7 +81,25 @@ function getFieldAgg(fieldType: PartitionFieldsType, query?: string) { terms: { size: AGG_SIZE, field: fieldValueKey, + ...(fieldConfig?.sort + ? { + order: { + [sortByField]: fieldConfig.sort.order ?? 'desc', + }, + } + : {}), }, + ...(isModelPlotSearch + ? {} + : { + aggs: { + maxRecordScore: { + max: { + field: 'record_score', + }, + }, + }, + }), }, }, }, @@ -68,7 +119,10 @@ function getFieldObject(fieldType: PartitionFieldsType, aggs: any) { ? { [fieldType]: { name: aggs[fieldNameKey].buckets[0].key, - values: aggs[fieldValueKey].values.buckets.map(({ key }: any) => key), + values: aggs[fieldValueKey].values.buckets.map(({ key, maxRecordScore }: any) => ({ + value: key, + ...(maxRecordScore ? { maxRecordScore: maxRecordScore.value } : {}), + })), }, } : {}; @@ -82,68 +136,94 @@ export const getPartitionFieldsValuesFactory = ({ asInternalUser }: IScopedClust * @param criteriaFields - key - value pairs of the term field, e.g. { detector_index: 0 } * @param earliestMs * @param latestMs + * @param fieldsConfig */ async function getPartitionFieldsValues( jobId: string, searchTerm: SearchTerm = {}, criteriaFields: CriteriaField[], earliestMs: number, - latestMs: number + latestMs: number, + fieldsConfig: FieldsConfig = {} ) { const { body: jobsResponse } = await asInternalUser.ml.getJobs({ job_id: jobId }); if (jobsResponse.count === 0 || jobsResponse.jobs === undefined) { throw Boom.notFound(`Job with the id "${jobId}" not found`); } - const job = jobsResponse.jobs[0]; + const job: Job = jobsResponse.jobs[0]; const isModelPlotEnabled = job?.model_plot_config?.enabled; + const isAnomalousOnly = (Object.entries(fieldsConfig) as Array<[string, FieldConfig]>).some( + ([k, v]) => { + return !!v?.anomalousOnly; + } + ); - const { body } = await asInternalUser.search({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - ...criteriaFields.map(({ fieldName, fieldValue }) => { - return { - term: { - [fieldName]: fieldValue, - }, - }; - }), - { + const applyTimeRange = (Object.entries(fieldsConfig) as Array<[string, FieldConfig]>).some( + ([k, v]) => { + return !!v?.applyTimeRange; + } + ); + + const isModelPlotSearch = !!isModelPlotEnabled && !isAnomalousOnly; + + // Remove the time filter in case model plot is not enabled + // and time range is not applied, so + // it includes the records that occurred as anomalies historically + const searchAllTime = !isModelPlotEnabled && !applyTimeRange; + + const requestBody = { + query: { + bool: { + filter: [ + ...criteriaFields.map(({ fieldName, fieldValue }) => { + return { term: { - job_id: jobId, + [fieldName]: fieldValue, }, + }; + }), + { + term: { + job_id: jobId, }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + }, + ...(searchAllTime + ? [] + : [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, + ]), + { + term: { + result_type: isModelPlotSearch ? 'model_plot' : 'record', }, - { - term: { - result_type: isModelPlotEnabled ? 'model_plot' : 'record', - }, - }, - ], - }, - }, - aggs: { - ...PARTITION_FIELDS.reduce((acc, key) => { - return { - ...acc, - ...getFieldAgg(key, searchTerm[key]), - }; - }, {}), + }, + ], }, }, + aggs: { + ...PARTITION_FIELDS.reduce((acc, key) => { + return { + ...acc, + ...getFieldAgg(key, isModelPlotSearch, searchTerm[key], fieldsConfig[key]), + }; + }, {}), + }, + }; + + const { body } = await asInternalUser.search({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: requestBody, }); return PARTITION_FIELDS.reduce((acc, key) => { diff --git a/x-pack/plugins/ml/server/routes/results_service.ts b/x-pack/plugins/ml/server/routes/results_service.ts index 4e34320d51333..c9d6fce86deb0 100644 --- a/x-pack/plugins/ml/server/routes/results_service.ts +++ b/x-pack/plugins/ml/server/routes/results_service.ts @@ -72,8 +72,15 @@ function getMaxAnomalyScore(client: IScopedClusterClient, payload: any) { function getPartitionFieldsValues(client: IScopedClusterClient, payload: any) { const rs = resultsServiceProvider(client); - const { jobId, searchTerm, criteriaFields, earliestMs, latestMs } = payload; - return rs.getPartitionFieldsValues(jobId, searchTerm, criteriaFields, earliestMs, latestMs); + const { jobId, searchTerm, criteriaFields, earliestMs, latestMs, fieldsConfig } = payload; + return rs.getPartitionFieldsValues( + jobId, + searchTerm, + criteriaFields, + earliestMs, + latestMs, + fieldsConfig + ); } function getCategorizerStats(client: IScopedClusterClient, params: any, query: any) { 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 0bf37826b6146..2b5c8c8338c72 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; const criteriaFieldSchema = schema.object({ fieldType: schema.maybe(schema.string()), @@ -45,14 +45,35 @@ export const categoryExamplesSchema = schema.object({ maxExamples: schema.number(), }); +const fieldConfig = schema.maybe( + schema.object({ + applyTimeRange: schema.maybe(schema.boolean()), + anomalousOnly: schema.maybe(schema.boolean()), + sort: schema.object({ + by: schema.string(), + order: schema.maybe(schema.string()), + }), + }) +); + export const partitionFieldValuesSchema = schema.object({ jobId: schema.string(), searchTerm: schema.maybe(schema.any()), criteriaFields: schema.arrayOf(criteriaFieldSchema), earliestMs: schema.number(), latestMs: schema.number(), + fieldsConfig: schema.maybe( + schema.object({ + partition_field: fieldConfig, + over_field: fieldConfig, + by_field: fieldConfig, + }) + ), }); +export type FieldsConfig = TypeOf['fieldsConfig']; +export type FieldConfig = TypeOf; + export const getCategorizerStatsSchema = schema.nullable( schema.object({ /** diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts b/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts index 5353f53e74d0b..a1517020b6e09 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts @@ -70,7 +70,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.jobSelection.assertJobSelection([JOB_CONFIG.job_id]); await ml.testExecution.logTestStep('pre-fills the detector input'); - await ml.singleMetricViewer.assertDetectorInputExsist(); + await ml.singleMetricViewer.assertDetectorInputExist(); await ml.singleMetricViewer.assertDetectorInputValue('0'); await ml.testExecution.logTestStep('should display the annotations section showing an error'); diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_viewer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_viewer.ts index 1dc4708c57dbc..6eac71dd05a98 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_viewer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_viewer.ts @@ -39,51 +39,166 @@ export default function ({ getService }: FtrProviderContext) { describe('single metric viewer', function () { this.tags(['mlqa']); - before(async () => { - await esArchiver.loadIfNeeded('ml/farequote'); - await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); - await ml.testResources.setKibanaTimeZoneToUTC(); - await ml.api.createAndRunAnomalyDetectionLookbackJob(JOB_CONFIG, DATAFEED_CONFIG); - await ml.securityUI.loginAsMlPowerUser(); - }); + describe('with single metric job', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.setKibanaTimeZoneToUTC(); - after(async () => { - await ml.api.cleanMlIndices(); - }); + await ml.api.createAndRunAnomalyDetectionLookbackJob(JOB_CONFIG, DATAFEED_CONFIG); + await ml.securityUI.loginAsMlPowerUser(); + }); - it('opens a job from job list link', async () => { - await ml.testExecution.logTestStep('navigate to job list'); - await ml.navigation.navigateToMl(); - await ml.navigation.navigateToJobManagement(); + after(async () => { + await ml.api.cleanMlIndices(); + }); - await ml.testExecution.logTestStep('open job in single metric viewer'); - await ml.jobTable.waitForJobsToLoad(); - await ml.jobTable.filterWithSearchString(JOB_CONFIG.job_id, 1); + it('opens a job from job list link', async () => { + await ml.testExecution.logTestStep('navigate to job list'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToJobManagement(); - await ml.jobTable.clickOpenJobInSingleMetricViewerButton(JOB_CONFIG.job_id); - await ml.commonUI.waitForMlLoadingIndicatorToDisappear(); - }); + await ml.testExecution.logTestStep('open job in single metric viewer'); + await ml.jobTable.waitForJobsToLoad(); + await ml.jobTable.filterWithSearchString(JOB_CONFIG.job_id, 1); - it('displays job results', async () => { - await ml.testExecution.logTestStep('pre-fills the job selection'); - await ml.jobSelection.assertJobSelection([JOB_CONFIG.job_id]); + await ml.jobTable.clickOpenJobInSingleMetricViewerButton(JOB_CONFIG.job_id); + await ml.commonUI.waitForMlLoadingIndicatorToDisappear(); + }); - await ml.testExecution.logTestStep('pre-fills the detector input'); - await ml.singleMetricViewer.assertDetectorInputExsist(); - await ml.singleMetricViewer.assertDetectorInputValue('0'); + it('displays job results', async () => { + await ml.testExecution.logTestStep('pre-fills the job selection'); + await ml.jobSelection.assertJobSelection([JOB_CONFIG.job_id]); - await ml.testExecution.logTestStep('displays the chart'); - await ml.singleMetricViewer.assertChartExsist(); + await ml.testExecution.logTestStep('pre-fills the detector input'); + await ml.singleMetricViewer.assertDetectorInputExist(); + await ml.singleMetricViewer.assertDetectorInputValue('0'); - await ml.testExecution.logTestStep('should display the annotations section'); - await ml.singleMetricViewer.assertAnnotationsExists('loaded'); + await ml.testExecution.logTestStep('displays the chart'); + await ml.singleMetricViewer.assertChartExist(); - await ml.testExecution.logTestStep('displays the anomalies table'); - await ml.anomaliesTable.assertTableExists(); + await ml.testExecution.logTestStep('should display the annotations section'); + await ml.singleMetricViewer.assertAnnotationsExists('loaded'); + + await ml.testExecution.logTestStep('displays the anomalies table'); + await ml.anomaliesTable.assertTableExists(); + + await ml.testExecution.logTestStep('anomalies table is not empty'); + await ml.anomaliesTable.assertTableNotEmpty(); + }); + }); - await ml.testExecution.logTestStep('anomalies table is not empty'); - await ml.anomaliesTable.assertTableNotEmpty(); + describe('with entity fields', function () { + const jobConfig: Job = { + job_id: `ecom_01`, + description: + 'mean(taxless_total_price) over "geoip.city_name" partitionfield=day_of_week on ecommerce dataset with 15m bucket span', + groups: ['ecommerce', 'automated', 'advanced'], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: + 'mean(taxless_total_price) over "geoip.city_name" partitionfield=day_of_week', + function: 'mean', + field_name: 'taxless_total_price', + over_field_name: 'geoip.city_name', + partition_field_name: 'day_of_week', + detector_index: 0, + }, + ], + influencers: ['day_of_week'], + }, + data_description: { + time_field: 'order_date', + time_format: 'epoch_ms', + }, + analysis_limits: { + model_memory_limit: '11mb', + categorization_examples_limit: 4, + }, + model_plot_config: { enabled: true }, + }; + + const datafeedConfig: Datafeed = { + datafeed_id: 'datafeed-ecom_01', + indices: ['ft_ecommerce'], + job_id: 'ecom_01', + query: { bool: { must: [{ match_all: {} }] } }, + }; + + before(async () => { + await esArchiver.loadIfNeeded('ml/ecommerce'); + await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.api.createAndRunAnomalyDetectionLookbackJob(jobConfig, datafeedConfig); + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + it('opens a job from job list link', async () => { + await ml.testExecution.logTestStep('navigate to job list'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToJobManagement(); + + await ml.testExecution.logTestStep('open job in single metric viewer'); + await ml.jobTable.waitForJobsToLoad(); + await ml.jobTable.filterWithSearchString(jobConfig.job_id, 1); + + await ml.jobTable.clickOpenJobInSingleMetricViewerButton(jobConfig.job_id); + await ml.commonUI.waitForMlLoadingIndicatorToDisappear(); + }); + + it('render entity control', async () => { + await ml.testExecution.logTestStep('pre-fills the detector input'); + await ml.singleMetricViewer.assertDetectorInputExist(); + await ml.singleMetricViewer.assertDetectorInputValue('0'); + + await ml.testExecution.logTestStep('should input entities values'); + await ml.singleMetricViewer.assertEntityInputExist('day_of_week'); + await ml.singleMetricViewer.assertEntityInputSelection('day_of_week', []); + await ml.singleMetricViewer.selectEntityValue('day_of_week', 'Friday'); + await ml.singleMetricViewer.assertEntityInputExist('geoip.city_name'); + await ml.singleMetricViewer.assertEntityInputSelection('geoip.city_name', []); + await ml.singleMetricViewer.selectEntityValue('geoip.city_name', 'Abu Dhabi'); + + // TODO if placed before combobox update, tests fail to update combobox values + await ml.testExecution.logTestStep('assert the default state of entity configs'); + await ml.singleMetricViewer.assertEntityConfig( + 'day_of_week', + true, + 'anomaly_score', + 'desc' + ); + + await ml.singleMetricViewer.assertEntityConfig( + 'geoip.city_name', + true, + 'anomaly_score', + 'desc' + ); + + await ml.testExecution.logTestStep('modify the entity config'); + await ml.singleMetricViewer.setEntityConfig('geoip.city_name', false, 'name', 'asc'); + + // Make sure anomalous only control has been synced. + // Also sorting by name is enforced because the model plot is enabled + // and anomalous only is disabled + await ml.singleMetricViewer.assertEntityConfig('day_of_week', false, 'name', 'desc'); + + await ml.testExecution.logTestStep('displays the chart'); + await ml.singleMetricViewer.assertChartExist(); + + await ml.testExecution.logTestStep('displays the anomalies table'); + await ml.anomaliesTable.assertTableExists(); + + await ml.testExecution.logTestStep('anomalies table is not empty'); + await ml.anomaliesTable.assertTableNotEmpty(); + }); }); }); } diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index c3dde872fa4a6..a10a5a59c01ad 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -192,16 +192,16 @@ export default function ({ getService }: FtrProviderContext) { await ml.jobSelection.assertJobSelection([adJobId]); await ml.testExecution.logTestStep('should pre-fill the detector input'); - await ml.singleMetricViewer.assertDetectorInputExsist(); + await ml.singleMetricViewer.assertDetectorInputExist(); await ml.singleMetricViewer.assertDetectorInputValue('0'); await ml.testExecution.logTestStep('should input the airline entity value'); - await ml.singleMetricViewer.assertEntityInputExsist('airline'); + await ml.singleMetricViewer.assertEntityInputExist('airline'); await ml.singleMetricViewer.assertEntityInputSelection('airline', []); await ml.singleMetricViewer.selectEntityValue('airline', 'AAL'); await ml.testExecution.logTestStep('should display the chart'); - await ml.singleMetricViewer.assertChartExsist(); + await ml.singleMetricViewer.assertChartExist(); await ml.testExecution.logTestStep('should display the annotations section'); await ml.singleMetricViewer.assertAnnotationsExists('loaded'); diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index cb964995511ef..b849877913a30 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -185,16 +185,16 @@ export default function ({ getService }: FtrProviderContext) { await ml.jobSelection.assertJobSelection([adJobId]); await ml.testExecution.logTestStep('should pre-fill the detector input'); - await ml.singleMetricViewer.assertDetectorInputExsist(); + await ml.singleMetricViewer.assertDetectorInputExist(); await ml.singleMetricViewer.assertDetectorInputValue('0'); await ml.testExecution.logTestStep('should input the airline entity value'); - await ml.singleMetricViewer.assertEntityInputExsist('airline'); + await ml.singleMetricViewer.assertEntityInputExist('airline'); await ml.singleMetricViewer.assertEntityInputSelection('airline', []); await ml.singleMetricViewer.selectEntityValue('airline', 'AAL'); await ml.testExecution.logTestStep('should display the chart'); - await ml.singleMetricViewer.assertChartExsist(); + await ml.singleMetricViewer.assertChartExist(); await ml.testExecution.logTestStep('should display the annotations section'); await ml.singleMetricViewer.assertAnnotationsExists('loaded'); diff --git a/x-pack/test/functional/services/ml/common_ui.ts b/x-pack/test/functional/services/ml/common_ui.ts index c3ee05db599a9..ef2fccab8a2cc 100644 --- a/x-pack/test/functional/services/ml/common_ui.ts +++ b/x-pack/test/functional/services/ml/common_ui.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect'; import { ProvidedType } from '@kbn/test/types/ftr'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -98,5 +99,22 @@ export function MachineLearningCommonUIProvider({ getService }: FtrProviderConte async assertKibanaHomeFileDataVisLinkNotExists() { await testSubjects.missingOrFail('homeSynopsisLinkml_file_data_visualizer'); }, + + async assertRadioGroupValue(testSubject: string, expectedValue: string) { + const assertRadioGroupValue = await testSubjects.find(testSubject); + const input = await assertRadioGroupValue.findByCssSelector(':checked'); + const selectedOptionId = await input.getAttribute('id'); + expect(selectedOptionId).to.eql( + expectedValue, + `Expected the radio group value to equal "${expectedValue}" (got "${selectedOptionId}")` + ); + }, + + async selectRadioGroupValue(testSubject: string, value: string) { + const radioGroup = await testSubjects.find(testSubject); + const label = await radioGroup.findByCssSelector(`label[for="${value}"]`); + await label.click(); + await this.assertRadioGroupValue(testSubject, value); + }, }; } diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 50da8425e493d..b9ff0692cb737 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -80,7 +80,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { const settings = MachineLearningSettingsProvider(context); const settingsCalendar = MachineLearningSettingsCalendarProvider(context, commonUI); const settingsFilterList = MachineLearningSettingsFilterListProvider(context, commonUI); - const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context); + const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context, commonUI); const testExecution = MachineLearningTestExecutionProvider(context); const testResources = MachineLearningTestResourcesProvider(context); diff --git a/x-pack/test/functional/services/ml/single_metric_viewer.ts b/x-pack/test/functional/services/ml/single_metric_viewer.ts index c6b912d83fae6..bb628d704c373 100644 --- a/x-pack/test/functional/services/ml/single_metric_viewer.ts +++ b/x-pack/test/functional/services/ml/single_metric_viewer.ts @@ -6,8 +6,12 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlCommonUI } from './common_ui'; -export function MachineLearningSingleMetricViewerProvider({ getService }: FtrProviderContext) { +export function MachineLearningSingleMetricViewerProvider( + { getService }: FtrProviderContext, + mlCommonUI: MlCommonUI +) { const comboBox = getService('comboBox'); const testSubjects = getService('testSubjects'); @@ -34,7 +38,7 @@ export function MachineLearningSingleMetricViewerProvider({ getService }: FtrPro ); }, - async assertDetectorInputExsist() { + async assertDetectorInputExist() { await testSubjects.existOrFail( 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerDetectorSelect' ); @@ -59,7 +63,7 @@ export function MachineLearningSingleMetricViewerProvider({ getService }: FtrPro await this.assertDetectorInputValue(detectorOptionValue); }, - async assertEntityInputExsist(entityFieldName: string) { + async assertEntityInputExist(entityFieldName: string) { await testSubjects.existOrFail(`mlSingleMetricViewerEntitySelection ${entityFieldName}`); }, @@ -81,7 +85,7 @@ export function MachineLearningSingleMetricViewerProvider({ getService }: FtrPro await this.assertEntityInputSelection(entityFieldName, [entityFieldValue]); }, - async assertChartExsist() { + async assertChartExist() { await testSubjects.existOrFail('mlSingleMetricViewerChart'); }, @@ -117,5 +121,71 @@ export function MachineLearningSingleMetricViewerProvider({ getService }: FtrPro await testSubjects.click('mlAnomalyResultsViewSelectorExplorer'); await testSubjects.existOrFail('mlPageAnomalyExplorer'); }, + + async openConfigForControl(entityFieldName: string) { + const isPopoverOpened = await testSubjects.exists( + `mlSingleMetricViewerEntitySelectionConfigPopover_${entityFieldName}` + ); + + if (isPopoverOpened) { + return; + } + + await testSubjects.click( + `mlSingleMetricViewerEntitySelectionConfigButton_${entityFieldName}` + ); + await testSubjects.existOrFail( + `mlSingleMetricViewerEntitySelectionConfigPopover_${entityFieldName}` + ); + }, + + async assertEntityConfig( + entityFieldName: string, + anomalousOnly: boolean, + sortBy: 'anomaly_score' | 'name', + order: 'asc' | 'desc' + ) { + await this.openConfigForControl(entityFieldName); + expect( + await testSubjects.isEuiSwitchChecked( + `mlSingleMetricViewerEntitySelectionConfigAnomalousOnly_${entityFieldName}` + ) + ).to.eql( + anomalousOnly, + `Expected the "Anomalous only" control for "${entityFieldName}" to be ${ + anomalousOnly ? 'enabled' : 'disabled' + }` + ); + await mlCommonUI.assertRadioGroupValue( + `mlSingleMetricViewerEntitySelectionConfigSortBy_${entityFieldName}`, + sortBy + ); + await mlCommonUI.assertRadioGroupValue( + `mlSingleMetricViewerEntitySelectionConfigOrder_${entityFieldName}`, + order + ); + }, + + async setEntityConfig( + entityFieldName: string, + anomalousOnly: boolean, + sortBy: 'anomaly_score' | 'name', + order: 'asc' | 'desc' + ) { + await this.openConfigForControl(entityFieldName); + await testSubjects.setEuiSwitch( + `mlSingleMetricViewerEntitySelectionConfigAnomalousOnly_${entityFieldName}`, + anomalousOnly ? 'check' : 'uncheck' + ); + await mlCommonUI.selectRadioGroupValue( + `mlSingleMetricViewerEntitySelectionConfigSortBy_${entityFieldName}`, + sortBy + ); + await mlCommonUI.selectRadioGroupValue( + `mlSingleMetricViewerEntitySelectionConfigOrder_${entityFieldName}`, + order + ); + await this.assertEntityConfig(entityFieldName, anomalousOnly, sortBy, order); + }, }; }