Skip to content

Commit

Permalink
[ML] Configure sorting for partition values on Single Metric Viewer (e…
Browse files Browse the repository at this point in the history
…lastic#81510) (elastic#82295)

* [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
  • Loading branch information
darnautov authored Nov 2, 2020
1 parent c98e285 commit 998e682
Show file tree
Hide file tree
Showing 23 changed files with 1,210 additions and 343 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/ml/common/types/anomalies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ export interface AnomalyCategorizerStatsDoc {
log_time: number;
timestamp: number;
}

export type EntityFieldType = 'partition_field' | 'over_field' | 'by_field';
38 changes: 38 additions & 0 deletions x-pack/plugins/ml/common/types/storage.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>;
32 changes: 32 additions & 0 deletions x-pack/plugins/ml/public/application/contexts/ml/use_storage.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit 998e682

Please sign in to comment.