[ML] Severity control for Anomaly timeline (#99489)

This commit is contained in:
Dima Arnautov 2021-05-28 21:34:29 +02:00 committed by GitHub
parent 4c48993bb0
commit d50a3db2b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 354 additions and 299 deletions

View file

@ -112,6 +112,10 @@ export interface ExplorerAppState {
viewByFieldName?: string;
viewByPerPage?: number;
viewByFromPage?: number;
/**
* Indicated severity threshold for both swim lanes
*/
severity?: number;
};
mlExplorerFilter: {
influencersFilterQuery?: InfluencersFilterQuery;

View file

@ -13,7 +13,7 @@ import { JobSelectorControl } from './job_selector';
import { useMlKibana } from '../application/contexts/kibana';
import { jobsApiProvider } from '../application/services/ml_api_service/jobs';
import { HttpService } from '../application/services/http_service';
import { SeverityControl } from './severity_control';
import { SeverityControl } from '../application/components/severity_control';
import { ResultTypeSelector } from './result_type_selector';
import { alertingApiProvider } from '../application/services/ml_api_service/alerting';
import { PreviewAlertCondition } from './preview_alert_condition';

View file

@ -1,84 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFormRow, EuiRange, EuiRangeProps } from '@elastic/eui';
import { SEVERITY_OPTIONS } from '../../application/components/controls/select_severity/select_severity';
import { ANOMALY_THRESHOLD } from '../../../common';
import './styles.scss';
export interface SeveritySelectorProps {
value: number | undefined;
onChange: (value: number) => void;
}
const MAX_ANOMALY_SCORE = 100;
export const SeverityControl: FC<SeveritySelectorProps> = React.memo(({ value, onChange }) => {
const levels: EuiRangeProps['levels'] = [
{
min: ANOMALY_THRESHOLD.LOW,
max: ANOMALY_THRESHOLD.MINOR - 1,
color: 'success',
},
{
min: ANOMALY_THRESHOLD.MINOR,
max: ANOMALY_THRESHOLD.MAJOR - 1,
color: 'primary',
},
{
min: ANOMALY_THRESHOLD.MAJOR,
max: ANOMALY_THRESHOLD.CRITICAL,
color: 'warning',
},
{
min: ANOMALY_THRESHOLD.CRITICAL,
max: MAX_ANOMALY_SCORE,
color: 'danger',
},
];
const toggleButtons = SEVERITY_OPTIONS.map((v) => ({
value: v.val,
label: v.display,
}));
return (
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="xpack.ml.severitySelector.formControlLabel"
defaultMessage="Select severity threshold"
/>
}
>
<EuiRange
className={'mlSeverityControl'}
fullWidth
min={ANOMALY_THRESHOLD.LOW}
max={MAX_ANOMALY_SCORE}
value={value ?? ANOMALY_THRESHOLD.LOW}
onChange={(e) => {
// @ts-ignore Property 'value' does not exist on type 'EventTarget' | (EventTarget & HTMLInputElement)
onChange(Number(e.target.value));
}}
showLabels
showValue
aria-label={i18n.translate('xpack.ml.severitySelector.formControlLabel', {
defaultMessage: 'Select severity threshold',
})}
showTicks
ticks={toggleButtons}
levels={levels}
data-test-subj={'mlAnomalyAlertScoreSelection'}
/>
</EuiFormRow>
);
});

View file

@ -6,7 +6,7 @@
*/
import React, { FC } from 'react';
import { EuiSelect } from '@elastic/eui';
import { EuiIcon, EuiSelect, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { usePageUrlState } from '../../../util/url_state';
@ -78,8 +78,22 @@ export const SelectIntervalUI: FC<SelectIntervalUIProps> = ({ interval, onChange
return (
<EuiSelect
prepend={i18n.translate('xpack.ml.explorer.intervalLabel', {
defaultMessage: 'Interval',
})}
append={
<EuiToolTip
content={i18n.translate('xpack.ml.explorer.intervalTooltip', {
defaultMessage:
'Show only the highest severity anomaly for each interval (such as hour or day) or show all anomalies in the selected time period.',
})}
>
<EuiIcon type="questionInCircle" color="subdued" />
</EuiToolTip>
}
compressed
id="selectInterval"
options={OPTIONS}
className="ml-select-interval"
value={interval.val}
onChange={handleOnChange}
/>

View file

@ -8,11 +8,11 @@
/*
* React component for rendering a select element with threshold levels.
*/
import React, { Fragment, FC } from 'react';
import React, { Fragment, FC, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui';
import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText, EuiSuperSelectProps } from '@elastic/eui';
import { getSeverityColor } from '../../../../../common/util/anomaly_utils';
import { usePageUrlState } from '../../../util/url_state';
@ -124,23 +124,34 @@ export const SelectSeverity: FC<Props> = ({ classNames } = { classNames: '' }) =
return <SelectSeverityUI severity={severity} onChange={setSeverity} />;
};
export const SelectSeverityUI: FC<{
classNames?: string;
severity: TableSeverity;
onChange: (s: TableSeverity) => void;
}> = ({ classNames = '', severity, onChange }) => {
export const SelectSeverityUI: FC<
Omit<EuiSuperSelectProps<string>, 'onChange' | 'options'> & {
classNames?: string;
severity: TableSeverity;
onChange: (s: TableSeverity) => void;
}
> = ({ classNames = '', severity, onChange, compressed }) => {
const handleOnChange = (valueDisplay: string) => {
onChange(optionValueToThreshold(optionsMap[valueDisplay]));
};
const options = useMemo(() => {
return getSeverityOptions();
}, []);
return (
<EuiSuperSelect
prepend={i18n.translate('xpack.ml.explorer.severityThresholdLabel', {
defaultMessage: 'Severity',
})}
id="severityThreshold"
data-test-subj={'mlAnomalySeverityThresholdControls'}
className={classNames}
hasDividers
options={getSeverityOptions()}
options={options}
valueOfSelected={severity.display}
onChange={handleOnChange}
compressed
/>
);
};

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiRange,
EuiRangeProps,
} from '@elastic/eui';
import { ANOMALY_THRESHOLD } from '../../../../common';
import './styles.scss';
export interface SeveritySelectorProps {
value: number | undefined;
onChange: (value: number) => void;
}
const MAX_ANOMALY_SCORE = 100;
export const SeverityControl: FC<SeveritySelectorProps> = React.memo(({ value, onChange }) => {
const levels: EuiRangeProps['levels'] = [
{
min: ANOMALY_THRESHOLD.LOW,
max: ANOMALY_THRESHOLD.MINOR - 1,
color: 'success',
},
{
min: ANOMALY_THRESHOLD.MINOR,
max: ANOMALY_THRESHOLD.MAJOR - 1,
color: 'primary',
},
{
min: ANOMALY_THRESHOLD.MAJOR,
max: ANOMALY_THRESHOLD.CRITICAL,
color: 'warning',
},
{
min: ANOMALY_THRESHOLD.CRITICAL,
max: MAX_ANOMALY_SCORE,
color: 'danger',
},
];
const label = i18n.translate('xpack.ml.severitySelector.formControlLabel', {
defaultMessage: 'Severity',
});
const resultValue = value ?? ANOMALY_THRESHOLD.LOW;
const onChangeCallback = (
e: React.ChangeEvent<HTMLInputElement> | React.MouseEvent<HTMLButtonElement>
) => {
// @ts-ignore Property 'value' does not exist on type 'EventTarget' | (EventTarget & HTMLInputElement)
onChange(Number(e.target.value));
};
const ticks = new Array(5).fill(null).map((x, i) => {
const v = i * 25;
return { value: v, label: v };
});
return (
<EuiFormRow fullWidth>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFieldNumber
id="severityControl"
style={{ width: '70px' }}
compressed
prepend={label}
value={resultValue}
onChange={onChangeCallback}
min={ANOMALY_THRESHOLD.LOW}
max={MAX_ANOMALY_SCORE}
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiRange
className={'mlSeverityControl'}
fullWidth
min={ANOMALY_THRESHOLD.LOW}
max={MAX_ANOMALY_SCORE}
value={resultValue}
onChange={onChangeCallback}
aria-label={i18n.translate('xpack.ml.severitySelector.formControlAriaLabel', {
defaultMessage: 'Select severity threshold',
})}
showTicks
ticks={ticks}
showRange={false}
levels={levels}
data-test-subj={'mlAnomalyAlertScoreSelection'}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
});

View file

@ -40,14 +40,6 @@ $borderRadius: $euiBorderRadius / 2;
font-size: $euiFontSizeXS;
}
}
.ml-anomalies-controls {
padding-top: $euiSizeXS;
#show_charts_checkbox_control {
padding-top: $euiSizeL;
}
}
}
.mlSwimLaneContainer {

View file

@ -83,6 +83,7 @@ export interface LoadExplorerDataConfig {
viewByFromPage: number;
viewByPerPage: number;
swimlaneContainerWidth: number;
swimLaneSeverity: number;
}
export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => {
@ -135,6 +136,7 @@ const loadExplorerDataProvider = (
swimlaneContainerWidth,
viewByFromPage,
viewByPerPage,
swimLaneSeverity,
} = config;
const combinedJobRecords: Record<string, CombinedJob> = selectedJobs.reduce((acc, job) => {
@ -192,7 +194,13 @@ const loadExplorerDataProvider = (
influencersFilterQuery
)
: Promise.resolve({}),
overallState: memoizedLoadOverallData(lastRefresh, selectedJobs, swimlaneContainerWidth),
overallState: memoizedLoadOverallData(
lastRefresh,
selectedJobs,
swimlaneContainerWidth,
undefined,
swimLaneSeverity
),
tableData: memoizedLoadAnomaliesTableData(
lastRefresh,
selectedCells,
@ -278,7 +286,9 @@ const loadExplorerDataProvider = (
viewByPerPage,
viewByFromPage,
swimlaneContainerWidth,
influencersFilterQuery
influencersFilterQuery,
undefined,
swimLaneSeverity
),
}).pipe(
map(({ viewBySwimlaneState, filteredTopInfluencers }) => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useMemo, useState } from 'react';
import React, { FC, useCallback, useMemo, useState } from 'react';
import { isEqual } from 'lodash';
import {
EuiPanel,
@ -14,7 +14,6 @@ import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSelect,
EuiTitle,
EuiSpacer,
@ -35,7 +34,9 @@ import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers
import { SwimlaneContainer } from './swimlane_container';
import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils';
import { NoOverallData } from './components/no_overall_data';
import { SeverityControl } from '../components/severity_control';
import { AnomalyTimelineHelpPopover } from './anomaly_timeline_help_popover';
import { isDefined } from '../../../common/types/guards';
function mapSwimlaneOptionsToEuiOptions(options: string[]) {
return options.map((option) => ({
@ -76,10 +77,8 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
filterActive,
filteredFields,
maskAll,
overallSwimlaneData,
selectedCells,
viewByLoadedForTimeFormatted,
viewBySwimlaneData,
viewBySwimlaneDataLoading,
viewBySwimlaneFieldName,
viewBySwimlaneOptions,
@ -89,6 +88,9 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
swimlaneLimit,
loading,
overallAnnotations,
swimLaneSeverity,
overallSwimlaneData,
viewBySwimlaneData,
} = explorerState;
const annotations = useMemo(() => overallAnnotations.annotationsData, [overallAnnotations]);
@ -128,7 +130,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
return (
<>
<EuiPanel paddingSize="m">
<EuiFlexGroup direction="row" gutterSize="m" responsive={false} alignItems="center">
<EuiFlexGroup direction="row" gutterSize="xs" responsive={false} alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiTitle className="panel-title">
<h2>
@ -139,68 +141,10 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
</h2>
</EuiTitle>
</EuiFlexItem>
{viewBySwimlaneOptions.length > 0 && (
<>
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<span className="eui-textNoWrap">
<FormattedMessage
id="xpack.ml.explorer.viewByLabel"
defaultMessage="View by"
/>
</span>
}
display={'columnCompressed'}
>
<EuiSelect
compressed
id="selectViewBy"
options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)}
value={viewBySwimlaneFieldName}
onChange={(e) => explorerService.setViewBySwimlaneFieldName(e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
{selectedCells ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
onClick={setSelectedCells.bind(null, undefined)}
data-test-subj="mlAnomalyTimelineClearSelection"
>
<FormattedMessage
id="xpack.ml.explorer.clearSelectionLabel"
defaultMessage="Clear selection"
/>
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
defaultMessage="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
values={{ viewByLoadedForTimeFormatted }}
/>
)}
{viewByLoadedForTimeFormatted === undefined && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
defaultMessage="(Sorted by max anomaly score)"
/>
)}
{filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && (
<FormattedMessage
id="xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel"
defaultMessage="(Job score across all influencers)"
/>
)}
</div>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
<AnomalyTimelineHelpPopover />
</EuiFlexItem>
{menuItems.length > 0 && (
<EuiFlexItem grow={false} style={{ marginLeft: 'auto', alignSelf: 'baseline' }}>
@ -226,14 +170,83 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexItem grow={false}>
<AnomalyTimelineHelpPopover />
<EuiSpacer size="m" />
<EuiFlexGroup direction="row" gutterSize="m" responsive={false} alignItems="baseline">
{viewBySwimlaneOptions.length > 0 && (
<>
<EuiFlexItem grow={false}>
<EuiSelect
prepend={i18n.translate('xpack.ml.explorer.viewByLabel', {
defaultMessage: 'View by',
})}
compressed
id="selectViewBy"
options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)}
value={viewBySwimlaneFieldName}
onChange={(e) => explorerService.setViewBySwimlaneFieldName(e.target.value)}
/>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={true}>
<SeverityControl
value={swimLaneSeverity ?? 0}
onChange={useCallback((update) => {
explorerService.setSwimLaneSeverity(update);
}, [])}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup direction="row" gutterSize="m" responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
defaultMessage="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
values={{ viewByLoadedForTimeFormatted }}
/>
)}
{isDefined(viewByLoadedForTimeFormatted) ? null : (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
defaultMessage="(Sorted by max anomaly score)"
/>
)}
{filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && (
<FormattedMessage
id="xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel"
defaultMessage="(Job score across all influencers)"
/>
)}
</div>
</EuiFlexItem>
{selectedCells ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
onClick={setSelectedCells.bind(null, undefined)}
data-test-subj="mlAnomalyTimelineClearSelection"
>
<FormattedMessage
id="xpack.ml.explorer.clearSelectionLabel"
defaultMessage="Clear selection"
/>
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiSpacer size="m" />
<SwimlaneContainer
id="overall"
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
@ -249,6 +262,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
noDataWarning={<NoOverallData />}
showTimeline={false}
annotationsData={annotations}
showLegend={false}
/>
<EuiSpacer size="m" />
@ -266,7 +280,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
})
}
timeBuckets={timeBuckets}
showLegend={true}
showLegend={false}
swimlaneData={viewBySwimlaneData as ViewBySwimLaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}

View file

@ -19,9 +19,7 @@ import {
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiHorizontalRule,
EuiIcon,
EuiIconTip,
EuiPage,
EuiPageBody,
@ -29,7 +27,6 @@ import {
EuiPageHeaderSection,
EuiSpacer,
EuiTitle,
EuiToolTip,
EuiLoadingContent,
EuiPanel,
EuiAccordion,
@ -78,6 +75,7 @@ import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/
import { withKibana } from '../../../../../../src/plugins/kibana_react/public';
import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator';
import { AnomalyContextMenu } from './anomaly_context_menu';
import { isDefined } from '../../../common/types/guards';
const ExplorerPage = ({
children,
@ -263,6 +261,7 @@ export class ExplorerUI extends React.Component {
selectedCells,
selectedJobs,
tableData,
swimLaneSeverity,
} = this.props.explorerState;
const { annotationsData, aggregations, error: annotationsError } = annotations;
@ -276,6 +275,8 @@ export class ExplorerUI extends React.Component {
(hasResults && overallSwimlaneData.points.some((v) => v.value > 0)) ||
tableData.anomalies?.length > 0;
const hasActiveFilter = isDefined(swimLaneSeverity);
if (noJobsFound && !loading) {
return (
<ExplorerPage jobSelectorProps={jobSelectorProps}>
@ -284,7 +285,7 @@ export class ExplorerUI extends React.Component {
);
}
if (hasResultsWithAnomalies === false && !loading) {
if (!hasResultsWithAnomalies && !loading && !hasActiveFilter) {
return (
<ExplorerPage jobSelectorProps={jobSelectorProps}>
<ExplorerNoResultsFound
@ -374,7 +375,9 @@ export class ExplorerUI extends React.Component {
explorerState={this.props.explorerState}
setSelectedCells={this.props.setSelectedCells}
/>
<EuiSpacer size="m" />
{annotationsError !== undefined && (
<>
<EuiTitle
@ -402,9 +405,9 @@ export class ExplorerUI extends React.Component {
<EuiSpacer size="m" />
</>
)}
{loading === false && tableData.anomalies?.length && (
{loading === false && tableData.anomalies?.length ? (
<AnomaliesMap anomalies={tableData.anomalies} jobIds={selectedJobIds} />
)}
) : null}
{annotationsData.length > 0 && (
<>
<EuiPanel data-test-subj="mlAnomalyExplorerAnnotationsPanel loaded">
@ -476,47 +479,16 @@ export class ExplorerUI extends React.Component {
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.severityThresholdLabel', {
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverity />
</EuiFormRow>
<EuiFlexGroup direction="row" gutterSize="l" responsive={true} alignItems="center">
<EuiFlexItem grow={false}>
<SelectSeverity />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={
<EuiToolTip
content={i18n.translate('xpack.ml.explorer.intervalTooltip', {
defaultMessage:
'Show only the highest severity anomaly for each interval (such as hour or day) or show all anomalies in the selected time period.',
})}
>
<span>
{i18n.translate('xpack.ml.explorer.intervalLabel', {
defaultMessage: 'Interval',
})}
<EuiIcon type="questionInCircle" color="subdued" />
</span>
</EuiToolTip>
}
>
<SelectInterval />
</EuiFormRow>
<EuiFlexItem grow={false}>
<SelectInterval />
</EuiFlexItem>
{chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && (
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<CheckboxShowCharts />
</EuiFormRow>
<EuiFlexItem grow={false}>
<CheckboxShowCharts />
</EuiFlexItem>
)}
</EuiFlexGroup>
@ -524,7 +496,7 @@ export class ExplorerUI extends React.Component {
<EuiSpacer size="m" />
<div className="euiText explorer-charts">
{showCharts && (
{showCharts ? (
<ExplorerChartsContainer
{...{
...chartsData,
@ -535,7 +507,7 @@ export class ExplorerUI extends React.Component {
onSelectEntity: this.applyFilter,
}}
/>
)}
) : null}
</div>
<AnomaliesTable

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
@ -56,21 +56,9 @@ export const ExplorerAnomaliesContainer: FC<ExplorerAnomaliesContainerProps> = (
}) => {
return (
<>
<EuiFlexGroup
id={id}
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.severityThresholdLabel', {
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverityUI severity={severity} onChange={setSeverity} />
</EuiFormRow>
<EuiFlexGroup id={id} direction="row" gutterSize="l" responsive={true}>
<EuiFlexItem grow={false}>
<SelectSeverityUI severity={severity} onChange={setSeverity} />
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -33,6 +33,7 @@ export const EXPLORER_ACTION = {
SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading',
SET_VIEW_BY_PER_PAGE: 'setViewByPerPage',
SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage',
SET_SWIM_LANE_SEVERITY: 'setSwimLaneSeverity',
};
export const FILTER_ACTION = {

View file

@ -79,6 +79,10 @@ const explorerAppState$: Observable<ExplorerAppState> = explorerState$.pipe(
appState.mlExplorerSwimlane.viewByPerPage = state.viewByPerPage;
}
if (state.swimLaneSeverity !== undefined) {
appState.mlExplorerSwimlane.severity = state.swimLaneSeverity;
}
if (state.filterActive) {
appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery;
appState.mlExplorerFilter.filterActive = state.filterActive;
@ -161,6 +165,9 @@ export const explorerService = {
setViewByPerPage: (payload: number) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload });
},
setSwimLaneSeverity: (payload: number) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIM_LANE_SEVERITY, payload });
},
};
export type ExplorerService = typeof explorerService;

View file

@ -149,6 +149,15 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
};
break;
case EXPLORER_ACTION.SET_SWIM_LANE_SEVERITY:
nextState = {
...state,
// reset current page on the page size change
viewByFromPage: 1,
swimLaneSeverity: payload,
};
break;
default:
nextState = state;
}
@ -181,7 +190,9 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
...nextState,
swimlaneBucketInterval,
viewByLoadedForTimeFormatted: timeRange
? formatHumanReadableDateTime(timeRange.earliestMs)
? `${formatHumanReadableDateTime(timeRange.earliestMs)} - ${formatHumanReadableDateTime(
timeRange.latestMs
)}`
: null,
viewBySwimlaneFieldName,
viewBySwimlaneOptions,

View file

@ -58,6 +58,7 @@ export interface ExplorerState {
viewByFromPage: number;
viewBySwimlaneOptions: string[];
swimlaneLimit?: number;
swimLaneSeverity?: number;
}
function getDefaultIndexPattern() {

View file

@ -68,6 +68,10 @@ declare global {
const RESIZE_THROTTLE_TIME_MS = 500;
const CELL_HEIGHT = 30;
const LEGEND_HEIGHT = 34;
/**
* Minimum container height to make sure "No data" message is displayed without overflow.
*/
const MIN_CONTAINER_HEIGHT = 40;
const Y_AXIS_HEIGHT = 24;
@ -245,7 +249,10 @@ export const SwimlaneContainer: FC<SwimlaneProps> = ({
return isLoading
? containerHeightRef.current
: // TODO update when elastic charts X label will be fixed
rowsCount * CELL_HEIGHT + LEGEND_HEIGHT + (true ? Y_AXIS_HEIGHT : 0);
Math.max(
rowsCount * CELL_HEIGHT + (showLegend ? LEGEND_HEIGHT : 0) + (true ? Y_AXIS_HEIGHT : 0),
MIN_CONTAINER_HEIGHT
);
}, [isLoading, rowsCount, showTimeline]);
useEffect(() => {
@ -331,7 +338,7 @@ export const SwimlaneContainer: FC<SwimlaneProps> = ({
brushArea: {
stroke: isDarkTheme ? 'rgb(255, 255, 255)' : 'rgb(105, 112, 125)',
},
maxLegendHeight: LEGEND_HEIGHT,
...(showLegend ? { maxLegendHeight: LEGEND_HEIGHT } : {}),
timeZone: 'UTC',
};
}, [
@ -463,7 +470,7 @@ export const SwimlaneContainer: FC<SwimlaneProps> = ({
)}
{!isLoading && !showSwimlane && (
<EuiEmptyPrompt
titleSize="xs"
titleSize="xxs"
style={{ padding: 0 }}
title={<h2>{noDataWarning}</h2>}
/>

View file

@ -177,7 +177,7 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
explorerService.setFilterData(filterData);
}
const { viewByFieldName, viewByFromPage, viewByPerPage } =
const { viewByFieldName, viewByFromPage, viewByPerPage, severity } =
explorerUrlState?.mlExplorerSwimlane ?? {};
if (viewByFieldName !== undefined) {
@ -191,6 +191,10 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
if (viewByFromPage !== undefined) {
explorerService.setViewByFromPage(viewByFromPage);
}
if (severity !== undefined) {
explorerService.setSwimLaneSeverity(severity);
}
}, []);
/** Sync URL state with {@link explorerService} state */
@ -238,6 +242,7 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
swimlaneContainerWidth: explorerState.swimlaneContainerWidth,
viewByPerPage: explorerState.viewByPerPage,
viewByFromPage: explorerState.viewByFromPage,
swimLaneSeverity: explorerState.swimLaneSeverity,
}
: undefined;

View file

@ -98,7 +98,8 @@ export class AnomalyTimelineService {
public async loadOverallData(
selectedJobs: ExplorerJob[],
chartWidth?: number,
bucketInterval?: TimeBucketsInterval
bucketInterval?: TimeBucketsInterval,
overallScore?: number
): Promise<OverallSwimlaneData> {
const interval = bucketInterval ?? this.getSwimlaneBucketInterval(selectedJobs, chartWidth!);
@ -127,7 +128,8 @@ export class AnomalyTimelineService {
1,
overallBucketsBounds.min.valueOf(),
overallBucketsBounds.max.valueOf(),
interval.asSeconds() + 's'
interval.asSeconds() + 's',
overallScore
);
const overallSwimlaneData = this.processOverallResults(
resp.results,
@ -161,7 +163,8 @@ export class AnomalyTimelineService {
fromPage: number,
swimlaneContainerWidth?: number,
influencersFilterQuery?: any,
bucketInterval?: TimeBucketsInterval
bucketInterval?: TimeBucketsInterval,
swimLaneSeverity?: number
): Promise<SwimlaneData | undefined> {
const timefilterBounds = this.getTimeBounds();
@ -195,7 +198,8 @@ export class AnomalyTimelineService {
searchBounds.max.valueOf(),
intervalMs,
perPage,
fromPage
fromPage,
swimLaneSeverity
);
} else {
response = await this.mlResultsService.getInfluencerValueMaxScoreByTime(
@ -208,7 +212,8 @@ export class AnomalyTimelineService {
swimlaneLimit,
perPage,
fromPage,
influencersFilterQuery
influencersFilterQuery,
swimLaneSeverity
);
}

View file

@ -323,14 +323,22 @@ export function mlApiServicesProvider(httpService: HttpService) {
bucketSpan,
start,
end,
overallScore,
}: {
jobId: string;
topN: string;
bucketSpan: string;
start: number;
end: number;
overallScore?: number;
}) {
const body = JSON.stringify({ topN, bucketSpan, start, end });
const body = JSON.stringify({
topN,
bucketSpan,
start,
end,
...(overallScore ? { overall_score: overallScore } : {}),
});
return httpService.http<any>({
path: `${basePath()}/anomaly_detectors/${jobId}/results/overall_buckets`,
method: 'POST',

View file

@ -22,7 +22,8 @@ export function resultsServiceProvider(
latestMs: number,
intervalMs: number,
perPage?: number,
fromPage?: number
fromPage?: number,
swimLaneSeverity?: number
): Promise<any>;
getTopInfluencers(
selectedJobIds: string[],
@ -40,7 +41,8 @@ export function resultsServiceProvider(
topN: any,
earliestMs: any,
latestMs: any,
interval?: any
interval?: any,
overallScore?: number
): Promise<any>;
getInfluencerValueMaxScoreByTime(
jobIds: string[],
@ -52,7 +54,8 @@ export function resultsServiceProvider(
maxResults: number,
perPage: number,
fromPage: number,
influencersFilterQuery: InfluencersFilterQuery
influencersFilterQuery: InfluencersFilterQuery,
swimLaneSeverity?: number
): Promise<any>;
getRecordInfluencers(): Promise<any>;
getRecordsForDetector(): Promise<any>;

View file

@ -30,7 +30,15 @@ export function resultsServiceProvider(mlApiServices) {
// Pass an empty array or ['*'] to search over all job IDs.
// Returned response contains a results property, with a key for job
// which has results for the specified time range.
getScoresByBucket(jobIds, earliestMs, latestMs, intervalMs, perPage = 10, fromPage = 1) {
getScoresByBucket(
jobIds,
earliestMs,
latestMs,
intervalMs,
perPage = 10,
fromPage = 1,
swimLaneSeverity = 0
) {
return new Promise((resolve, reject) => {
const obj = {
success: true,
@ -49,6 +57,13 @@ export function resultsServiceProvider(mlApiServices) {
},
},
},
{
range: {
anomaly_score: {
gt: swimLaneSeverity,
},
},
},
];
if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) {
@ -463,7 +478,7 @@ export function resultsServiceProvider(mlApiServices) {
// Obtains the overall bucket scores for the specified job ID(s).
// Pass ['*'] to search over all job IDs.
// Returned response contains a results property as an object of max score by time.
getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval) {
getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval, overallScore) {
return new Promise((resolve, reject) => {
const obj = { success: true, results: {} };
@ -474,6 +489,7 @@ export function resultsServiceProvider(mlApiServices) {
bucketSpan: interval,
start: earliestMs,
end: latestMs,
overallScore,
})
.then((resp) => {
const dataByTime = get(resp, ['overall_buckets'], []);
@ -507,7 +523,8 @@ export function resultsServiceProvider(mlApiServices) {
maxResults = ANOMALY_SWIM_LANE_HARD_LIMIT,
perPage = SWIM_LANE_DEFAULT_PAGE_SIZE,
fromPage = 1,
influencersFilterQuery
influencersFilterQuery,
swimLaneSeverity
) {
return new Promise((resolve, reject) => {
const obj = { success: true, results: {} };
@ -527,7 +544,7 @@ export function resultsServiceProvider(mlApiServices) {
{
range: {
influencer_score: {
gt: 0,
gt: swimLaneSeverity !== undefined ? swimLaneSeverity : 0,
},
},
},

View file

@ -19,10 +19,6 @@
float: right;
}
.ml-anomalies-controls {
padding-top: $euiSizeXS;
}
.ml-timeseries-chart {
svg {
font-size: $euiFontSizeXS;

View file

@ -26,11 +26,9 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
EuiSpacer,
EuiPanel,
EuiTitle,
EuiToolTip,
EuiAccordion,
EuiBadge,
} from '@elastic/eui';
@ -1273,41 +1271,12 @@ export class TimeSeriesExplorer extends React.Component {
/>
</h2>
</EuiTitle>
<EuiFlexGroup
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={i18n.translate('xpack.ml.timeSeriesExplorer.severityThresholdLabel', {
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverity />
</EuiFormRow>
<EuiFlexGroup direction="row" gutterSize="l" responsive={true}>
<EuiFlexItem grow={false}>
<SelectSeverity />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={
<EuiToolTip
content={i18n.translate('xpack.ml.timeSeriesExplorer.intervalTooltip', {
defaultMessage:
'Show only the highest severity anomaly for each interval (such as hour or day) or show all anomalies in the selected time period.',
})}
>
<span>
{i18n.translate('xpack.ml.timeSeriesExplorer.intervalLabel', {
defaultMessage: 'Interval',
})}
<EuiIcon type="questionInCircle" color="subdued" />
</span>
</EuiToolTip>
}
>
<SelectInterval />
</EuiFormRow>
<EuiFlexItem grow={false}>
<SelectInterval />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />

View file

@ -522,6 +522,7 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) {
bucket_span: request.body.bucketSpan,
start: request.body.start !== undefined ? String(request.body.start) : undefined,
end: request.body.end !== undefined ? String(request.body.end) : undefined,
overall_score: request.body.overall_score ?? 0,
},
});
return response.ok({

View file

@ -186,6 +186,7 @@ export const getOverallBucketsSchema = schema.object({
bucketSpan: schema.string(),
start: schema.number(),
end: schema.number(),
overall_score: schema.maybe(schema.number()),
});
export const getCategoriesSchema = schema.object({

View file

@ -15967,7 +15967,6 @@
"xpack.ml.timeSeriesExplorer.forecastsList.viewForecastAriaLabel": "{createdDate} に作成された予測を表示",
"xpack.ml.timeSeriesExplorer.highestAnomalyScoreErrorToastTitle": "最高異常値スコアのレコードの取得中にエラーが発生しました",
"xpack.ml.timeSeriesExplorer.ignoreTimeRangeInfo": "リストには、ジョブのライフタイム中に作成されたすべての異常値の値が含まれます。",
"xpack.ml.timeSeriesExplorer.intervalLabel": "間隔",
"xpack.ml.timeSeriesExplorer.invalidTimeRangeInUrlCallout": "無効なデフォルト時間フィルターのため、このジョブの時間フィルターが全範囲に変更されました。{field}の詳細設定を確認してください。",
"xpack.ml.timeSeriesExplorer.loadingLabel": "読み込み中",
"xpack.ml.timeSeriesExplorer.metricPlotByOption": "関数",
@ -15990,7 +15989,6 @@
"xpack.ml.timeSeriesExplorer.runControls.runNewForecastTitle": "新規予測の実行",
"xpack.ml.timeSeriesExplorer.selectFieldMessage": "{fieldName}を選択してください",
"xpack.ml.timeSeriesExplorer.setManualInputHelperText": "一致する値がありません",
"xpack.ml.timeSeriesExplorer.severityThresholdLabel": "深刻度のしきい値",
"xpack.ml.timeSeriesExplorer.showForecastLabel": "予測を表示",
"xpack.ml.timeSeriesExplorer.showModelBoundsLabel": "モデルバウンドを表示",
"xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle": "{functionLabel} の単独時系列分析",

View file

@ -16195,7 +16195,6 @@
"xpack.ml.timeSeriesExplorer.forecastsList.viewForecastAriaLabel": "查看在 {createdDate} 创建的预测",
"xpack.ml.timeSeriesExplorer.highestAnomalyScoreErrorToastTitle": "在获取异常分数最高的记录时出错",
"xpack.ml.timeSeriesExplorer.ignoreTimeRangeInfo": "该列表包含在作业生命周期内创建的所有异常的值。",
"xpack.ml.timeSeriesExplorer.intervalLabel": "时间间隔",
"xpack.ml.timeSeriesExplorer.invalidTimeRangeInUrlCallout": "由于默认时间筛选无效,时间筛选已更改为此作业的完整范围。检查 {field} 的高级设置。",
"xpack.ml.timeSeriesExplorer.loadingLabel": "正在加载",
"xpack.ml.timeSeriesExplorer.metricPlotByOption": "函数",
@ -16218,7 +16217,6 @@
"xpack.ml.timeSeriesExplorer.runControls.runNewForecastTitle": "运行新的预测",
"xpack.ml.timeSeriesExplorer.selectFieldMessage": "选择 {fieldName}",
"xpack.ml.timeSeriesExplorer.setManualInputHelperText": "无匹配值",
"xpack.ml.timeSeriesExplorer.severityThresholdLabel": "严重性阈值",
"xpack.ml.timeSeriesExplorer.showForecastLabel": "显示预测",
"xpack.ml.timeSeriesExplorer.showModelBoundsLabel": "显示模型边界",
"xpack.ml.timeSeriesExplorer.singleMetricRequiredMessage": "要查看单个指标,请选择 {missingValuesCount, plural, one {{fieldName1} 的值} other {{fieldName1} 和 {fieldName2} 的值}}。",