[ML] Configure sorting for partition values on Single Metric Viewer (#81510)

* [ML] fix callout styles

* [ML] refactor timeseriesexplorer.js, add series_controls.tsx, storage support for partition config

* [ML] anomalousOnly support

* [ML] sort by control

* [ML] update query

* [ML] sort order controls

* [ML] adjust query

* [ML] merge default and local configs, add info

* [ML] fix types, adjust sorting logic for model plot results

* [ML] fix translation keys

* [ML] fixed size for the icon flex item

* [ML] fix time range condition, refactor

* [ML] change info messages and the icon color

* Fix model plot info message

Co-authored-by: István Zoltán Szabó <istvan.szabo@elastic.co>

* [ML] functional tests

* [ML] rename ML_ENTITY_FIELDS_CONFIG

* [ML] support manual input

* [ML] show max record score color indicator

* [ML] use :checked selector

* [ML] refactor functional tests

* [ML] extend config with "applyTimeRange", refactor with entity_config.tsx

* [ML] info messages

* [ML] remove custom message

* [ML] adjust the endpoint

* [ML] customOptionText

* [ML] sort by name UI tweak

* [ML] change text

* [ML] remove TODO comment

* [ML] fix functional test

* [ML] move "Anomalous only"/"Apply time range" control to the bottom of the popover

* [ML] update types
This commit is contained in:
Dima Arnautov 2020-11-02 16:04:59 +01:00 committed by GitHub
parent fb629bd94d
commit f50c5a2cd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1213 additions and 346 deletions

View file

@ -74,3 +74,5 @@ export interface AnomalyCategorizerStatsDoc {
log_time: number;
timestamp: number;
}
export type EntityFieldType = 'partition_field' | 'over_field' | 'by_field';

View file

@ -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<Record<EntityFieldType, PartitionFieldConfig>>
| undefined;
export type MlStorage = Partial<{
[ML_ENTITY_FIELDS_CONFIG]: PartitionFieldsConfig;
}> | null;

View file

@ -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<StartServices>();
export type MlKibanaReactContextValue = KibanaReactContextValue<StartServices>;

View file

@ -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<T>(key: string, initValue?: T): [T, (value: T) => void] {
const {
services: { storage },
} = useMlKibana();
const [val, setVal] = useState<T>(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];
}

View file

@ -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<string, string>,
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$<PartitionFieldsDefinition>({
path: `${basePath()}/results/partition_fields_values`,
method: 'POST',

View file

@ -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';

View file

@ -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;
}

View file

@ -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<UiPartitionFieldConfig>) => void;
}
export const EntityConfig: FC<EntityConfigProps> = ({
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 (
<EuiPopover
ownFocus
style={{ height: '40px' }}
button={
<EuiButtonIcon
color="text"
iconSize="m"
iconType="gear"
aria-label={i18n.translate('xpack.ml.timeSeriesExplorer.editControlConfiguration', {
defaultMessage: 'Edit field configuration',
})}
onClick={() => {
setIsEntityConfigPopoverOpen(!isEntityConfigPopoverOpen);
}}
data-test-subj={`mlSingleMetricViewerEntitySelectionConfigButton_${entity.fieldName}`}
/>
}
isOpen={isEntityConfigPopoverOpen}
closePopover={() => {
setIsEntityConfigPopoverOpen(false);
}}
>
<div data-test-subj={`mlSingleMetricViewerEntitySelectionConfigPopover_${entity.fieldName}`}>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.sortByLabel"
defaultMessage="Sort by"
/>
}
>
<EuiRadioGroup
options={sortOptions}
idSelected={forceSortByName ? 'name' : config?.sort?.by}
onChange={(id) => {
onConfigChange(entity.fieldType, {
sort: {
order: config.sort.order,
by: id as UiPartitionFieldConfig['sort']['by'],
},
});
}}
compressed
data-test-subj={`mlSingleMetricViewerEntitySelectionConfigSortBy_${entity.fieldName}`}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage id="xpack.ml.timeSeriesExplorer.orderLabel" defaultMessage="Order" />
}
>
<EuiRadioGroup
options={orderOptions}
idSelected={config?.sort?.order}
onChange={(id) => {
onConfigChange(entity.fieldType, {
sort: {
by: config.sort.by,
order: id as UiPartitionFieldConfig['sort']['order'],
},
});
}}
compressed
data-test-subj={`mlSingleMetricViewerEntitySelectionConfigOrder_${entity.fieldName}`}
/>
</EuiFormRow>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup gutterSize={'xs'} alignItems={'center'}>
<EuiFlexItem grow={false}>
{isModelPlotEnabled ? (
<EuiSwitch
label={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.anomalousOnlyLabel"
defaultMessage="Anomalous only"
/>
}
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}`}
/>
) : (
<EuiSwitch
label={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.applyTimeRangeLabel"
defaultMessage="Apply time range"
/>
}
checked={config.applyTimeRange}
onChange={(e) => {
const applyTimeRange = e.target.checked;
onConfigChange(entity.fieldType, {
applyTimeRange,
});
}}
compressed
data-test-subj={`mlSingleMetricViewerEntitySelectionConfigAnomalousOnly_${entity.fieldName}`}
/>
)}
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '16px' }}>
{isModelPlotEnabled && !config?.anomalousOnly ? (
<EuiToolTip
position="top"
content={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.nonAnomalousResultsWithModelPlotInfo"
defaultMessage="The list contains values from the model plot results."
/>
}
>
<EuiIcon tabIndex={0} type="iInCircle" color={'subdued'} />
</EuiToolTip>
) : null}
{!isModelPlotEnabled && !config?.applyTimeRange ? (
<EuiToolTip
position="top"
content={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.ignoreTimeRangeInfo"
defaultMessage="The list contains values from all anomalies created during the lifetime of the job."
/>
}
>
<EuiIcon tabIndex={0} type="iInCircle" color={'subdued'} />
</EuiToolTip>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</div>
</EuiPopover>
);
};

View file

@ -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<UiPartitionFieldConfig>) => void;
forceSelection: boolean;
options: Array<EuiComboBoxOptionOption<string>>;
options: ComboBoxOption[];
isModelPlotEnabled: boolean;
}
interface EntityControlState {
selectedOptions: Array<EuiComboBoxOptionOption<string>> | undefined;
selectedOptions: ComboBoxOption[] | undefined;
isLoading: boolean;
options: Array<EuiComboBoxOptionOption<string>> | undefined;
options: ComboBoxOption[] | undefined;
isEntityConfigPopoverOpen: boolean;
}
export const EMPTY_FIELD_VALUE_LABEL = i18n.translate(
@ -46,6 +74,7 @@ export class EntityControl extends Component<EntityControlProps, EntityControlSt
selectedOptions: undefined,
options: undefined,
isLoading: false,
isEntityConfigPopoverOpen: false,
};
componentDidUpdate(prevProps: EntityControlProps) {
@ -54,7 +83,7 @@ export class EntityControl extends Component<EntityControlProps, EntityControlSt
const { fieldValue } = entity;
let selectedOptionsUpdate: Array<EuiComboBoxOptionOption<string>> | undefined = selectedOptions;
let selectedOptionsUpdate: ComboBoxOption[] | undefined = selectedOptions;
if (
(selectedOptions === undefined && fieldValue !== null) ||
(Array.isArray(selectedOptions) &&
@ -87,17 +116,36 @@ export class EntityControl extends Component<EntityControlProps, EntityControlSt
}
}
onChange = (selectedOptions: Array<EuiComboBoxOptionOption<string>>) => {
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<EntityControlProps, EntityControlSt
this.props.onSearchChange(this.props.entity, searchValue);
};
renderOption = (option: EuiComboBoxOptionOption) => {
const { label } = option;
return label === EMPTY_FIELD_VALUE_LABEL ? <i>{label}</i> : label;
renderOption = (option: ComboBoxOption, searchValue: string) => {
const highlightedLabel = <EuiHighlight search={searchValue}>{option.label}</EuiHighlight>;
return option.value?.maxRecordScore ? (
<EuiHealth color={getSeverityColor(option.value.maxRecordScore)}>
{highlightedLabel}
</EuiHealth>
) : (
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<EntityControlProps, EntityControlSt
defaultMessage: 'Enter value',
})}
singleSelection={{ asPlainText: true }}
onCreateOption={this.onManualInput}
customOptionText={i18n.translate('xpack.ml.timeSeriesExplorer.setManualInputHelperText', {
defaultMessage: 'No matching values',
})}
options={options}
selectedOptions={selectedOptions}
onChange={this.onChange}
@ -136,6 +194,14 @@ export class EntityControl extends Component<EntityControlProps, EntityControlSt
isClearable={false}
renderOption={this.renderOption}
data-test-subj={`mlSingleMetricViewerEntitySelection ${entity.fieldName}`}
prepend={
<EntityConfig
entity={entity}
isModelPlotEnabled={isModelPlotEnabled}
config={config}
onConfigChange={onConfigChange}
/>
}
/>
);

View file

@ -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';

View file

@ -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<PartitionFieldsConfig, undefined>;
export type UiPartitionFieldConfig = Exclude<PartitionFieldConfig, undefined>;
/**
* 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<string, any>;
}
/**
* Component for handling the detector and entities controls.
*/
export const SeriesControls: FC<SeriesControlsProps> = ({
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<Record<string, FieldDefinition['values']>>({});
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<PartitionFieldsConfig>(
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<string, any> = {};
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<string, any>),
[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 (
<div data-test-subj="mlSingleMetricViewerSeriesControls">
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.detectorLabel"
defaultMessage="Detector"
/>
}
>
<EuiSelect
onChange={detectorIndexChangeHandler}
value={selectedDetectorIndex}
options={detectorSelectOptions}
data-test-subj="mlSingleMetricViewerDetectorSelect"
/>
</EuiFormRow>
</EuiFlexItem>
{entityControls.map((entity) => {
const entityKey = `${entity.fieldName}`;
const forceSelection = !hasEmptyFieldValues && entity.fieldValue === null;
hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection;
return (
<EntityControl
entity={entity}
entityFieldValueChanged={entityFieldValueChanged}
isLoading={entitiesLoading}
onSearchChange={entityFieldSearchChanged}
config={resultFieldsConfig[entity.fieldType]!}
onConfigChange={onFieldConfigChange}
forceSelection={forceSelection}
key={entityKey}
options={getEntityControlOptions(entityValues[entity.fieldName])}
isModelPlotEnabled={isModelPlotEnabled}
/>
);
})}
{children}
</EuiFlexGroup>
</div>
);
};

View file

@ -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<string, any>,
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;
}

View file

@ -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 (
<TimeSeriesExplorerPage dateFormatTz={dateFormatTz} resizeRef={this.resizeRef}>
{fieldNamesWithEmptyValues.length > 0 && (
@ -1154,53 +994,27 @@ export class TimeSeriesExplorer extends React.Component {
</>
)}
<div className="series-controls" data-test-subj="mlSingleMetricViewerSeriesControls">
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow
label={i18n.translate('xpack.ml.timeSeriesExplorer.detectorLabel', {
defaultMessage: 'Detector',
})}
>
<EuiSelect
onChange={this.detectorIndexChangeHandler}
value={selectedDetectorIndex}
options={detectorSelectOptions}
data-test-subj="mlSingleMetricViewerDetectorSelect"
<SeriesControls
selectedJobId={selectedJobId}
appStateHandler={this.props.appStateHandler}
selectedDetectorIndex={selectedDetectorIndex}
selectedEntities={this.props.selectedEntities}
bounds={bounds}
>
{arePartitioningFieldsProvided && (
<EuiFlexItem style={{ textAlign: 'right' }}>
<EuiFormRow hasEmptyLabelSpace style={{ maxWidth: '100%' }}>
<ForecastingModal
job={selectedJob}
detectorIndex={selectedDetectorIndex}
entities={entityControls}
setForecastId={this.setForecastId}
className="forecast-controls"
/>
</EuiFormRow>
</EuiFlexItem>
{entityControls.map((entity) => {
const entityKey = `${entity.fieldName}`;
const forceSelection = !hasEmptyFieldValues && entity.fieldValue === null;
hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection;
return (
<EntityControl
entity={entity}
entityFieldValueChanged={this.entityFieldValueChanged}
isLoading={this.state.entitiesLoading}
onSearchChange={this.entityFieldSearchChanged}
forceSelection={forceSelection}
key={entityKey}
options={getEntityControlOptions(entityValues[entity.fieldName])}
/>
);
})}
{arePartitioningFieldsProvided && (
<EuiFlexItem style={{ textAlign: 'right' }}>
<EuiFormRow hasEmptyLabelSpace style={{ maxWidth: '100%' }}>
<ForecastingModal
job={selectedJob}
detectorIndex={selectedDetectorIndex}
entities={entityControls}
setForecastId={this.setForecastId}
className="forecast-controls"
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
</div>
)}
</SeriesControls>
<EuiSpacer size="m" />

View file

@ -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 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: {
[fieldName]: fieldValue,
},
};
}),
{
term: {
job_id: jobId,
},
},
...(searchAllTime
? []
: [
{
range: {
timestamp: {
gte: earliestMs,
lte: latestMs,
format: 'epoch_millis',
},
},
},
]),
{
term: {
result_type: isModelPlotSearch ? 'model_plot' : 'record',
},
},
],
},
},
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: {
query: {
bool: {
filter: [
...criteriaFields.map(({ fieldName, fieldValue }) => {
return {
term: {
[fieldName]: fieldValue,
},
};
}),
{
term: {
job_id: jobId,
},
},
{
range: {
timestamp: {
gte: earliestMs,
lte: latestMs,
format: 'epoch_millis',
},
},
},
{
term: {
result_type: isModelPlotEnabled ? 'model_plot' : 'record',
},
},
],
},
},
aggs: {
...PARTITION_FIELDS.reduce((acc, key) => {
return {
...acc,
...getFieldAgg(key, searchTerm[key]),
};
}, {}),
},
},
body: requestBody,
});
return PARTITION_FIELDS.reduce((acc, key) => {

View file

@ -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) {

View file

@ -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<typeof partitionFieldValuesSchema>['fieldsConfig'];
export type FieldConfig = TypeOf<typeof fieldConfig>;
export const getCategorizerStatsSchema = schema.nullable(
schema.object({
/**

View file

@ -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');

View file

@ -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();
await ml.api.createAndRunAnomalyDetectionLookbackJob(JOB_CONFIG, DATAFEED_CONFIG);
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(JOB_CONFIG.job_id, 1);
await ml.jobTable.clickOpenJobInSingleMetricViewerButton(JOB_CONFIG.job_id);
await ml.commonUI.waitForMlLoadingIndicatorToDisappear();
});
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('pre-fills the detector input');
await ml.singleMetricViewer.assertDetectorInputExist();
await ml.singleMetricViewer.assertDetectorInputValue('0');
await ml.testExecution.logTestStep('displays the chart');
await ml.singleMetricViewer.assertChartExist();
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();
});
});
after(async () => {
await ml.api.cleanMlIndices();
});
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 },
};
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();
const datafeedConfig: Datafeed = {
datafeed_id: 'datafeed-ecom_01',
indices: ['ft_ecommerce'],
job_id: 'ecom_01',
query: { bool: { must: [{ match_all: {} }] } },
};
await ml.testExecution.logTestStep('open job in single metric viewer');
await ml.jobTable.waitForJobsToLoad();
await ml.jobTable.filterWithSearchString(JOB_CONFIG.job_id, 1);
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();
});
await ml.jobTable.clickOpenJobInSingleMetricViewerButton(JOB_CONFIG.job_id);
await ml.commonUI.waitForMlLoadingIndicatorToDisappear();
});
after(async () => {
await ml.api.cleanMlIndices();
});
it('displays job results', async () => {
await ml.testExecution.logTestStep('pre-fills the job selection');
await ml.jobSelection.assertJobSelection([JOB_CONFIG.job_id]);
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('pre-fills the detector input');
await ml.singleMetricViewer.assertDetectorInputExsist();
await ml.singleMetricViewer.assertDetectorInputValue('0');
await ml.testExecution.logTestStep('open job in single metric viewer');
await ml.jobTable.waitForJobsToLoad();
await ml.jobTable.filterWithSearchString(jobConfig.job_id, 1);
await ml.testExecution.logTestStep('displays the chart');
await ml.singleMetricViewer.assertChartExsist();
await ml.jobTable.clickOpenJobInSingleMetricViewerButton(jobConfig.job_id);
await ml.commonUI.waitForMlLoadingIndicatorToDisappear();
});
await ml.testExecution.logTestStep('should display the annotations section');
await ml.singleMetricViewer.assertAnnotationsExists('loaded');
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('displays the anomalies table');
await ml.anomaliesTable.assertTableExists();
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');
await ml.testExecution.logTestStep('anomalies table is not empty');
await ml.anomaliesTable.assertTableNotEmpty();
// 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();
});
});
});
}

View file

@ -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');

View file

@ -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');

View file

@ -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);
},
};
}

View file

@ -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);

View file

@ -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);
},
};
}