mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
fb629bd94d
commit
f50c5a2cd3
23 changed files with 1213 additions and 346 deletions
|
@ -74,3 +74,5 @@ export interface AnomalyCategorizerStatsDoc {
|
|||
log_time: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type EntityFieldType = 'partition_field' | 'over_field' | 'by_field';
|
||||
|
|
38
x-pack/plugins/ml/common/types/storage.ts
Normal file
38
x-pack/plugins/ml/common/types/storage.ts
Normal 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;
|
|
@ -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>;
|
||||
|
|
|
@ -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];
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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" />
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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({
|
||||
/**
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue