[ML] Support multiple change point requests (#154237)

This commit is contained in:
Dima Arnautov 2023-04-12 17:58:50 +02:00 committed by GitHub
parent fbe024306c
commit c3e6e70428
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1156 additions and 557 deletions

View file

@ -20,14 +20,13 @@ import type { Filter, Query } from '@kbn/es-query';
import { usePageUrlState } from '@kbn/ml-url-state';
import { useTimefilter, useTimeRangeUpdates } from '@kbn/ml-date-picker';
import { ES_FIELD_TYPES } from '@kbn/field-types';
import { DEFAULT_AGG_FUNCTION } from './constants';
import { useSplitFieldCardinality } from './use_split_field_cardinality';
import { type QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { type ChangePointType, DEFAULT_AGG_FUNCTION } from './constants';
import {
createMergedEsQuery,
getEsQueryFromSavedSearch,
} from '../../application/utils/search_utils';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { useChangePointResults } from './use_change_point_agg_request';
import { type TimeBuckets, TimeBucketsInterval } from '../../../common/time_buckets';
import { useDataSource } from '../../hooks/use_data_source';
import { useTimeBuckets } from '../../hooks/use_time_buckets';
@ -37,10 +36,14 @@ export interface ChangePointDetectionPageUrlState {
pageUrlState: ChangePointDetectionRequestParams;
}
export interface ChangePointDetectionRequestParams {
export interface FieldConfig {
fn: string;
splitField?: string;
metricField: string;
}
export interface ChangePointDetectionRequestParams {
fieldConfigs: FieldConfig[];
interval: string;
query: Query;
filters: Filter[];
@ -54,50 +57,29 @@ export const ChangePointDetectionContext = createContext<{
metricFieldOptions: DataViewField[];
splitFieldsOptions: DataViewField[];
updateRequestParams: (update: Partial<ChangePointDetectionRequestParams>) => void;
isLoading: boolean;
annotations: ChangePointAnnotation[];
resultFilters: Filter[];
updateFilters: (update: Filter[]) => void;
resultQuery: Query;
progress: number;
pagination: {
activePage: number;
pageCount: number;
updatePagination: (newPage: number) => void;
};
splitFieldCardinality: number | null;
combinedQuery: QueryDslQueryContainer;
selectedChangePoints: Record<number, SelectedChangePoint[]>;
setSelectedChangePoints: (update: Record<number, SelectedChangePoint[]>) => void;
}>({
isLoading: false,
splitFieldsOptions: [],
metricFieldOptions: [],
requestParams: {} as ChangePointDetectionRequestParams,
timeBuckets: {} as TimeBuckets,
bucketInterval: {} as TimeBucketsInterval,
updateRequestParams: () => {},
annotations: [],
resultFilters: [],
updateFilters: () => {},
resultQuery: { query: '', language: 'kuery' },
progress: 0,
pagination: {
activePage: 0,
pageCount: 1,
updatePagination: () => {},
},
splitFieldCardinality: null,
combinedQuery: {},
selectedChangePoints: {},
setSelectedChangePoints: () => {},
});
export type ChangePointType =
| 'dip'
| 'spike'
| 'distribution_change'
| 'step_change'
| 'trend_change'
| 'stationary'
| 'non_stationary'
| 'indeterminable';
export interface ChangePointAnnotation {
id: string;
label: string;
reason: string;
timestamp: string;
@ -109,6 +91,8 @@ export interface ChangePointAnnotation {
p_value: number;
}
export type SelectedChangePoint = FieldConfig & ChangePointAnnotation;
export const ChangePointDetectionContextProvider: FC = ({ children }) => {
const { dataView, savedSearch } = useDataSource();
const {
@ -129,8 +113,11 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => {
const timefilter = useTimefilter();
const timeBuckets = useTimeBuckets();
const [resultFilters, setResultFilter] = useState<Filter[]>([]);
const [resultFilters, setResultFilter] = useState<Filter[]>([]);
const [selectedChangePoints, setSelectedChangePoints] = useState<
Record<number, SelectedChangePoint[]>
>({});
const [bucketInterval, setBucketInterval] = useState<TimeBucketsInterval>();
const timeRange = useTimeRangeUpdates();
@ -184,11 +171,13 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => {
const requestParams = useMemo(() => {
const params = { ...requestParamsFromUrl };
if (!params.fn) {
params.fn = DEFAULT_AGG_FUNCTION;
}
if (!params.metricField && metricFieldOptions.length > 0) {
params.metricField = metricFieldOptions[0].name;
if (!params.fieldConfigs) {
params.fieldConfigs = [
{
fn: DEFAULT_AGG_FUNCTION,
metricField: metricFieldOptions[0]?.name,
},
];
}
params.interval = bucketInterval?.expression!;
return params;
@ -246,32 +235,21 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => {
return mergedQuery;
}, [resultFilters, resultQuery, uiSettings, dataView, timeRange]);
const splitFieldCardinality = useSplitFieldCardinality(requestParams.splitField, combinedQuery);
const {
results: annotations,
isLoading: annotationsLoading,
progress,
pagination,
} = useChangePointResults(requestParams, combinedQuery, splitFieldCardinality);
if (!bucketInterval) return null;
const value = {
isLoading: annotationsLoading,
progress,
timeBuckets,
requestParams,
updateRequestParams,
metricFieldOptions,
splitFieldsOptions,
annotations,
bucketInterval,
resultFilters,
updateFilters,
resultQuery,
pagination,
splitFieldCardinality,
combinedQuery,
selectedChangePoints,
setSelectedChangePoints,
};
return (

View file

@ -4,75 +4,46 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useCallback } from 'react';
import React, { FC, useCallback, useState } from 'react';
import {
EuiBadge,
EuiButtonEmpty,
EuiCallOut,
EuiDescriptionList,
EuiEmptyPrompt,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiPagination,
EuiPanel,
EuiProgress,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiSpacer,
EuiText,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Query } from '@kbn/es-query';
import { ChartsGrid } from './charts_grid';
import { FieldsConfig } from './fields_config';
import { useDataSource } from '../../hooks/use_data_source';
import { SPLIT_FIELD_CARDINALITY_LIMIT } from './constants';
import { ChangePointTypeFilter } from './change_point_type_filter';
import { SearchBarWrapper } from './search_bar';
import { ChangePointType, useChangePointDetectionContext } from './change_point_detection_context';
import { MetricFieldSelector } from './metric_field_selector';
import { SplitFieldSelector } from './split_field_selector';
import { FunctionPicker } from './function_picker';
import { ChartComponent } from './chart_component';
import { useChangePointDetectionContext } from './change_point_detection_context';
import { type ChangePointType } from './constants';
export const ChangePointDetectionPage: FC = () => {
const [isFlyoutVisible, setFlyoutVisible] = useState<boolean>(false);
const {
requestParams,
updateRequestParams,
annotations,
resultFilters,
updateFilters,
resultQuery,
progress,
pagination,
splitFieldCardinality,
splitFieldsOptions,
metricFieldOptions,
selectedChangePoints,
} = useChangePointDetectionContext();
const { dataView } = useDataSource();
const setFn = useCallback(
(fn: string) => {
updateRequestParams({ fn });
},
[updateRequestParams]
);
const setSplitField = useCallback(
(splitField: string | undefined) => {
updateRequestParams({ splitField });
},
[updateRequestParams]
);
const setMetricField = useCallback(
(metricField: string) => {
updateRequestParams({ metricField });
},
[updateRequestParams]
);
const setQuery = useCallback(
(query: Query) => {
updateRequestParams({ query });
@ -87,11 +58,6 @@ export const ChangePointDetectionPage: FC = () => {
[updateRequestParams]
);
const selectControlCss = { width: '300px' };
const cardinalityExceeded =
splitFieldCardinality && splitFieldCardinality > SPLIT_FIELD_CARDINALITY_LIMIT;
if (metricFieldOptions.length === 0) {
return (
<EuiCallOut
@ -112,6 +78,8 @@ export const ChangePointDetectionPage: FC = () => {
);
}
const hasSelectedChangePoints = Object.values(selectedChangePoints).some((v) => v.length > 0);
return (
<div data-test-subj="aiopsChangePointDetectionPage">
<SearchBarWrapper
@ -123,72 +91,45 @@ export const ChangePointDetectionPage: FC = () => {
<EuiSpacer size="m" />
<EuiFlexGroup alignItems={'center'}>
<EuiFlexItem grow={false} css={selectControlCss}>
<FunctionPicker value={requestParams.fn} onChange={setFn} />
</EuiFlexItem>
<EuiFlexItem grow={false} css={selectControlCss}>
<MetricFieldSelector value={requestParams.metricField} onChange={setMetricField} />
</EuiFlexItem>
{splitFieldsOptions.length > 0 ? (
<EuiFlexItem grow={false} css={selectControlCss}>
<SplitFieldSelector value={requestParams.splitField} onChange={setSplitField} />
</EuiFlexItem>
) : null}
<EuiFlexItem css={{ visibility: progress === 100 ? 'hidden' : 'visible' }} grow={false}>
<EuiProgress
label={
<FormattedMessage
id="xpack.aiops.changePointDetection.progressBarLabel"
defaultMessage="Fetching change points"
/>
}
value={progress}
max={100}
valueText
size="m"
/>
<EuiSpacer size="s" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{cardinalityExceeded ? (
<>
<EuiCallOut
title={i18n.translate('xpack.aiops.changePointDetection.cardinalityWarningTitle', {
defaultMessage: 'Analysis has been limited',
})}
color="warning"
iconType="warning"
>
<p>
{i18n.translate('xpack.aiops.changePointDetection.cardinalityWarningMessage', {
defaultMessage:
'The "{splitField}" field cardinality is {cardinality} which exceeds the limit of {cardinalityLimit}. Only the first {cardinalityLimit} partitions, sorted by document count, are analyzed.',
values: {
cardinality: splitFieldCardinality,
cardinalityLimit: SPLIT_FIELD_CARDINALITY_LIMIT,
splitField: requestParams.splitField,
},
})}
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
) : null}
<EuiFlexGroup alignItems={'center'} justifyContent={'spaceBetween'}>
<EuiFlexItem grow={false}>
<EuiText size={'s'}>
<FormattedMessage
id="xpack.aiops.changePointDetection.aggregationIntervalTitle"
defaultMessage="Aggregation interval: "
/>
{requestParams.interval}
</EuiText>
<EuiFlexGroup alignItems={'center'}>
<EuiFlexItem grow={false}>
<EuiText size={'s'}>
<FormattedMessage
id="xpack.aiops.changePointDetection.aggregationIntervalTitle"
defaultMessage="Aggregation interval: "
/>
{requestParams.interval}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
hasSelectedChangePoints ? (
''
) : (
<FormattedMessage
id="xpack.aiops.changePointDetection.viewSelectedChartsToltip"
defaultMessage="Select change points to view them in detail."
/>
)
}
>
<EuiButtonEmpty
onClick={() => setFlyoutVisible(!isFlyoutVisible)}
size={'s'}
disabled={!hasSelectedChangePoints}
>
<FormattedMessage
id="xpack.aiops.changePointDetection.viewSelectedButtonLabel"
defaultMessage="View selected"
/>
</EuiButtonEmpty>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ minWidth: '400px' }}>
<ChangePointTypeFilter
@ -200,116 +141,29 @@ export const ChangePointDetectionPage: FC = () => {
<EuiSpacer size="s" />
{annotations.length === 0 && progress === 100 ? (
<>
<EuiEmptyPrompt
iconType="search"
title={
<h2>
<FieldsConfig />
{isFlyoutVisible ? (
<EuiFlyout
ownFocus
onClose={setFlyoutVisible.bind(null, false)}
aria-labelledby={'change_point_charts'}
size={'l'}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id={'change_point_charts'}>
<FormattedMessage
id="xpack.aiops.changePointDetection.noChangePointsFoundTitle"
defaultMessage="No change points found"
id="xpack.aiops.changePointDetection.selectedChangePointsHeader"
defaultMessage="Selected change points"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.aiops.changePointDetection.noChangePointsFoundMessage"
defaultMessage="Detect statistically significant change points such as dips, spikes, and distribution changes in a metric. Select a metric and set a time range to start detecting change points in your data."
/>
</p>
}
/>
</>
) : null}
<EuiFlexGrid columns={annotations.length >= 2 ? 2 : 1} responsive gutterSize={'m'}>
{annotations.map((v) => {
return (
<EuiFlexItem key={v.group?.value ?? 'single_metric'}>
<EuiPanel paddingSize="s" hasBorder hasShadow={false}>
<EuiFlexGroup
alignItems={'center'}
justifyContent={'spaceBetween'}
gutterSize={'s'}
>
<EuiFlexItem grow={false}>
{v.group ? (
<EuiDescriptionList
type="inline"
listItems={[{ title: v.group.name, description: v.group.value }]}
/>
) : null}
{v.reason ? (
<EuiToolTip position="top" content={v.reason}>
<EuiIcon
tabIndex={0}
color={'warning'}
type="warning"
title={i18n.translate(
'xpack.aiops.changePointDetection.notResultsWarning',
{
defaultMessage: 'No change point agg results warning',
}
)}
/>
</EuiToolTip>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color={'subdued'} size={'s'}>
{requestParams.fn}({requestParams.metricField})
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup justifyContent={'spaceBetween'} alignItems={'center'}>
{v.p_value !== undefined ? (
<EuiFlexItem grow={false}>
<EuiDescriptionList
type="inline"
listItems={[
{
title: (
<FormattedMessage
id="xpack.aiops.explainLogRateSpikes.spikeAnalysisTableGroups.pValueLabel"
defaultMessage="p-value"
/>
),
description: v.p_value.toPrecision(3),
},
]}
/>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">{v.type}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
<ChartComponent annotation={v} />
</EuiPanel>
</EuiFlexItem>
);
})}
</EuiFlexGrid>
<EuiSpacer size="m" />
{pagination.pageCount > 1 ? (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiPagination
pageCount={pagination.pageCount}
activePage={pagination.activePage}
onPageClick={pagination.updatePagination}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<ChartsGrid changePoints={selectedChangePoints} />
</EuiFlyoutBody>
</EuiFlyout>
) : null}
</div>
);

View file

@ -18,7 +18,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isDefined } from '@kbn/ml-is-defined';
import { type ChangePointType } from './change_point_detection_context';
import { type ChangePointType, CHANGE_POINT_TYPES } from './constants';
export type ChangePointUIValue = ChangePointType | undefined;
@ -29,32 +29,32 @@ interface ChangePointTypeFilterProps {
const changePointTypes: Array<{ value: ChangePointType; description: string }> = [
{
value: 'dip',
value: CHANGE_POINT_TYPES.DIP,
description: i18n.translate('xpack.aiops.changePointDetection.dipDescription', {
defaultMessage: 'A significant dip occurs at this point.',
}),
},
{
value: 'spike',
value: CHANGE_POINT_TYPES.SPIKE,
description: i18n.translate('xpack.aiops.changePointDetection.spikeDescription', {
defaultMessage: 'A significant spike occurs at this point.',
}),
},
{
value: 'distribution_change',
value: CHANGE_POINT_TYPES.DISTRIBUTION_CHANGE,
description: i18n.translate('xpack.aiops.changePointDetection.distributionChangeDescription', {
defaultMessage: 'The overall distribution of the values has changed significantly.',
}),
},
{
value: 'step_change',
value: CHANGE_POINT_TYPES.STEP_CHANGE,
description: i18n.translate('xpack.aiops.changePointDetection.stepChangeDescription', {
defaultMessage:
'The change indicates a statistically significant step up or down in value distribution.',
}),
},
{
value: 'trend_change',
value: CHANGE_POINT_TYPES.TREND_CHANGE,
description: i18n.translate('xpack.aiops.changePointDetection.trendChangeDescription', {
defaultMessage: 'An overall trend change occurs at this point.',
}),
@ -134,6 +134,7 @@ export const ChangePointTypeFilter: FC<ChangePointTypeFilterProps> = ({ value, o
isClearable
data-test-subj="aiopsChangePointTypeFilter"
renderOption={renderOption}
compressed
/>
</EuiFormRow>
);

View file

@ -0,0 +1,226 @@
/*
* 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 {
EuiBadge,
type EuiBasicTableColumn,
EuiEmptyPrompt,
EuiIcon,
EuiInMemoryTable,
EuiToolTip,
} from '@elastic/eui';
import React, { type FC, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types';
import { useCommonChartProps } from './use_common_chart_props';
import {
type ChangePointAnnotation,
FieldConfig,
SelectedChangePoint,
} from './change_point_detection_context';
import { type ChartComponentProps } from './chart_component';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
export interface ChangePointsTableProps {
annotations: ChangePointAnnotation[];
fieldConfig: FieldConfig;
isLoading: boolean;
onSelectionChange: (update: SelectedChangePoint[]) => void;
}
export const ChangePointsTable: FC<ChangePointsTableProps> = ({
isLoading,
annotations,
fieldConfig,
onSelectionChange,
}) => {
const { fieldFormats } = useAiopsAppContext();
const dateFormatter = useMemo(() => fieldFormats.deserialize({ id: 'date' }), [fieldFormats]);
const defaultSorting = {
sort: {
field: 'p_value',
// Lower p_value indicates a bigger change point, hence the asc sorting
direction: 'asc' as const,
},
};
const columns: Array<EuiBasicTableColumn<ChangePointAnnotation>> = [
{
field: 'timestamp',
name: i18n.translate('xpack.aiops.changePointDetection.timeColumn', {
defaultMessage: 'Time',
}),
sortable: true,
truncateText: false,
width: '230px',
render: (timestamp: ChangePointAnnotation['timestamp']) => dateFormatter.convert(timestamp),
},
{
name: i18n.translate('xpack.aiops.changePointDetection.previewColumn', {
defaultMessage: 'Preview',
}),
align: 'center',
width: '200px',
height: '80px',
truncateText: false,
valign: 'middle',
css: { display: 'block', padding: 0 },
render: (annotation: ChangePointAnnotation) => {
return <MiniChartPreview annotation={annotation} fieldConfig={fieldConfig} />;
},
},
{
field: 'type',
name: i18n.translate('xpack.aiops.changePointDetection.typeColumn', {
defaultMessage: 'Type',
}),
sortable: true,
truncateText: false,
render: (type: ChangePointAnnotation['type']) => <EuiBadge color="hollow">{type}</EuiBadge>,
},
{
field: 'p_value',
name: (
<EuiToolTip
content={i18n.translate('xpack.aiops.changePointDetection.pValueTooltip', {
defaultMessage:
'Indicates how extreme the change is. Lower values indicate greater change.',
})}
>
<span>
{i18n.translate(
'xpack.aiops.explainLogRateSpikes.spikeAnalysisTableGroups.pValueLabel',
{
defaultMessage: 'p-value',
}
)}
<EuiIcon size="s" color="subdued" type="questionInCircle" className="eui-alignTop" />
</span>
</EuiToolTip>
),
sortable: true,
truncateText: false,
render: (pValue: ChangePointAnnotation['p_value']) => pValue.toPrecision(3),
},
...(fieldConfig.splitField
? [
{
field: 'group.name',
name: i18n.translate('xpack.aiops.changePointDetection.fieldNameColumn', {
defaultMessage: 'Field name',
}),
truncateText: false,
},
{
field: 'group.value',
name: i18n.translate('xpack.aiops.changePointDetection.fieldValueColumn', {
defaultMessage: 'Field value',
}),
truncateText: false,
sortable: true,
},
]
: []),
];
const selectionValue = useMemo<EuiTableSelectionType<ChangePointAnnotation>>(() => {
return {
selectable: (item) => true,
onSelectionChange: (selection) => {
onSelectionChange(
selection.map((s) => {
return {
...s,
...fieldConfig,
};
})
);
},
};
}, [fieldConfig, onSelectionChange]);
return (
<EuiInMemoryTable<ChangePointAnnotation>
itemId={'id'}
selection={selectionValue}
loading={isLoading}
items={annotations}
columns={columns}
pagination={{ pageSizeOptions: [5, 10, 15] }}
sorting={defaultSorting}
message={
isLoading ? (
<EuiEmptyPrompt
iconType="search"
title={
<h2>
<FormattedMessage
id="xpack.aiops.changePointDetection.fetchingChangePointsTitle"
defaultMessage="Fetching change points..."
/>
</h2>
}
/>
) : (
<EuiEmptyPrompt
iconType="search"
title={
<h2>
<FormattedMessage
id="xpack.aiops.changePointDetection.noChangePointsFoundTitle"
defaultMessage="No change points found"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.aiops.changePointDetection.noChangePointsFoundMessage"
defaultMessage="Detect statistically significant change points such as dips, spikes, and distribution changes in a metric. Select a metric and set a time range to start detecting change points in your data."
/>
</p>
}
/>
)
}
/>
);
};
export const MiniChartPreview: FC<ChartComponentProps> = ({ fieldConfig, annotation }) => {
const {
lens: { EmbeddableComponent },
} = useAiopsAppContext();
const { filters, query, attributes, timeRange } = useCommonChartProps({
annotation,
fieldConfig,
previewMode: true,
});
return (
<div>
<EmbeddableComponent
id={`mini_changePointChart_${annotation.group ? annotation.group.value : annotation.label}`}
style={{ height: 80 }}
timeRange={timeRange}
query={query}
filters={filters}
// @ts-ignore
attributes={attributes}
renderMode={'preview'}
executionContext={{
type: 'aiops_change_point_detection_chart',
name: 'Change point detection',
}}
/>
</div>
);
};

View file

@ -5,230 +5,41 @@
* 2.0.
*/
import React, { FC, useMemo } from 'react';
import { type TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { FilterStateStore } from '@kbn/es-query';
import { useTimeRangeUpdates } from '@kbn/ml-date-picker';
import { useDataSource } from '../../hooks/use_data_source';
import React, { FC } from 'react';
import { useCommonChartProps } from './use_common_chart_props';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import {
type ChangePointAnnotation,
useChangePointDetectionContext,
} from './change_point_detection_context';
import { fnOperationTypeMapping } from './constants';
import type { ChangePointAnnotation, FieldConfig } from './change_point_detection_context';
export interface ChartComponentProps {
fieldConfig: FieldConfig;
annotation: ChangePointAnnotation;
}
export const ChartComponent: FC<ChartComponentProps> = React.memo(({ annotation }) => {
export const ChartComponent: FC<ChartComponentProps> = React.memo(({ annotation, fieldConfig }) => {
const {
lens: { EmbeddableComponent },
} = useAiopsAppContext();
const timeRange = useTimeRangeUpdates();
const { dataView } = useDataSource();
const { requestParams, bucketInterval, resultQuery, resultFilters } =
useChangePointDetectionContext();
const filters = useMemo(() => {
return [
...resultFilters,
...(annotation.group
? [
{
meta: {
index: dataView.id!,
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: annotation.group.name,
params: {
query: annotation.group.value,
},
},
query: {
match_phrase: {
[annotation.group.name]: annotation.group.value,
},
},
$state: {
store: FilterStateStore.APP_STATE,
},
},
]
: []),
];
}, [dataView.id, annotation.group, resultFilters]);
// @ts-ignore incorrect types for attributes
const attributes = useMemo<TypedLensByValueInput['attributes']>(() => {
return {
title: annotation.group?.value ?? '',
description: '',
visualizationType: 'lnsXY',
type: 'lens',
references: [
{
type: 'index-pattern',
id: dataView.id!,
name: 'indexpattern-datasource-layer-2d61a885-abb0-4d4e-a5f9-c488caec3c22',
},
{
type: 'index-pattern',
id: dataView.id!,
name: 'xy-visualization-layer-8d26ab67-b841-4877-9d02-55bf270f9caf',
},
],
state: {
visualization: {
yLeftExtent: {
mode: 'dataBounds',
},
legend: {
isVisible: false,
position: 'right',
},
valueLabels: 'hide',
fittingFunction: 'None',
axisTitlesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
tickLabelsVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
labelsOrientation: {
x: 0,
yLeft: 0,
yRight: 0,
},
gridlinesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
preferredSeriesType: 'line',
layers: [
{
layerId: '2d61a885-abb0-4d4e-a5f9-c488caec3c22',
accessors: ['e9f26d17-fb36-4982-8539-03f1849cbed0'],
position: 'top',
seriesType: 'line',
showGridlines: false,
layerType: 'data',
xAccessor: '877e6638-bfaa-43ec-afb9-2241dc8e1c86',
},
...(annotation.timestamp
? [
{
layerId: '8d26ab67-b841-4877-9d02-55bf270f9caf',
layerType: 'annotations',
annotations: [
{
type: 'manual',
label: annotation.label,
icon: 'triangle',
textVisibility: true,
key: {
type: 'point_in_time',
timestamp: annotation.timestamp,
},
id: 'a8fb297c-8d96-4011-93c0-45af110d5302',
isHidden: false,
color: '#F04E98',
lineStyle: 'solid',
lineWidth: 2,
outside: false,
},
],
ignoreGlobalFilters: true,
},
]
: []),
],
},
query: resultQuery,
filters,
datasourceStates: {
formBased: {
layers: {
'2d61a885-abb0-4d4e-a5f9-c488caec3c22': {
columns: {
'877e6638-bfaa-43ec-afb9-2241dc8e1c86': {
label: dataView.timeFieldName,
dataType: 'date',
operationType: 'date_histogram',
sourceField: dataView.timeFieldName,
isBucketed: true,
scale: 'interval',
params: {
interval: bucketInterval.expression,
includeEmptyRows: true,
dropPartials: false,
},
},
'e9f26d17-fb36-4982-8539-03f1849cbed0': {
label: `${requestParams.fn}(${requestParams.metricField})`,
dataType: 'number',
operationType: fnOperationTypeMapping[requestParams.fn],
sourceField: requestParams.metricField,
isBucketed: false,
scale: 'ratio',
params: {
emptyAsNull: true,
},
},
},
columnOrder: [
'877e6638-bfaa-43ec-afb9-2241dc8e1c86',
'e9f26d17-fb36-4982-8539-03f1849cbed0',
],
incompleteColumns: {},
},
},
},
textBased: {
layers: {},
},
},
internalReferences: [],
adHocDataViews: {},
},
};
}, [
annotation.group?.value,
annotation.timestamp,
annotation.label,
dataView.id,
dataView.timeFieldName,
resultQuery,
filters,
bucketInterval.expression,
requestParams.fn,
requestParams.metricField,
]);
const { filters, timeRange, query, attributes } = useCommonChartProps({
fieldConfig,
annotation,
});
return (
<EmbeddableComponent
id={`changePointChart_${annotation.group ? annotation.group.value : annotation.label}`}
style={{ height: 350 }}
timeRange={timeRange}
query={resultQuery}
query={query}
filters={filters}
// @ts-ignore
attributes={attributes}
renderMode={'view'}
executionContext={{
type: 'aiops_change_point_detection_chart',
name: 'Change point detection',
}}
disableTriggers
/>
);
});

View file

@ -0,0 +1,148 @@
/*
* 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, { type FC, useMemo, useState } from 'react';
import {
EuiBadge,
EuiDescriptionList,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiPagination,
EuiPanel,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { SelectedChangePoint } from './change_point_detection_context';
import { ChartComponent } from './chart_component';
const CHARTS_PER_PAGE = 6;
interface ChartsGridProps {
changePoints: Record<number, SelectedChangePoint[]>;
}
export const ChartsGrid: FC<ChartsGridProps> = ({ changePoints: changePointsDict }) => {
const changePoints = useMemo(() => {
return Object.values(changePointsDict).flat();
}, [changePointsDict]);
const [activePage, setActivePage] = useState<number>(0);
const resultPerPage = useMemo(() => {
const start = activePage * CHARTS_PER_PAGE;
return changePoints.slice(start, start + CHARTS_PER_PAGE);
}, [changePoints, activePage]);
const pagination = useMemo(() => {
return {
activePage,
pageCount: Math.ceil((changePoints.length ?? 0) / CHARTS_PER_PAGE),
updatePagination: setActivePage,
};
}, [activePage, changePoints.length]);
return (
<>
<EuiFlexGrid columns={resultPerPage.length >= 2 ? 2 : 1} responsive gutterSize={'m'}>
{resultPerPage.map((v, index) => {
const key = `${index}_${v.group?.value ?? 'single_metric'}_${v.fn}_${v.metricField}_${
v.timestamp
}_${v.p_value}`;
return (
<EuiFlexItem key={key}>
<EuiPanel paddingSize="s" hasBorder hasShadow={false}>
<EuiFlexGroup
alignItems={'center'}
justifyContent={'spaceBetween'}
gutterSize={'s'}
>
<EuiFlexItem grow={false}>
{v.group ? (
<EuiDescriptionList
type="inline"
listItems={[{ title: v.group.name, description: v.group.value }]}
/>
) : null}
{v.reason ? (
<EuiToolTip position="top" content={v.reason}>
<EuiIcon
tabIndex={0}
color={'warning'}
type="warning"
title={i18n.translate(
'xpack.aiops.changePointDetection.notResultsWarning',
{
defaultMessage: 'No change point agg results warning',
}
)}
/>
</EuiToolTip>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color={'subdued'} size={'s'}>
{v.fn}({v.metricField})
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup justifyContent={'spaceBetween'} alignItems={'center'}>
{v.p_value !== undefined ? (
<EuiFlexItem grow={false}>
<EuiDescriptionList
type="inline"
listItems={[
{
title: (
<FormattedMessage
id="xpack.aiops.explainLogRateSpikes.spikeAnalysisTableGroups.pValueLabel"
defaultMessage="p-value"
/>
),
description: v.p_value.toPrecision(3),
},
]}
/>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">{v.type}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
<ChartComponent
fieldConfig={{ splitField: v.splitField, fn: v.fn, metricField: v.metricField }}
annotation={v}
/>
</EuiPanel>
</EuiFlexItem>
);
})}
</EuiFlexGrid>
{pagination.pageCount > 1 ? (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiPagination
pageCount={pagination.pageCount}
activePage={pagination.activePage}
onPageClick={pagination.updatePagination}
/>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
</>
);
};

View file

@ -6,10 +6,10 @@
*/
export const fnOperationTypeMapping: Record<string, string> = {
min: 'min',
max: 'max',
sum: 'sum',
avg: 'average',
max: 'max',
min: 'min',
sum: 'sum',
} as const;
export const DEFAULT_AGG_FUNCTION = 'avg';
@ -17,3 +17,24 @@ export const DEFAULT_AGG_FUNCTION = 'avg';
export const SPLIT_FIELD_CARDINALITY_LIMIT = 10000;
export const COMPOSITE_AGG_SIZE = 500;
export const CHANGE_POINT_TYPES = {
DIP: 'dip',
SPIKE: 'spike',
DISTRIBUTION_CHANGE: 'distribution_change',
STEP_CHANGE: 'step_change',
TREND_CHANGE: 'trend_change',
STATIONARY: 'stationary',
NON_STATIONARY: 'non_stationary',
INDETERMINABLE: 'indeterminable',
} as const;
export type ChangePointType = typeof CHANGE_POINT_TYPES[keyof typeof CHANGE_POINT_TYPES];
export const EXCLUDED_CHANGE_POINT_TYPES = new Set<ChangePointType>([
CHANGE_POINT_TYPES.STATIONARY,
CHANGE_POINT_TYPES.NON_STATIONARY,
CHANGE_POINT_TYPES.INDETERMINABLE,
]);
export const MAX_CHANGE_POINT_CONFIGS = 6;

View file

@ -0,0 +1,301 @@
/*
* 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, { type FC, useCallback } from 'react';
import {
EuiAccordion,
EuiButton,
EuiButtonIcon,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiProgress,
EuiSpacer,
useGeneratedHtmlId,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { ChangePointsTable } from './change_points_table';
import { MAX_CHANGE_POINT_CONFIGS, SPLIT_FIELD_CARDINALITY_LIMIT } from './constants';
import { FunctionPicker } from './function_picker';
import { MetricFieldSelector } from './metric_field_selector';
import { SplitFieldSelector } from './split_field_selector';
import {
type ChangePointAnnotation,
type FieldConfig,
SelectedChangePoint,
useChangePointDetectionContext,
} from './change_point_detection_context';
import { useChangePointResults } from './use_change_point_agg_request';
import { useSplitFieldCardinality } from './use_split_field_cardinality';
const selectControlCss = { width: '300px' };
/**
* Contains panels with controls and change point results.
*/
export const FieldsConfig: FC = () => {
const {
requestParams: { fieldConfigs },
updateRequestParams,
selectedChangePoints,
setSelectedChangePoints,
} = useChangePointDetectionContext();
const onChange = useCallback(
(update: FieldConfig, index: number) => {
fieldConfigs.splice(index, 1, update);
updateRequestParams({ fieldConfigs });
},
[updateRequestParams, fieldConfigs]
);
const onAdd = useCallback(() => {
const update = [...fieldConfigs];
update.push(update[update.length - 1]);
updateRequestParams({ fieldConfigs: update });
}, [updateRequestParams, fieldConfigs]);
const onRemove = useCallback(
(index: number) => {
fieldConfigs.splice(index, 1);
updateRequestParams({ fieldConfigs });
delete selectedChangePoints[index];
setSelectedChangePoints({
...selectedChangePoints,
});
},
[updateRequestParams, fieldConfigs, setSelectedChangePoints, selectedChangePoints]
);
const onSelectionChange = useCallback(
(update: SelectedChangePoint[], index: number) => {
setSelectedChangePoints({
...selectedChangePoints,
[index]: update,
});
},
[setSelectedChangePoints, selectedChangePoints]
);
return (
<>
{fieldConfigs.map((fieldConfig, index) => {
const key = index;
return (
<React.Fragment key={key}>
<FieldPanel
fieldConfig={fieldConfig}
onChange={(value) => onChange(value, index)}
onRemove={onRemove.bind(null, index)}
removeDisabled={fieldConfigs.length === 1}
onSelectionChange={(update) => {
onSelectionChange(update, index);
}}
/>
<EuiSpacer size="s" />
</React.Fragment>
);
})}
<EuiButton onClick={onAdd} disabled={fieldConfigs.length >= MAX_CHANGE_POINT_CONFIGS}>
<FormattedMessage
id="xpack.aiops.changePointDetection.addButtonLabel"
defaultMessage="Add"
/>
</EuiButton>
</>
);
};
export interface FieldPanelProps {
fieldConfig: FieldConfig;
removeDisabled: boolean;
onChange: (update: FieldConfig) => void;
onRemove: () => void;
onSelectionChange: (update: SelectedChangePoint[]) => void;
}
/**
* Components that combines field config and state for change point response.
* @param fieldConfig
* @param onChange
* @param onRemove
* @param removeDisabled
* @constructor
*/
const FieldPanel: FC<FieldPanelProps> = ({
fieldConfig,
onChange,
onRemove,
removeDisabled,
onSelectionChange,
}) => {
const { combinedQuery, requestParams } = useChangePointDetectionContext();
const splitFieldCardinality = useSplitFieldCardinality(fieldConfig.splitField, combinedQuery);
const {
results: annotations,
isLoading: annotationsLoading,
progress,
} = useChangePointResults(fieldConfig, requestParams, combinedQuery, splitFieldCardinality);
const accordionId = useGeneratedHtmlId({ prefix: 'fieldConfig' });
return (
<EuiPanel paddingSize="s" hasBorder hasShadow={false}>
<EuiAccordion
id={accordionId}
initialIsOpen={true}
buttonElement={'div'}
buttonContent={
<FieldsControls fieldConfig={fieldConfig} onChange={onChange}>
<EuiFlexItem css={{ visibility: progress === null ? 'hidden' : 'visible' }} grow={true}>
<EuiProgress
label={
<FormattedMessage
id="xpack.aiops.changePointDetection.progressBarLabel"
defaultMessage="Fetching change points"
/>
}
value={progress ?? 0}
max={100}
valueText
size="m"
/>
<EuiSpacer size="s" />
</EuiFlexItem>
</FieldsControls>
}
extraAction={
<EuiButtonIcon
disabled={removeDisabled}
aria-label="trash"
iconType="trash"
color="danger"
onClick={onRemove}
/>
}
paddingSize="s"
>
<ChangePointResults
fieldConfig={fieldConfig}
isLoading={annotationsLoading}
annotations={annotations}
splitFieldCardinality={splitFieldCardinality}
onSelectionChange={onSelectionChange}
/>
</EuiAccordion>
</EuiPanel>
);
};
interface FieldsControlsProps {
fieldConfig: FieldConfig;
onChange: (update: FieldConfig) => void;
}
/**
* Renders controls for fields selection and emits updates on change.
*/
export const FieldsControls: FC<FieldsControlsProps> = ({ fieldConfig, onChange, children }) => {
const { splitFieldsOptions } = useChangePointDetectionContext();
const onChangeFn = useCallback(
(field: keyof FieldConfig, value: string) => {
const result = { ...fieldConfig, [field]: value };
onChange(result);
},
[onChange, fieldConfig]
);
return (
<EuiFlexGroup alignItems={'center'} responsive={true} wrap={true} gutterSize={'m'}>
<EuiFlexItem grow={false} css={{ width: '200px' }}>
<FunctionPicker value={fieldConfig.fn} onChange={(v) => onChangeFn('fn', v)} />
</EuiFlexItem>
<EuiFlexItem grow={true} css={selectControlCss}>
<MetricFieldSelector
value={fieldConfig.metricField!}
onChange={(v) => onChangeFn('metricField', v)}
/>
</EuiFlexItem>
{splitFieldsOptions.length > 0 ? (
<EuiFlexItem grow={true} css={selectControlCss}>
<SplitFieldSelector
value={fieldConfig.splitField}
onChange={(v) => onChangeFn('splitField', v!)}
/>
</EuiFlexItem>
) : null}
{children}
</EuiFlexGroup>
);
};
interface ChangePointResultsProps {
fieldConfig: FieldConfig;
splitFieldCardinality: number | null;
isLoading: boolean;
annotations: ChangePointAnnotation[];
onSelectionChange: (update: SelectedChangePoint[]) => void;
}
/**
* Handles request and rendering results of the change point with provided config.
*/
export const ChangePointResults: FC<ChangePointResultsProps> = ({
fieldConfig,
splitFieldCardinality,
isLoading,
annotations,
onSelectionChange,
}) => {
const cardinalityExceeded =
splitFieldCardinality && splitFieldCardinality > SPLIT_FIELD_CARDINALITY_LIMIT;
return (
<>
<EuiSpacer size="s" />
{cardinalityExceeded ? (
<>
<EuiCallOut
title={i18n.translate('xpack.aiops.changePointDetection.cardinalityWarningTitle', {
defaultMessage: 'Analysis has been limited',
})}
color="warning"
iconType="warning"
>
<p>
{i18n.translate('xpack.aiops.changePointDetection.cardinalityWarningMessage', {
defaultMessage:
'The "{splitField}" field cardinality is {cardinality} which exceeds the limit of {cardinalityLimit}. Only the first {cardinalityLimit} partitions, sorted by document count, are analyzed.',
values: {
cardinality: splitFieldCardinality,
cardinalityLimit: SPLIT_FIELD_CARDINALITY_LIMIT,
splitField: fieldConfig.splitField,
},
})}
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
) : null}
<ChangePointsTable
annotations={annotations}
fieldConfig={fieldConfig}
isLoading={isLoading}
onSelectionChange={onSelectionChange}
/>
</>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { EuiButtonGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { fnOperationTypeMapping } from './constants';
@ -18,21 +18,22 @@ interface FunctionPickerProps {
export const FunctionPicker: FC<FunctionPickerProps> = React.memo(({ value, onChange }) => {
const options = Object.keys(fnOperationTypeMapping).map((v) => {
return {
value: v,
text: v,
id: v,
label: v,
};
});
return (
<EuiFormRow>
<EuiSelect
options={options}
value={value}
onChange={(e) => onChange(e.target.value)}
prepend={i18n.translate('xpack.aiops.changePointDetection.selectFunctionLabel', {
defaultMessage: 'Function',
})}
/>
</EuiFormRow>
<EuiButtonGroup
legend={i18n.translate('xpack.aiops.changePointDetection.selectFunctionLabel', {
defaultMessage: 'Function',
})}
options={options}
idSelected={value}
onChange={(id) => onChange(id)}
isFullWidth
buttonSize="compressed"
onClick={(e) => e.stopPropagation()}
/>
);
});

View file

@ -38,6 +38,7 @@ export const MetricFieldSelector: FC<MetricFieldSelectorProps> = React.memo(
return (
<EuiFormRow>
<EuiComboBox
compressed
prepend={i18n.translate('xpack.aiops.changePointDetection.selectMetricFieldLabel', {
defaultMessage: 'Metric field',
})}
@ -47,6 +48,7 @@ export const MetricFieldSelector: FC<MetricFieldSelectorProps> = React.memo(
onChange={onChangeCallback}
isClearable={false}
data-test-subj="aiopsChangePointMetricField"
onClick={(e) => e.stopPropagation()}
/>
</EuiFormRow>
);

View file

@ -47,6 +47,7 @@ export const SplitFieldSelector: FC<SplitFieldSelectorProps> = React.memo(({ val
return (
<EuiFormRow>
<EuiComboBox
compressed
prepend={i18n.translate('xpack.aiops.changePointDetection.selectSpitFieldLabel', {
defaultMessage: 'Split field',
})}
@ -56,6 +57,7 @@ export const SplitFieldSelector: FC<SplitFieldSelectorProps> = React.memo(({ val
onChange={onChangeCallback}
isClearable
data-test-subj="aiopsChangePointSplitField"
onClick={(e) => e.stopPropagation()}
/>
</EuiFormRow>
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { useEffect, useCallback, useState, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { i18n } from '@kbn/i18n';
import { useRefresh } from '@kbn/ml-date-picker';
@ -14,11 +14,16 @@ import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import {
ChangePointAnnotation,
ChangePointDetectionRequestParams,
ChangePointType,
FieldConfig,
} from './change_point_detection_context';
import { useDataSource } from '../../hooks/use_data_source';
import { useCancellableSearch } from '../../hooks/use_cancellable_search';
import { SPLIT_FIELD_CARDINALITY_LIMIT, COMPOSITE_AGG_SIZE } from './constants';
import {
type ChangePointType,
COMPOSITE_AGG_SIZE,
EXCLUDED_CHANGE_POINT_TYPES,
SPLIT_FIELD_CARDINALITY_LIMIT,
} from './constants';
interface RequestOptions {
index: string;
@ -104,9 +109,8 @@ function getChangePointDetectionRequestBody(
};
}
const CHARTS_PER_PAGE = 6;
export function useChangePointResults(
fieldConfig: FieldConfig,
requestParams: ChangePointDetectionRequestParams,
query: QueryDslQueryContainer,
splitFieldCardinality: number | null
@ -120,10 +124,12 @@ export function useChangePointResults(
const refresh = useRefresh();
const [results, setResults] = useState<ChangePointAnnotation[]>([]);
const [activePage, setActivePage] = useState<number>(0);
const [progress, setProgress] = useState<number>(0);
/**
* null also means the fetching has been complete
*/
const [progress, setProgress] = useState<number | null>(null);
const isSingleMetric = !isDefined(requestParams.splitField);
const isSingleMetric = !isDefined(fieldConfig.splitField);
const totalAggPages = useMemo<number>(() => {
return Math.ceil(
@ -131,12 +137,10 @@ export function useChangePointResults(
);
}, [splitFieldCardinality]);
const { runRequest, cancelRequest, isLoading } = useCancellableSearch();
const { runRequest, cancelRequest } = useCancellableSearch();
const reset = useCallback(() => {
cancelRequest();
setProgress(0);
setActivePage(0);
setResults([]);
}, [cancelRequest]);
@ -144,18 +148,18 @@ export function useChangePointResults(
async (pageNumber: number = 1, afterKey?: string) => {
try {
if (!isSingleMetric && !totalAggPages) {
setProgress(100);
setProgress(null);
return;
}
const requestPayload = getChangePointDetectionRequestBody(
{
index: dataView.getIndexPattern(),
fn: requestParams.fn,
fn: fieldConfig.fn,
timeInterval: requestParams.interval,
metricField: requestParams.metricField,
metricField: fieldConfig.metricField,
timeField: dataView.timeFieldName!,
splitField: requestParams.splitField,
splitField: fieldConfig.splitField,
afterKey,
},
query
@ -166,63 +170,68 @@ export function useChangePointResults(
>(requestPayload);
if (result === null) {
setProgress(100);
setProgress(null);
return;
}
const isFetchCompleted = !(
result.rawResponse.aggregations?.groupings?.after_key?.splitFieldTerm &&
pageNumber < totalAggPages
);
const buckets = (
isSingleMetric
? [result.rawResponse.aggregations]
: result.rawResponse.aggregations.groupings.buckets
) as ChangePointAggResponse['aggregations']['groupings']['buckets'];
setProgress(Math.min(Math.round((pageNumber / totalAggPages) * 100), 100));
setProgress(
isFetchCompleted ? null : Math.min(Math.round((pageNumber / totalAggPages) * 100), 100)
);
let groups = buckets.map((v) => {
const changePointType = Object.keys(v.change_point_request.type)[0] as ChangePointType;
const timeAsString = v.change_point_request.bucket?.key;
const rawPValue = v.change_point_request.type[changePointType].p_value;
let groups = buckets
.map((v) => {
const changePointType = Object.keys(v.change_point_request.type)[0] as ChangePointType;
const timeAsString = v.change_point_request.bucket?.key;
const rawPValue = v.change_point_request.type[changePointType].p_value;
return {
...(isSingleMetric
? {}
: {
group: {
name: requestParams.splitField,
value: v.key.splitFieldTerm,
},
}),
type: changePointType,
p_value: rawPValue,
timestamp: timeAsString,
label: changePointType,
reason: v.change_point_request.type[changePointType].reason,
} as ChangePointAnnotation;
});
return {
...(isSingleMetric
? {}
: {
group: {
name: fieldConfig.splitField,
value: v.key.splitFieldTerm,
},
}),
type: changePointType,
p_value: rawPValue,
timestamp: timeAsString,
label: changePointType,
reason: v.change_point_request.type[changePointType].reason,
id: isSingleMetric
? 'single_metric'
: `${fieldConfig.splitField}_${v.key?.splitFieldTerm}`,
} as ChangePointAnnotation;
})
.filter((v) => !EXCLUDED_CHANGE_POINT_TYPES.has(v.type));
if (Array.isArray(requestParams.changePointType)) {
groups = groups.filter((v) => requestParams.changePointType!.includes(v.type));
}
setResults((prev) => {
return (
(prev ?? [])
.concat(groups)
// Lower p_value indicates a bigger change point, hence the acs sorting
.sort((a, b) => a.p_value - b.p_value)
);
return (prev ?? []).concat(groups);
});
if (
result.rawResponse.aggregations?.groupings?.after_key?.splitFieldTerm &&
pageNumber < totalAggPages
!isFetchCompleted &&
result.rawResponse.aggregations?.groupings?.after_key?.splitFieldTerm
) {
await fetchResults(
pageNumber + 1,
result.rawResponse.aggregations.groupings.after_key.splitFieldTerm
);
} else {
setProgress(100);
}
} catch (e) {
toasts.addError(e, {
@ -232,35 +241,53 @@ export function useChangePointResults(
});
}
},
[runRequest, requestParams, query, dataView, totalAggPages, toasts, isSingleMetric]
[
runRequest,
requestParams.interval,
requestParams.changePointType,
fieldConfig.fn,
fieldConfig.metricField,
fieldConfig.splitField,
query,
dataView,
totalAggPages,
toasts,
isSingleMetric,
]
);
useEffect(
function fetchResultsOnInputChange() {
setProgress(0);
reset();
if (fieldConfig.splitField && splitFieldCardinality === null) {
// wait for cardinality to be resolved
return;
}
fetchResults();
return () => {
cancelRequest();
};
},
[requestParams, query, splitFieldCardinality, fetchResults, reset, cancelRequest, refresh]
[
requestParams.interval,
requestParams.changePointType,
fieldConfig.fn,
fieldConfig.metricField,
fieldConfig.splitField,
query,
splitFieldCardinality,
fetchResults,
reset,
cancelRequest,
refresh,
]
);
const pagination = useMemo(() => {
return {
activePage,
pageCount: Math.round((results.length ?? 0) / CHARTS_PER_PAGE),
updatePagination: setActivePage,
};
}, [activePage, results.length]);
const resultPerPage = useMemo(() => {
const start = activePage * CHARTS_PER_PAGE;
return results.slice(start, start + CHARTS_PER_PAGE);
}, [results, activePage]);
return { results: resultPerPage, isLoading, reset, progress, pagination };
return { results, isLoading: progress !== null, reset, progress };
}
/**

View file

@ -0,0 +1,222 @@
/*
* 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 { FilterStateStore } from '@kbn/es-query';
import { type TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { useTimeRangeUpdates } from '@kbn/ml-date-picker';
import { useMemo } from 'react';
import { fnOperationTypeMapping } from './constants';
import { useDataSource } from '../../hooks/use_data_source';
import {
ChangePointAnnotation,
FieldConfig,
useChangePointDetectionContext,
} from './change_point_detection_context';
/**
* Provides common props for the Lens Embeddable component
*/
export const useCommonChartProps = ({
annotation,
fieldConfig,
previewMode = false,
}: {
fieldConfig: FieldConfig;
annotation: ChangePointAnnotation;
previewMode?: boolean;
}): Partial<TypedLensByValueInput> => {
const timeRange = useTimeRangeUpdates();
const { dataView } = useDataSource();
const { bucketInterval, resultQuery, resultFilters } = useChangePointDetectionContext();
const filters = useMemo(() => {
return [
...resultFilters,
...(annotation.group
? [
{
meta: {
index: dataView.id!,
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: annotation.group.name,
params: {
query: annotation.group.value,
},
},
query: {
match_phrase: {
[annotation.group.name]: annotation.group.value,
},
},
$state: {
store: FilterStateStore.APP_STATE,
},
},
]
: []),
];
}, [dataView.id, annotation.group, resultFilters]);
const gridAndLabelsVisibility = !previewMode;
const attributes = useMemo<TypedLensByValueInput['attributes']>(() => {
return {
title: annotation.group?.value ?? '',
description: '',
visualizationType: 'lnsXY',
type: 'lens',
references: [
{
type: 'index-pattern',
id: dataView.id!,
name: 'indexpattern-datasource-layer-2d61a885-abb0-4d4e-a5f9-c488caec3c22',
},
{
type: 'index-pattern',
id: dataView.id!,
name: 'xy-visualization-layer-8d26ab67-b841-4877-9d02-55bf270f9caf',
},
],
state: {
visualization: {
hideEndzones: true,
yLeftExtent: {
mode: 'dataBounds',
},
legend: {
isVisible: false,
},
valueLabels: 'hide',
fittingFunction: 'None',
// Updates per chart type
axisTitlesVisibilitySettings: {
x: gridAndLabelsVisibility,
yLeft: gridAndLabelsVisibility,
yRight: gridAndLabelsVisibility,
},
tickLabelsVisibilitySettings: {
x: gridAndLabelsVisibility,
yLeft: gridAndLabelsVisibility,
yRight: gridAndLabelsVisibility,
},
labelsOrientation: {
x: 0,
yLeft: 0,
yRight: 0,
},
gridlinesVisibilitySettings: {
x: gridAndLabelsVisibility,
yLeft: gridAndLabelsVisibility,
yRight: gridAndLabelsVisibility,
},
preferredSeriesType: 'line',
layers: [
{
layerId: '2d61a885-abb0-4d4e-a5f9-c488caec3c22',
accessors: ['e9f26d17-fb36-4982-8539-03f1849cbed0'],
position: 'top',
seriesType: 'line',
showGridlines: false,
layerType: 'data',
xAccessor: '877e6638-bfaa-43ec-afb9-2241dc8e1c86',
},
// Annotation layer
{
layerId: '8d26ab67-b841-4877-9d02-55bf270f9caf',
layerType: 'annotations',
annotations: [
{
type: 'manual',
icon: 'triangle',
textVisibility: gridAndLabelsVisibility,
label: annotation.label,
key: {
type: 'point_in_time',
timestamp: annotation.timestamp,
},
id: 'a8fb297c-8d96-4011-93c0-45af110d5302',
isHidden: false,
color: '#F04E98',
lineStyle: 'solid',
lineWidth: 1,
outside: false,
},
],
ignoreGlobalFilters: true,
},
],
},
query: resultQuery,
filters,
datasourceStates: {
formBased: {
layers: {
'2d61a885-abb0-4d4e-a5f9-c488caec3c22': {
columns: {
'877e6638-bfaa-43ec-afb9-2241dc8e1c86': {
label: dataView.timeFieldName,
dataType: 'date',
operationType: 'date_histogram',
sourceField: dataView.timeFieldName,
isBucketed: true,
scale: 'interval',
params: {
interval: bucketInterval.expression,
includeEmptyRows: true,
dropPartials: false,
},
},
'e9f26d17-fb36-4982-8539-03f1849cbed0': {
label: `${fieldConfig.fn}(${fieldConfig.metricField})`,
dataType: 'number',
operationType: fnOperationTypeMapping[fieldConfig.fn],
sourceField: fieldConfig.metricField,
isBucketed: false,
scale: 'ratio',
params: {
emptyAsNull: true,
},
},
},
columnOrder: [
'877e6638-bfaa-43ec-afb9-2241dc8e1c86',
'e9f26d17-fb36-4982-8539-03f1849cbed0',
],
incompleteColumns: {},
},
},
},
textBased: {
layers: {},
},
},
},
} as TypedLensByValueInput['attributes'];
}, [
annotation.group?.value,
annotation.timestamp,
annotation.label,
dataView.id,
dataView.timeFieldName,
resultQuery,
filters,
bucketInterval.expression,
fieldConfig.fn,
fieldConfig.metricField,
gridAndLabelsVisibility,
]);
return {
timeRange,
filters,
query: resultQuery,
attributes,
};
};

View file

@ -11,6 +11,7 @@ import type {
AggregationsCardinalityAggregate,
SearchResponseBody,
} from '@elastic/elasticsearch/lib/api/types';
import usePrevious from 'react-use/lib/usePrevious';
import { useCancellableSearch } from '../../hooks/use_cancellable_search';
import { useDataSource } from '../../hooks/use_data_source';
@ -23,6 +24,8 @@ export function useSplitFieldCardinality(
splitField: string | undefined,
query: QueryDslQueryContainer
) {
const prevSplitField = usePrevious(splitField);
const [cardinality, setCardinality] = useState<number | null>(null);
const { dataView } = useDataSource();
@ -49,6 +52,7 @@ export function useSplitFieldCardinality(
useEffect(
function performCardinalityCheck() {
setCardinality(null);
if (splitField === undefined) {
return;
}
@ -72,5 +76,5 @@ export function useSplitFieldCardinality(
[getSplitFieldCardinality, requestPayload, cancelRequest, splitField]
);
return cardinality;
return prevSplitField !== splitField ? null : cardinality;
}

View file

@ -50,6 +50,7 @@ export function useCancellableSearch() {
if (error.name === 'AbortError') {
return resolve(null);
}
setIsFetching(false);
reject(error);
},
});