mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ML] Support multiple change point requests (#154237)
This commit is contained in:
parent
fbe024306c
commit
c3e6e70428
15 changed files with 1156 additions and 557 deletions
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ export function useCancellableSearch() {
|
|||
if (error.name === 'AbortError') {
|
||||
return resolve(null);
|
||||
}
|
||||
setIsFetching(false);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue