[ML] Change Point Detection (#144093)

## Summary

Adds a Change point detection page in the AIOps labs.

_Note:_ 
This page will be hidden under the hardcoded feature flag for 8.6. 

<img width="1775" alt="image"
src="https://user-images.githubusercontent.com/5236598/199506277-f0d71104-3098-4e15-a697-35f5eec5c110.png">

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Dima Arnautov 2022-11-15 18:47:27 +01:00 committed by GitHub
parent aef304e147
commit 3e22323e00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1805 additions and 66 deletions

View file

@ -12,6 +12,7 @@
"requiredPlugins": [
"charts",
"data",
"lens",
"licensing"
],
"optionalPlugins": [],

View file

@ -204,7 +204,7 @@ export function getEsQueryFromSavedSearch({
};
}
// If saved search available, merge saved search with latest user query or filters
// If saved search available, merge saved search with the latest user query or filters
// which might differ from extracted saved search data
if (savedSearchData) {
const globalFilters = filterManager?.getGlobalFilters();

View file

@ -0,0 +1,275 @@
/*
* 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, {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { type DataViewField } from '@kbn/data-views-plugin/public';
import { startWith } from 'rxjs';
import useMount from 'react-use/lib/useMount';
import type { Query, Filter } from '@kbn/es-query';
import {
createMergedEsQuery,
getEsQueryFromSavedSearch,
} from '../../application/utils/search_utils';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { useTimefilter, useTimeRangeUpdates } from '../../hooks/use_time_filter';
import { useChangePointResults } from './use_change_point_agg_request';
import { type TimeBuckets, TimeBucketsInterval } from '../../../common/time_buckets';
import { useDataSource } from '../../hooks/use_data_source';
import { usePageUrlState } from '../../hooks/use_url_state';
import { useTimeBuckets } from '../../hooks/use_time_buckets';
export interface ChangePointDetectionRequestParams {
fn: string;
splitField: string;
metricField: string;
interval: string;
query: Query;
filters: Filter[];
}
export const ChangePointDetectionContext = createContext<{
timeBuckets: TimeBuckets;
bucketInterval: TimeBucketsInterval;
requestParams: ChangePointDetectionRequestParams;
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;
};
}>({
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: () => {},
},
});
export type ChangePointType =
| 'dip'
| 'spike'
| 'distribution_change'
| 'step_change'
| 'trend_change'
| 'stationary'
| 'non_stationary'
| 'indeterminable';
export interface ChangePointAnnotation {
label: string;
reason: string;
timestamp: string;
group_field: string;
type: ChangePointType;
p_value: number;
}
const DEFAULT_AGG_FUNCTION = 'min';
export const ChangePointDetectionContextProvider: FC = ({ children }) => {
const { dataView, savedSearch } = useDataSource();
const {
uiSettings,
data: {
query: { filterManager },
},
} = useAiopsAppContext();
const savedSearchQuery = useMemo(() => {
return getEsQueryFromSavedSearch({
dataView,
uiSettings,
savedSearch,
filterManager,
});
}, [dataView, savedSearch, uiSettings, filterManager]);
const timefilter = useTimefilter();
const timeBuckets = useTimeBuckets();
const [resultFilters, setResultFilter] = useState<Filter[]>([]);
const [bucketInterval, setBucketInterval] = useState<TimeBucketsInterval>();
const timeRange = useTimeRangeUpdates();
useMount(function updateIntervalOnTimeBoundsChange() {
const timeUpdateSubscription = timefilter
.getTimeUpdate$()
.pipe(startWith(timefilter.getTime()))
.subscribe(() => {
const activeBounds = timefilter.getActiveBounds();
if (!activeBounds) {
throw new Error('Time bound not available');
}
timeBuckets.setInterval('auto');
timeBuckets.setBounds(activeBounds);
setBucketInterval(timeBuckets.getInterval());
});
return () => {
timeUpdateSubscription.unsubscribe();
};
});
const metricFieldOptions = useMemo<DataViewField[]>(() => {
return dataView.fields.filter(({ aggregatable, type }) => aggregatable && type === 'number');
}, [dataView]);
const splitFieldsOptions = useMemo<DataViewField[]>(() => {
return dataView.fields.filter(
({ aggregatable, esTypes, displayName }) =>
aggregatable &&
esTypes &&
esTypes.includes('keyword') &&
!['_id', '_index'].includes(displayName)
);
}, [dataView]);
const [requestParamsFromUrl, updateRequestParams] =
usePageUrlState<ChangePointDetectionRequestParams>('changePoint');
const resultQuery = useMemo<Query>(() => {
return (
requestParamsFromUrl.query ?? {
query: savedSearchQuery?.searchString ?? '',
language: savedSearchQuery?.queryLanguage ?? 'kuery',
}
);
}, [savedSearchQuery, requestParamsFromUrl.query]);
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.splitField && splitFieldsOptions.length > 0) {
params.splitField = splitFieldsOptions[0].name;
}
params.interval = bucketInterval?.expression!;
return params;
}, [requestParamsFromUrl, metricFieldOptions, splitFieldsOptions, bucketInterval]);
const updateFilters = useCallback(
(update: Filter[]) => {
filterManager.setFilters(update);
},
[filterManager]
);
useMount(() => {
setResultFilter(filterManager.getFilters());
const sub = filterManager.getUpdates$().subscribe(() => {
setResultFilter(filterManager.getFilters());
});
return () => {
sub.unsubscribe();
};
});
useEffect(
function syncFilters() {
const globalFilters = filterManager?.getGlobalFilters();
if (requestParamsFromUrl.filters) {
filterManager.setFilters(requestParamsFromUrl.filters);
}
if (globalFilters) {
filterManager?.addFilters(globalFilters);
}
},
[requestParamsFromUrl.filters, filterManager]
);
const combinedQuery = useMemo(() => {
const mergedQuery = createMergedEsQuery(resultQuery, resultFilters, dataView, uiSettings);
if (!Array.isArray(mergedQuery.bool?.filter)) {
if (!mergedQuery.bool) {
mergedQuery.bool = {};
}
mergedQuery.bool.filter = [];
}
mergedQuery.bool!.filter.push({
range: {
[dataView.timeFieldName!]: {
from: timeRange.from,
to: timeRange.to,
},
},
});
return mergedQuery;
}, [resultFilters, resultQuery, uiSettings, dataView, timeRange]);
const {
results: annotations,
isLoading: annotationsLoading,
progress,
pagination,
} = useChangePointResults(requestParams, combinedQuery);
if (!bucketInterval) return null;
const value = {
isLoading: annotationsLoading,
progress,
timeBuckets,
requestParams,
updateRequestParams,
metricFieldOptions,
splitFieldsOptions,
annotations,
bucketInterval,
resultFilters,
updateFilters,
resultQuery,
pagination,
};
return (
<ChangePointDetectionContext.Provider value={value}>
{children}
</ChangePointDetectionContext.Provider>
);
};
export function useChangePointDetectionContext() {
return useContext(ChangePointDetectionContext);
}
export function useRequestParams() {
return useChangePointDetectionContext().requestParams;
}

View file

@ -0,0 +1,205 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useCallback } from 'react';
import {
EuiBadge,
EuiDescriptionList,
EuiEmptyPrompt,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiPagination,
EuiPanel,
EuiProgress,
EuiSpacer,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Query } from '@kbn/es-query';
import { SearchBarWrapper } from './search_bar';
import { 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';
export const ChangePointDetectionPage: FC = () => {
const {
requestParams,
updateRequestParams,
annotations,
resultFilters,
updateFilters,
resultQuery,
progress,
pagination,
} = useChangePointDetectionContext();
const setFn = useCallback(
(fn: string) => {
updateRequestParams({ fn });
},
[updateRequestParams]
);
const setSplitField = useCallback(
(splitField: string) => {
updateRequestParams({ splitField });
},
[updateRequestParams]
);
const setMetricField = useCallback(
(metricField: string) => {
updateRequestParams({ metricField });
},
[updateRequestParams]
);
const setQuery = useCallback(
(query: Query) => {
updateRequestParams({ query });
},
[updateRequestParams]
);
const selectControlCss = { width: '200px' };
return (
<div data-test-subj="aiopsChangePointDetectionPage">
<SearchBarWrapper
query={resultQuery}
onQueryChange={setQuery}
filters={resultFilters}
onFiltersChange={updateFilters}
/>
<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>
<EuiFlexItem grow={false} css={selectControlCss}>
<SplitFieldSelector value={requestParams.splitField} onChange={setSplitField} />
</EuiFlexItem>
<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" />
{annotations.length === 0 && progress === 100 ? (
<>
<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="Try to extend the time range or update the query"
/>
</p>
}
/>
</>
) : null}
<EuiFlexGrid columns={annotations.length >= 2 ? 2 : 1} responsive gutterSize={'m'}>
{annotations.map((v) => {
return (
<EuiFlexItem key={v.group_field}>
<EuiPanel paddingSize="s" hasBorder hasShadow={false}>
<EuiFlexGroup justifyContent={'spaceBetween'} alignItems={'center'}>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems={'center'} gutterSize={'s'}>
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h3>{v.group_field}</h3>
</EuiTitle>
</EuiFlexItem>
{v.reason ? (
<EuiFlexItem grow={false}>
<EuiToolTip position="top" content={v.reason}>
<EuiIcon
tabIndex={0}
color={'warning'}
type="alert"
title={i18n.translate(
'xpack.aiops.changePointDetection.notResultsWarning',
{
defaultMessage: 'No change point agg results warning',
}
)}
/>
</EuiToolTip>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">{v.type}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
{v.p_value !== undefined ? (
<EuiDescriptionList
type="inline"
listItems={[{ title: 'p-value', description: v.p_value.toPrecision(3) }]}
/>
) : null}
<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>
) : null}
</div>
);
};

View file

@ -0,0 +1,42 @@
/*
* 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 { DataView } from '@kbn/data-views-plugin/common';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import React, { FC } from 'react';
import { PageHeader } from '../page_header';
import { ChangePointDetectionContextProvider } from './change_point_detection_context';
import { DataSourceContext } from '../../hooks/use_data_source';
import { UrlStateProvider } from '../../hooks/use_url_state';
import { SavedSearchSavedObject } from '../../application/utils/search_utils';
import { AiopsAppContext, AiopsAppDependencies } from '../../hooks/use_aiops_app_context';
import { ChangePointDetectionPage } from './change_point_detection_page';
export interface ChangePointDetectionAppStateProps {
dataView: DataView;
savedSearch: SavedSearch | SavedSearchSavedObject | null;
appDependencies: AiopsAppDependencies;
}
export const ChangePointDetectionAppState: FC<ChangePointDetectionAppStateProps> = ({
dataView,
savedSearch,
appDependencies,
}) => {
return (
<AiopsAppContext.Provider value={appDependencies}>
<UrlStateProvider>
<DataSourceContext.Provider value={{ dataView, savedSearch }}>
<PageHeader />
<ChangePointDetectionContextProvider>
<ChangePointDetectionPage />
</ChangePointDetectionContextProvider>
</DataSourceContext.Provider>
</UrlStateProvider>
</AiopsAppContext.Provider>
);
};

View file

@ -0,0 +1,215 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useMemo } from 'react';
import { type TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { FilterStateStore } from '@kbn/es-query';
import { useChangePointDetectionContext } from './change_point_detection_context';
import { useDataSource } from '../../hooks/use_data_source';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { useTimeRangeUpdates } from '../../hooks/use_time_filter';
import { fnOperationTypeMapping } from './constants';
export interface ChartComponentProps {
annotation: {
group_field: string;
label: string;
timestamp: string;
reason: string;
};
}
export const ChartComponent: FC<ChartComponentProps> = React.memo(({ annotation }) => {
const {
lens: { EmbeddableComponent },
} = useAiopsAppContext();
const timeRange = useTimeRangeUpdates();
const { dataView } = useDataSource();
const { requestParams, bucketInterval } = useChangePointDetectionContext();
const filters = useMemo(
() => [
{
meta: {
index: dataView.id!,
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: requestParams.splitField,
params: {
query: annotation.group_field,
},
},
query: {
match_phrase: {
[requestParams.splitField]: annotation.group_field,
},
},
$state: {
store: FilterStateStore.APP_STATE,
},
},
],
[dataView.id, requestParams.splitField, annotation.group_field]
);
// @ts-ignore incorrect types for attributes
const attributes = useMemo<TypedLensByValueInput['attributes']>(() => {
return {
title: annotation.group_field,
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: {
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: {
query: '',
language: 'kuery',
},
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: {},
},
};
}, [dataView.id, dataView.timeFieldName, annotation, requestParams, filters, bucketInterval]);
return (
<EmbeddableComponent
id={`changePointChart_${annotation.group_field}`}
style={{ height: 350 }}
timeRange={timeRange}
attributes={attributes}
renderMode={'view'}
executionContext={{
type: 'aiops_change_point_detection_chart',
name: 'Change point detection',
}}
/>
);
});

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export const fnOperationTypeMapping: Record<string, string> = {
min: 'min',
max: 'max',
sum: 'sum',
avg: 'average',
} as const;

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { fnOperationTypeMapping } from './constants';
interface FunctionPickerProps {
value: string;
onChange: (value: string) => void;
}
export const FunctionPicker: FC<FunctionPickerProps> = React.memo(({ value, onChange }) => {
const options = Object.keys(fnOperationTypeMapping).map((v) => {
return {
value: v,
text: v,
};
});
return (
<EuiFormRow>
<EuiSelect
options={options}
value={value}
onChange={(e) => onChange(e.target.value)}
prepend={i18n.translate('xpack.aiops.changePointDetection.selectFunctionLabel', {
defaultMessage: 'Function',
})}
/>
</EuiFormRow>
);
});

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
export { type ChangePointDetectionAppStateProps } from './change_point_detetion_root';
import { ChangePointDetectionAppState } from './change_point_detetion_root';
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default ChangePointDetectionAppState;

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui';
import { useChangePointDetectionContext } from './change_point_detection_context';
interface MetricFieldSelectorProps {
value: string;
onChange: (value: string) => void;
}
export const MetricFieldSelector: FC<MetricFieldSelectorProps> = React.memo(
({ value, onChange }) => {
const { metricFieldOptions } = useChangePointDetectionContext();
const options = useMemo<EuiSelectOption[]>(() => {
return metricFieldOptions.map((v) => ({ value: v.name, text: v.displayName }));
}, [metricFieldOptions]);
return (
<EuiFormRow>
<EuiSelect
prepend={i18n.translate('xpack.aiops.changePointDetection.selectMetricFieldLabel', {
defaultMessage: 'Metric field',
})}
options={options}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</EuiFormRow>
);
}
);

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { type Filter, fromKueryExpression, type Query } from '@kbn/es-query';
import { type SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar';
import { EuiSpacer, EuiTextColor } from '@elastic/eui';
import { SEARCH_QUERY_LANGUAGE } from '../../application/utils/search_utils';
import { useDataSource } from '../../hooks/use_data_source';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
export interface SearchBarProps {
query: Query;
filters: Filter[];
onQueryChange: (update: Query) => void;
onFiltersChange: (update: Filter[]) => void;
}
/**
* Reusable search bar component for the AIOps app.
*
* @param query
* @param filters
* @param onQueryChange
* @param onFiltersChange
* @constructor
*/
export const SearchBarWrapper: FC<SearchBarProps> = ({
query,
filters,
onQueryChange,
onFiltersChange,
}) => {
const { dataView } = useDataSource();
const {
unifiedSearch: {
ui: { SearchBar },
},
} = useAiopsAppContext();
const [error, setError] = useState<string | undefined>();
const onQuerySubmit: SearchBarOwnProps['onQuerySubmit'] = useCallback(
(payload, isUpdate) => {
if (payload.query.language === SEARCH_QUERY_LANGUAGE.KUERY) {
try {
// Validates the query
fromKueryExpression(payload.query.query);
setError(undefined);
onQueryChange(payload.query);
} catch (e) {
setError(e.message);
}
}
},
[onQueryChange]
);
const onFiltersUpdated = useCallback(
(updatedFilters: Filter[]) => {
onFiltersChange(updatedFilters);
},
[onFiltersChange]
);
const resultQuery = query ?? { query: '', language: 'kuery' };
return (
<>
<SearchBar
showSubmitButton={false}
appName={'aiops'}
showFilterBar
showDatePicker={false}
showQueryInput
query={resultQuery}
filters={filters ?? []}
onQuerySubmit={onQuerySubmit}
indexPatterns={[dataView]}
placeholder={i18n.translate('xpack.aiops.searchPanel.queryBarPlaceholderText', {
defaultMessage: 'Search… (e.g. status:200 AND extension:"PHP")',
})}
displayStyle={'inPage'}
isClearable
onFiltersUpdated={onFiltersUpdated}
/>
{error ? (
<>
<EuiSpacer size={'s'} />
<EuiTextColor color="danger">{error}</EuiTextColor>
</>
) : null}
</>
);
};

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSelect, type EuiSelectOption } from '@elastic/eui';
import { useChangePointDetectionContext } from './change_point_detection_context';
interface SplitFieldSelectorProps {
value: string;
onChange: (value: string) => void;
}
export const SplitFieldSelector: FC<SplitFieldSelectorProps> = React.memo(({ value, onChange }) => {
const { splitFieldsOptions } = useChangePointDetectionContext();
const options = useMemo<EuiSelectOption[]>(() => {
return splitFieldsOptions.map((v) => ({ value: v.name, text: v.displayName }));
}, [splitFieldsOptions]);
return (
<EuiFormRow>
<EuiSelect
prepend={i18n.translate('xpack.aiops.changePointDetection.selectSpitFieldLabel', {
defaultMessage: 'Split field',
})}
options={options}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</EuiFormRow>
);
});

View file

@ -0,0 +1,263 @@
/*
* 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 { useEffect, useCallback, useState, useMemo } from 'react';
import { type QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { i18n } from '@kbn/i18n';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import {
ChangePointAnnotation,
ChangePointDetectionRequestParams,
ChangePointType,
} from './change_point_detection_context';
import { useDataSource } from '../../hooks/use_data_source';
import { useCancellableSearch } from '../../hooks/use_cancellable_search';
import { useSplitFieldCardinality } from './use_split_field_cardinality';
interface RequestOptions {
index: string;
fn: string;
metricField: string;
splitField: string;
timeField: string;
timeInterval: string;
afterKey?: string;
}
export const COMPOSITE_AGG_SIZE = 500;
function getChangePointDetectionRequestBody(
{ index, fn, metricField, splitField, timeInterval, timeField, afterKey }: RequestOptions,
query: QueryDslQueryContainer
) {
return {
params: {
index,
size: 0,
body: {
query,
aggregations: {
groupings: {
composite: {
size: COMPOSITE_AGG_SIZE,
...(afterKey !== undefined ? { after: { splitFieldTerm: afterKey } } : {}),
sources: [
{
splitFieldTerm: {
terms: {
field: splitField,
},
},
},
],
},
aggregations: {
over_time: {
date_histogram: {
field: timeField,
fixed_interval: timeInterval,
},
aggs: {
function_value: {
[fn]: {
field: metricField,
},
},
},
},
change_point_request: {
change_point: {
buckets_path: 'over_time>function_value',
},
},
select: {
bucket_selector: {
buckets_path: { p_value: 'change_point_request.p_value' },
script: 'params.p_value < 1',
},
},
sort: {
bucket_sort: {
sort: [{ 'change_point_request.p_value': { order: 'asc' } }],
},
},
},
},
},
},
},
};
}
const CHARTS_PER_PAGE = 6;
export function useChangePointResults(
requestParams: ChangePointDetectionRequestParams,
query: QueryDslQueryContainer
) {
const {
notifications: { toasts },
} = useAiopsAppContext();
const { dataView } = useDataSource();
const [results, setResults] = useState<ChangePointAnnotation[]>([]);
const [activePage, setActivePage] = useState<number>(0);
const [progress, setProgress] = useState<number>(0);
const splitFieldCardinality = useSplitFieldCardinality(requestParams.splitField, query);
const { runRequest, cancelRequest, isLoading } = useCancellableSearch();
const reset = useCallback(() => {
cancelRequest();
setProgress(0);
setActivePage(0);
setResults([]);
}, [cancelRequest]);
const fetchResults = useCallback(
async (afterKey?: string, prevBucketsCount?: number) => {
try {
if (!splitFieldCardinality) {
setProgress(100);
return;
}
const requestPayload = getChangePointDetectionRequestBody(
{
index: dataView.getIndexPattern(),
fn: requestParams.fn,
timeInterval: requestParams.interval,
metricField: requestParams.metricField,
timeField: dataView.timeFieldName!,
splitField: requestParams.splitField,
afterKey,
},
query
);
const result = await runRequest<
typeof requestPayload,
{ rawResponse: ChangePointAggResponse }
>(requestPayload);
if (result === null) {
setProgress(100);
return;
}
const buckets = result.rawResponse.aggregations.groupings.buckets;
setProgress(
Math.min(
Math.round(((buckets.length + (prevBucketsCount ?? 0)) / splitFieldCardinality) * 100),
100
)
);
const 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 {
group_field: v.key.splitFieldTerm,
type: changePointType,
p_value: rawPValue,
timestamp: timeAsString,
label: changePointType,
reason: v.change_point_request.type[changePointType].reason,
} as ChangePointAnnotation;
});
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)
);
});
if (result.rawResponse.aggregations.groupings.after_key?.splitFieldTerm) {
await fetchResults(
result.rawResponse.aggregations.groupings.after_key.splitFieldTerm,
buckets.length + (prevBucketsCount ?? 0)
);
} else {
setProgress(100);
}
} catch (e) {
toasts.addError(e, {
title: i18n.translate('xpack.aiops.changePointDetection.fetchErrorTitle', {
defaultMessage: 'Failed to fetch change points',
}),
});
}
},
[runRequest, requestParams, query, dataView, splitFieldCardinality, toasts]
);
useEffect(
function fetchResultsOnInputChange() {
reset();
fetchResults();
return () => {
cancelRequest();
};
},
[requestParams, query, splitFieldCardinality, fetchResults, reset, cancelRequest]
);
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 };
}
interface ChangePointAggResponse {
took: number;
timed_out: boolean;
_shards: { total: number; failed: number; successful: number; skipped: number };
hits: { hits: any[]; total: number; max_score: null };
aggregations: {
groupings: {
after_key?: {
splitFieldTerm: string;
};
buckets: Array<{
key: { splitFieldTerm: string };
doc_count: number;
over_time: {
buckets: Array<{
key_as_string: string;
doc_count: number;
function_value: { value: number };
key: number;
}>;
};
change_point_request: {
bucket?: { doc_count: number; function_value: { value: number }; key: string };
type: {
[key in ChangePointType]: { p_value: number; change_point: number; reason?: string };
};
};
}>;
};
};
}

View file

@ -0,0 +1,69 @@
/*
* 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 { useEffect, useMemo, useState } from 'react';
import type {
QueryDslQueryContainer,
AggregationsCardinalityAggregate,
SearchResponseBody,
} from '@elastic/elasticsearch/lib/api/types';
import { useCancellableSearch } from '../../hooks/use_cancellable_search';
import { useDataSource } from '../../hooks/use_data_source';
/**
* Gets the cardinality of the selected split field
* @param splitField
* @param query
*/
export function useSplitFieldCardinality(splitField: string, query: QueryDslQueryContainer) {
const [cardinality, setCardinality] = useState<number>();
const { dataView } = useDataSource();
const requestPayload = useMemo(() => {
return {
params: {
index: dataView.getIndexPattern(),
size: 0,
body: {
query,
aggregations: {
fieldCount: {
cardinality: {
field: splitField,
},
},
},
},
},
};
}, [splitField, dataView, query]);
const { runRequest: getSplitFieldCardinality, cancelRequest } = useCancellableSearch();
useEffect(
function performCardinalityCheck() {
cancelRequest();
getSplitFieldCardinality<
typeof requestPayload,
{
rawResponse: SearchResponseBody<
unknown,
{ fieldCount: AggregationsCardinalityAggregate }
>;
}
>(requestPayload).then((response) => {
if (response?.rawResponse.aggregations) {
setCardinality(response.rawResponse.aggregations.fieldCount.value);
}
});
},
[getSplitFieldCardinality, requestPayload, cancelRequest]
);
return cardinality;
}

View file

@ -21,13 +21,12 @@ import {
OnTimeChangeProps,
} from '@elastic/eui';
import type { TimeRange } from '@kbn/es-query';
import { TimefilterContract, TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public';
import { TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import useObservable from 'react-use/lib/useObservable';
import { map } from 'rxjs/operators';
import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public';
import { useRefreshIntervalUpdates, useTimeRangeUpdates } from '../../hooks/use_time_filter';
import { useUrlState } from '../../hooks/use_url_state';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { aiopsRefresh$ } from '../../application/services/timefilter_refresh_service';
@ -67,21 +66,6 @@ function updateLastRefresh(timeRange?: OnRefreshProps) {
aiopsRefresh$.next({ lastRefresh: Date.now(), timeRange });
}
export const useRefreshIntervalUpdates = (timefilter: TimefilterContract) => {
return useObservable(
timefilter.getRefreshIntervalUpdate$().pipe(map(timefilter.getRefreshInterval)),
timefilter.getRefreshInterval()
);
};
export const useTimeRangeUpdates = (timefilter: TimefilterContract, absolute = false) => {
const getTimeCallback = absolute
? timefilter.getAbsoluteTime.bind(timefilter)
: timefilter.getTime.bind(timefilter);
return useObservable(timefilter.getTimeUpdate$().pipe(map(getTimeCallback)), getTimeCallback());
};
export const DatePickerWrapper: FC = () => {
const services = useAiopsAppContext();
const { toasts } = services.notifications;
@ -93,8 +77,8 @@ export const DatePickerWrapper: FC = () => {
const [globalState, setGlobalState] = useUrlState('_g');
const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(history);
const timeFilterRefreshInterval = useRefreshIntervalUpdates(timefilter);
const time = useTimeRangeUpdates(timefilter);
const timeFilterRefreshInterval = useRefreshIntervalUpdates();
const time = useTimeRangeUpdates();
useEffect(
function syncTimRangeFromUrlState() {
@ -257,13 +241,10 @@ export const DatePickerWrapper: FC = () => {
}
return isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled ? (
<EuiFlexGroup
gutterSize="s"
alignItems="center"
className="mlNavigationMenu__datePickerWrapper"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center" css={{ width: '100%', minWidth: 0 }}>
<EuiFlexItem grow={true} css={{ width: '100%' }}>
<EuiSuperDatePicker
css={{ width: '100%', minWidth: 0 }}
start={time.from}
end={time.to}
isPaused={refreshInterval.pause}

View file

@ -26,15 +26,18 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { setFullTimeRange } from './full_time_range_selector_service';
import {
type GetTimeFieldRangeResponse,
setFullTimeRange,
} from './full_time_range_selector_service';
import { AIOPS_FROZEN_TIER_PREFERENCE, useStorage } from '../../hooks/use_storage';
interface Props {
export interface FullTimeRangeSelectorProps {
timefilter: TimefilterContract;
dataView: DataView;
disabled: boolean;
query?: QueryDslQueryContainer;
callback?: (a: any) => void;
callback?: (a: GetTimeFieldRangeResponse) => void;
}
const FROZEN_TIER_PREFERENCE = {
@ -44,7 +47,7 @@ const FROZEN_TIER_PREFERENCE = {
type FrozenTierPreference = typeof FROZEN_TIER_PREFERENCE[keyof typeof FROZEN_TIER_PREFERENCE];
export const FullTimeRangeSelector: FC<Props> = ({
export const FullTimeRangeSelector: FC<FullTimeRangeSelectorProps> = ({
timefilter,
dataView,
query,

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { PageHeader } from './page_header';

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useCallback } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiPageContentHeader_Deprecated as EuiPageContentHeader,
EuiPageContentHeaderSection_Deprecated as EuiPageContentHeaderSection,
} from '@elastic/eui';
import { FullTimeRangeSelectorProps } from '../full_time_range_selector/full_time_range_selector';
import { useUrlState } from '../../hooks/use_url_state';
import { useDataSource } from '../../hooks/use_data_source';
import { useTimefilter } from '../../hooks/use_time_filter';
import { FullTimeRangeSelector } from '../full_time_range_selector';
import { DatePickerWrapper } from '../date_picker_wrapper';
export const PageHeader: FC = () => {
const [, setGlobalState] = useUrlState('_g');
const { dataView } = useDataSource();
const timefilter = useTimefilter({
timeRangeSelector: dataView.timeFieldName !== undefined,
autoRefreshSelector: true,
});
const updateTimeState: FullTimeRangeSelectorProps['callback'] = useCallback(
(update) => {
setGlobalState({ time: { from: update.start.string, to: update.end.string } });
},
[setGlobalState]
);
return (
<>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<EuiPageContentHeader className="aiopsPageHeader">
<EuiPageContentHeaderSection>
<div className="dataViewTitleHeader">
<EuiTitle size="s">
<h2>{dataView.getName()}</h2>
</EuiTitle>
</div>
</EuiPageContentHeaderSection>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="s"
data-test-subj="aiopsTimeRangeSelectorSection"
>
{dataView.timeFieldName !== undefined && (
<EuiFlexItem grow={false}>
<FullTimeRangeSelector
dataView={dataView}
query={undefined}
disabled={false}
timefilter={timefilter}
callback={updateTimeState}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<DatePickerWrapper />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentHeader>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</>
);
};

View file

@ -16,6 +16,7 @@ import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { SharePluginStart } from '@kbn/share-plugin/public';
import type { CoreStart, CoreSetup, HttpStart, IUiSettingsClient } from '@kbn/core/public';
import type { ThemeServiceStart } from '@kbn/core/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
export interface AiopsAppDependencies {
application: CoreStart['application'];
@ -29,6 +30,7 @@ export interface AiopsAppDependencies {
uiSettings: IUiSettingsClient;
unifiedSearch: UnifiedSearchPublicPluginStart;
share: SharePluginStart;
lens: LensPublicStart;
}
export const AiopsAppContext = createContext<AiopsAppDependencies | undefined>(undefined);

View file

@ -0,0 +1,67 @@
/*
* 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 { useCallback, useRef, useState } from 'react';
import {
type IKibanaSearchResponse,
isCompleteResponse,
isErrorResponse,
} from '@kbn/data-plugin/common';
import { tap } from 'rxjs/operators';
import { useAiopsAppContext } from './use_aiops_app_context';
export function useCancellableSearch() {
const { data } = useAiopsAppContext();
const abortController = useRef(new AbortController());
const [isLoading, setIsFetching] = useState<boolean>(false);
const runRequest = useCallback(
<RequestBody, ResponseType extends IKibanaSearchResponse>(
requestBody: RequestBody
): Promise<ResponseType | null> => {
return new Promise((resolve, reject) => {
data.search
.search<RequestBody, ResponseType>(requestBody, {
abortSignal: abortController.current.signal,
})
.pipe(
tap(() => {
setIsFetching(true);
})
)
.subscribe({
next: (result) => {
if (isCompleteResponse(result)) {
setIsFetching(false);
resolve(result);
} else if (isErrorResponse(result)) {
reject(result);
} else {
// partial results
// Ignore partial results for now.
// An issue with the search function means partial results are not being returned correctly.
}
},
error: (error) => {
if (error.name === 'AbortError') {
return resolve(null);
}
reject(error);
},
});
});
},
[data.search]
);
const cancelRequest = useCallback(() => {
abortController.current.abort();
abortController.current = new AbortController();
}, []);
return { runRequest, cancelRequest, isLoading };
}

View file

@ -9,12 +9,11 @@ import { useEffect, useMemo, useState } from 'react';
import { merge } from 'rxjs';
import type { DataView } from '@kbn/data-views-plugin/public';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import type { ChangePoint } from '@kbn/ml-agg-utils';
import type { SavedSearch } from '@kbn/discover-plugin/public';
import { TimeBuckets } from '../../common/time_buckets';
import { useTimeBuckets } from './use_time_buckets';
import { useAiopsAppContext } from './use_aiops_app_context';
import { aiopsRefresh$ } from '../application/services/timefilter_refresh_service';
@ -96,14 +95,7 @@ export const useData = (
lastRefresh,
]);
const _timeBuckets = useMemo(() => {
return new TimeBuckets({
[UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
[UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
}, [uiSettings]);
const _timeBuckets = useTimeBuckets();
const timefilter = useTimefilter({
timeRangeSelector: currentDataView?.timeFieldName !== undefined,

View file

@ -0,0 +1,25 @@
/*
* 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 { createContext, useContext } from 'react';
import { DataView } from '@kbn/data-views-plugin/common';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { SavedSearchSavedObject } from '../application/utils/search_utils';
export const DataSourceContext = createContext<{
dataView: DataView | never;
savedSearch: SavedSearch | SavedSearchSavedObject | null;
}>({
get dataView(): never {
throw new Error('Context is not implemented');
},
savedSearch: null,
});
export function useDataSource() {
return useContext(DataSourceContext);
}

View file

@ -0,0 +1,24 @@
/*
* 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 { useMemo } from 'react';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import { TimeBuckets } from '../../common/time_buckets';
import { useAiopsAppContext } from './use_aiops_app_context';
export const useTimeBuckets = () => {
const { uiSettings } = useAiopsAppContext();
return useMemo(() => {
return new TimeBuckets({
[UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
[UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
}, [uiSettings]);
};

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { isEqual } from 'lodash';
import { useAiopsAppContext } from './use_aiops_app_context';
interface UseTimefilterOptions {
@ -41,3 +44,31 @@ export const useTimefilter = ({
return timefilter;
};
export const useRefreshIntervalUpdates = () => {
const timefilter = useTimefilter();
const refreshIntervalObservable$ = useMemo(
() => timefilter.getRefreshIntervalUpdate$().pipe(map(timefilter.getRefreshInterval)),
[timefilter]
);
return useObservable(refreshIntervalObservable$, timefilter.getRefreshInterval());
};
export const useTimeRangeUpdates = (absolute = false) => {
const timefilter = useTimefilter();
const getTimeCallback = useMemo(() => {
return absolute
? timefilter.getAbsoluteTime.bind(timefilter)
: timefilter.getTime.bind(timefilter);
}, [absolute, timefilter]);
const timeChangeObservable$ = useMemo(
() => timefilter.getTimeUpdate$().pipe(map(getTimeCallback), distinctUntilChanged(isEqual)),
[timefilter, getTimeCallback]
);
return useObservable(timeChangeObservable$, getTimeCallback());
};

View file

@ -184,12 +184,12 @@ export const useUrlState = (accessor: Accessor) => {
};
export const AppStateKey = 'AIOPS_INDEX_VIEWER';
export const ChangePointStateKey = 'changePoint' as const;
/**
* Hook for managing the URL state of the page.
*/
export const usePageUrlState = <PageUrlState extends {}>(
pageKey: typeof AppStateKey,
pageKey: typeof AppStateKey | typeof ChangePointStateKey,
defaultState?: PageUrlState
): [PageUrlState, (update: Partial<PageUrlState>, replaceState?: boolean) => void] => {
const [appState, setAppState] = useUrlState('_a');

View file

@ -13,4 +13,8 @@ export function plugin() {
return new AiopsPlugin();
}
export { ExplainLogRateSpikes, LogCategorization } from './shared_lazy_components';
export {
ExplainLogRateSpikes,
LogCategorization,
ChangePointDetection,
} from './shared_lazy_components';

View file

@ -15,7 +15,7 @@ const ExplainLogRateSpikesAppStateLazy = React.lazy(
() => import('./components/explain_log_rate_spikes')
);
const ExplainLogRateSpikesLazyWrapper: FC = ({ children }) => (
const LazyWrapper: FC = ({ children }) => (
<EuiErrorBoundary>
<Suspense fallback={<EuiLoadingContent lines={3} />}>{children}</Suspense>
</EuiErrorBoundary>
@ -26,25 +26,30 @@ const ExplainLogRateSpikesLazyWrapper: FC = ({ children }) => (
* @param {ExplainLogRateSpikesAppStateProps} props - properties specifying the data on which to run the analysis.
*/
export const ExplainLogRateSpikes: FC<ExplainLogRateSpikesAppStateProps> = (props) => (
<ExplainLogRateSpikesLazyWrapper>
<LazyWrapper>
<ExplainLogRateSpikesAppStateLazy {...props} />
</ExplainLogRateSpikesLazyWrapper>
</LazyWrapper>
);
const LogCategorizationAppStateLazy = React.lazy(() => import('./components/log_categorization'));
const LogCategorizationLazyWrapper: FC = ({ children }) => (
<EuiErrorBoundary>
<Suspense fallback={<EuiLoadingContent lines={3} />}>{children}</Suspense>
</EuiErrorBoundary>
);
/**
* Lazy-wrapped LogCategorizationAppStateProps React component
* @param {LogCategorizationAppStateProps} props - properties specifying the data on which to run the analysis.
*/
export const LogCategorization: FC<LogCategorizationAppStateProps> = (props) => (
<LogCategorizationLazyWrapper>
<LazyWrapper>
<LogCategorizationAppStateLazy {...props} />
</LogCategorizationLazyWrapper>
</LazyWrapper>
);
const ChangePointDetectionLazy = React.lazy(() => import('./components/change_point_detection'));
/**
* Lazy-wrapped LogCategorizationAppStateProps React component
* @param {LogCategorizationAppStateProps} props - properties specifying the data on which to run the analysis.
*/
export const ChangePointDetection: FC<LogCategorizationAppStateProps> = (props) => (
<LazyWrapper>
<ChangePointDetectionLazy {...props} />
</LazyWrapper>
);

View file

@ -25,5 +25,6 @@
{ "path": "../security/tsconfig.json" },
{ "path": "../../../src/plugins/charts/tsconfig.json" },
{ "path": "../../../src/plugins/discover/tsconfig.json" },
{ "path": "../lens/tsconfig.json" }
]
}

View file

@ -62,6 +62,8 @@ export const ML_PAGES = {
AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: 'aiops/explain_log_rate_spikes_index_select',
AIOPS_LOG_CATEGORIZATION: 'aiops/log_categorization',
AIOPS_LOG_CATEGORIZATION_INDEX_SELECT: 'aiops/log_categorization_index_select',
AIOPS_CHANGE_POINT_DETECTION: 'aiops/change_point_detection',
AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT: 'aiops/change_point_detection_index_select',
} as const;
export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES];

View file

@ -65,7 +65,9 @@ export type MlGenericUrlState = MLPageState<
| typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES
| typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT
| typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION
| typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT,
| typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT
| typeof ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT
| typeof ML_PAGES.AIOPS_CHANGE_POINT_DETECTION,
MlGenericUrlPageState | undefined
>;

View file

@ -17,6 +17,7 @@
"embeddable",
"features",
"fieldFormats",
"lens",
"licensing",
"share",
"taskManager",
@ -28,7 +29,6 @@
"alerting",
"dashboard",
"home",
"lens",
"licenseManagement",
"management",
"maps",

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { pick } from 'lodash';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ChangePointDetection } from '@kbn/aiops-plugin/public';
import { useMlContext } from '../contexts/ml';
import { useMlKibana } from '../contexts/kibana';
import { HelpMenu } from '../components/help_menu';
import { TechnicalPreviewBadge } from '../components/technical_preview_badge';
import { MlPageHeader } from '../components/page_header';
export const ChangePointDetectionPage: FC = () => {
const { services } = useMlKibana();
const context = useMlContext();
const dataView = context.currentDataView;
const savedSearch = context.currentSavedSearch;
return (
<>
<MlPageHeader>
<EuiFlexGroup responsive={false} wrap={false} alignItems={'center'} gutterSize={'m'}>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.ml.changePointDetection.pageHeader"
defaultMessage="Change point detection"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TechnicalPreviewBadge />
</EuiFlexItem>
</EuiFlexGroup>
</MlPageHeader>
{dataView ? (
<ChangePointDetection
dataView={dataView}
savedSearch={savedSearch}
appDependencies={pick(services, [
'application',
'data',
'charts',
'fieldFormats',
'http',
'notifications',
'share',
'storage',
'uiSettings',
'unifiedSearch',
'theme',
'lens',
])}
/>
) : null}
<HelpMenu docLink={services.docLinks.links.ml.guide} />
</>
);
};

View file

@ -58,6 +58,7 @@ export const ExplainLogRateSpikesPage: FC = () => {
'uiSettings',
'unifiedSearch',
'theme',
'lens',
])}
/>
)}

View file

@ -6,3 +6,4 @@
*/
export { ExplainLogRateSpikesPage } from './explain_log_rate_spikes';
export { ChangePointDetectionPage } from './change_point_detection';

View file

@ -58,6 +58,7 @@ export const LogCategorizationPage: FC = () => {
'uiSettings',
'unifiedSearch',
'theme',
'lens',
])}
/>
)}

View file

@ -93,6 +93,7 @@ const App: FC<AppProps> = ({ coreStart, deps, appMountParams }) => {
cases: deps.cases,
unifiedSearch: deps.unifiedSearch,
licensing: deps.licensing,
lens: deps.lens,
...coreStart,
};

View file

@ -28,6 +28,8 @@ export interface Tab {
onClick?: () => Promise<void>;
/** Indicates if item should be marked as active with nested routes */
highlightNestedRoutes?: boolean;
/** List of route IDs related to the side nav entry */
relatedRouteIds?: string[];
}
export function useSideNavItems(activeRoute: MlRoute | undefined) {
@ -252,6 +254,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) {
}),
disabled: disableLinks,
testSubj: 'mlMainTab explainLogRateSpikes',
relatedRouteIds: ['explain_log_rate_spikes'],
},
{
id: 'logCategorization',
@ -261,6 +264,17 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) {
}),
disabled: disableLinks,
testSubj: 'mlMainTab logCategorization',
relatedRouteIds: ['log_categorization'],
},
{
id: 'changePointDetection',
pathId: ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT,
name: i18n.translate('xpack.ml.navMenu.changePointDetectionLinkText', {
defaultMessage: 'Change Point Detection',
}),
disabled: disableLinks,
testSubj: 'mlMainTab changePointDetection',
relatedRouteIds: ['change_point_detection'],
},
],
});
@ -271,13 +285,24 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) {
const getTabItem: (tab: Tab) => EuiSideNavItemType<unknown> = useCallback(
(tab: Tab) => {
const { id, disabled, items, onClick, pathId, name, testSubj, highlightNestedRoutes } = tab;
const {
id,
disabled,
items,
onClick,
pathId,
name,
testSubj,
highlightNestedRoutes,
relatedRouteIds,
} = tab;
const onClickCallback = onClick ?? (pathId ? redirectToTab.bind(null, pathId) : undefined);
const isSelected =
`/${pathId}` === activeRoute?.path ||
(!!highlightNestedRoutes && activeRoute?.path.includes(`${pathId}/`));
(!!highlightNestedRoutes && activeRoute?.path.includes(`${pathId}/`)) ||
(Array.isArray(relatedRouteIds) && relatedRouteIds.includes(activeRoute?.id!));
return {
id,
@ -290,7 +315,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) {
forceOpen: true,
};
},
[activeRoute?.path, redirectToTab]
[activeRoute, redirectToTab]
);
return useMemo(() => tabsDefinition.map(getTabItem), [tabsDefinition, getTabItem]);

View file

@ -24,6 +24,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { CasesUiStart } from '@kbn/cases-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
import type { MlServicesContext } from '../../app';
interface StartPlugins {
@ -45,6 +46,7 @@ interface StartPlugins {
unifiedSearch: UnifiedSearchPublicPluginStart;
core: CoreStart;
appName: string;
lens: LensPublicStart;
}
export type StartServices = CoreStart &
StartPlugins & {

View file

@ -71,6 +71,13 @@ export const AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS: ChromeBreadcrumb = Object.fr
href: '/aiops/log_categorization_index_select',
});
export const AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', {
defaultMessage: 'AIOps Labs',
}),
href: '/aiops/change_point_detection_index_select',
});
export const EXPLAIN_LOG_RATE_SPIKES: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.aiops.explainLogRateSpikesBreadcrumbLabel', {
defaultMessage: 'Explain Log Rate Spikes',
@ -85,6 +92,13 @@ export const LOG_PATTERN_ANALYSIS: ChromeBreadcrumb = Object.freeze({
href: '/aiops/log_categorization_index_select',
});
export const CHANGE_POINT_DETECTION: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.aiops.changePointDetectionBreadcrumbLabel', {
defaultMessage: 'Change Point Detection',
}),
href: '/aiops/change_point_detection_index_select',
});
export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.createJobsBreadcrumbLabel', {
defaultMessage: 'Create job',
@ -115,8 +129,10 @@ const breadcrumbs = {
DATA_VISUALIZER_BREADCRUMB,
AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES,
AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS,
AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION,
EXPLAIN_LOG_RATE_SPIKES,
LOG_PATTERN_ANALYSIS,
CHANGE_POINT_DETECTION,
CREATE_JOB_BREADCRUMB,
CALENDAR_MANAGEMENT_BREADCRUMB,
FILTER_LISTS_BREADCRUMB,

View file

@ -0,0 +1,55 @@
/*
* 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 { AIOPS_ENABLED } from '@kbn/aiops-plugin/common';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { parse } from 'query-string';
import { NavigateToPath } from '../../../contexts/kibana';
import { MlRoute } from '../..';
import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs';
import { PageLoader, PageProps } from '../../router';
import { useResolver } from '../../use_resolver';
import { checkBasicLicense } from '../../../license';
import { cacheDataViewsContract } from '../../../util/index_utils';
import { ChangePointDetectionPage as Page } from '../../../aiops';
export const changePointDetectionRouteFactory = (
navigateToPath: NavigateToPath,
basePath: string
): MlRoute => ({
id: 'change_point_detection',
path: '/aiops/change_point_detection',
title: i18n.translate('xpack.ml.aiops.changePointDetection.docTitle', {
defaultMessage: 'Change point detection',
}),
render: (props, deps) => <PageWrapper {...props} deps={deps} />,
breadcrumbs: [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.changePointDetectionLabel', {
defaultMessage: 'Change point detection',
}),
},
],
disabled: !AIOPS_ENABLED,
});
const PageWrapper: FC<PageProps> = ({ location, deps }) => {
const { index, savedSearchId }: Record<string, any> = parse(location.search, { sort: false });
const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, {
checkBasicLicense,
cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract),
});
return (
<PageLoader context={context}>
<Page />
</PageLoader>
);
};

View file

@ -7,3 +7,4 @@
export * from './explain_log_rate_spikes';
export * from './log_categorization';
export * from './change_point_detection';

View file

@ -55,7 +55,7 @@ const getExplainLogRateSpikesBreadcrumbs = (navigateToPath: NavigateToPath, base
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('EXPLAIN_LOG_RATE_SPIKES', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', {
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDataViewLabel', {
defaultMessage: 'Select Data View',
}),
},
@ -66,7 +66,18 @@ const getLogCategorizationBreadcrumbs = (navigateToPath: NavigateToPath, basePat
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('LOG_PATTERN_ANALYSIS', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', {
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDataViewLabel', {
defaultMessage: 'Select Data View',
}),
},
];
const getChangePointDetectionBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('CHANGE_POINT_DETECTION', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDataViewLabel', {
defaultMessage: 'Select Data View',
}),
},
@ -148,6 +159,26 @@ export const logCategorizationIndexOrSearchRouteFactory = (
breadcrumbs: getLogCategorizationBreadcrumbs(navigateToPath, basePath),
});
export const changePointDetectionIndexOrSearchRouteFactory = (
navigateToPath: NavigateToPath,
basePath: string
): MlRoute => ({
id: 'data_view_change_point_detection',
path: '/aiops/change_point_detection_index_select',
title: i18n.translate('xpack.ml.selectDataViewLabel', {
defaultMessage: 'Select Data View',
}),
render: (props, deps) => (
<PageWrapper
{...props}
nextStepPath="aiops/change_point_detection"
deps={deps}
mode={MODE.DATAVISUALIZER}
/>
),
breadcrumbs: getChangePointDetectionBreadcrumbs(navigateToPath, basePath),
});
const PageWrapper: FC<IndexOrSearchPageProps> = ({ nextStepPath, deps, mode }) => {
const {
services: {

View file

@ -90,6 +90,8 @@ export class MlLocatorDefinition implements LocatorDefinition<MlLocatorParams> {
case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT:
case ML_PAGES.AIOPS_LOG_CATEGORIZATION:
case ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT:
case ML_PAGES.AIOPS_CHANGE_POINT_DETECTION:
case ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT:
case ML_PAGES.OVERVIEW:
case ML_PAGES.SETTINGS:
case ML_PAGES.FILTER_LISTS_MANAGE:

View file

@ -19475,7 +19475,6 @@
"xpack.ml.aiops.explainLogRateSpikes.docTitle": "Expliquer les pics de taux de log",
"xpack.ml.aiopsBreadcrumbLabel": "AIOps",
"xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel": "Expliquer les pics de taux de log",
"xpack.ml.aiopsBreadcrumbs.selectDateViewLabel": "Vue de données",
"xpack.ml.alertConditionValidation.title": "La condition d'alerte contient les problèmes suivants :",
"xpack.ml.alertContext.anomalyExplorerUrlDescription": "URL pour ouvrir dans Anomaly Explorer",
"xpack.ml.alertContext.isInterimDescription": "Indique si les premiers résultats contiennent des résultats temporaires",

View file

@ -19456,7 +19456,6 @@
"xpack.ml.aiops.explainLogRateSpikes.docTitle": "ログレートスパイクを説明",
"xpack.ml.aiopsBreadcrumbLabel": "AIOps",
"xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel": "ログレートスパイクを説明",
"xpack.ml.aiopsBreadcrumbs.selectDateViewLabel": "データビュー",
"xpack.ml.alertConditionValidation.title": "アラート条件には次の問題が含まれます。",
"xpack.ml.alertContext.anomalyExplorerUrlDescription": "異常エクスプローラーを開くURL",
"xpack.ml.alertContext.isInterimDescription": "上位の一致に中間結果が含まれるかどうかを示します",

View file

@ -19486,7 +19486,6 @@
"xpack.ml.aiops.explainLogRateSpikes.docTitle": "解释日志速率峰值",
"xpack.ml.aiopsBreadcrumbLabel": "AIOps",
"xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel": "解释日志速率峰值",
"xpack.ml.aiopsBreadcrumbs.selectDateViewLabel": "数据视图",
"xpack.ml.alertConditionValidation.title": "告警条件包含以下问题:",
"xpack.ml.alertContext.anomalyExplorerUrlDescription": "要在 Anomaly Explorer 中打开的 URL",
"xpack.ml.alertContext.isInterimDescription": "表示排名靠前的命中是否包含中间结果",