mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[ML] AIOps: Add/edit change point charts embeddable from the Dashboard app (#163694)
This commit is contained in:
parent
39f2a2567a
commit
fb6ac2e445
24 changed files with 862 additions and 257 deletions
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { memoize } from 'lodash';
|
||||
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
|
||||
export interface NumberValidationResult {
|
||||
|
@ -29,7 +30,7 @@ export function numberValidator(conditions?: {
|
|||
throw new Error('Invalid validator conditions');
|
||||
}
|
||||
|
||||
return (value: number): NumberValidationResult | null => {
|
||||
return memoize((value: number): NumberValidationResult | null => {
|
||||
const result = {} as NumberValidationResult;
|
||||
if (conditions?.min !== undefined && value < conditions.min) {
|
||||
result.min = true;
|
||||
|
@ -44,5 +45,5 @@ export function numberValidator(conditions?: {
|
|||
return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -16,7 +16,8 @@
|
|||
"embeddable",
|
||||
"presentationUtil",
|
||||
"dashboard",
|
||||
"fieldFormats"
|
||||
"fieldFormats",
|
||||
"unifiedSearch"
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"cases"
|
||||
|
|
|
@ -94,6 +94,46 @@ export interface ChangePointAnnotation {
|
|||
|
||||
export type SelectedChangePoint = FieldConfig & ChangePointAnnotation;
|
||||
|
||||
export const ChangePointDetectionControlsContext = createContext<{
|
||||
metricFieldOptions: DataViewField[];
|
||||
splitFieldsOptions: DataViewField[];
|
||||
}>({
|
||||
splitFieldsOptions: [],
|
||||
metricFieldOptions: [],
|
||||
});
|
||||
|
||||
export const useChangePointDetectionControlsContext = () => {
|
||||
return useContext(ChangePointDetectionControlsContext);
|
||||
};
|
||||
|
||||
export const ChangePointDetectionControlsContextProvider: FC = ({ children }) => {
|
||||
const { dataView } = useDataSource();
|
||||
|
||||
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.some((el) =>
|
||||
[ES_FIELD_TYPES.KEYWORD, ES_FIELD_TYPES.IP].includes(el as ES_FIELD_TYPES)
|
||||
) &&
|
||||
!['_id', '_index'].includes(displayName)
|
||||
);
|
||||
}, [dataView]);
|
||||
|
||||
const value = { metricFieldOptions, splitFieldsOptions };
|
||||
|
||||
return (
|
||||
<ChangePointDetectionControlsContext.Provider value={value}>
|
||||
{children}
|
||||
</ChangePointDetectionControlsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChangePointDetectionContextProvider: FC = ({ children }) => {
|
||||
const { dataView, savedSearch } = useDataSource();
|
||||
const {
|
||||
|
|
|
@ -28,7 +28,10 @@ import { AIOPS_STORAGE_KEYS } from '../../types/storage';
|
|||
import { PageHeader } from '../page_header';
|
||||
|
||||
import { ChangePointDetectionPage } from './change_point_detection_page';
|
||||
import { ChangePointDetectionContextProvider } from './change_point_detection_context';
|
||||
import {
|
||||
ChangePointDetectionContextProvider,
|
||||
ChangePointDetectionControlsContextProvider,
|
||||
} from './change_point_detection_context';
|
||||
import { timeSeriesDataViewWarning } from '../../application/utils/time_series_dataview_check';
|
||||
import { ReloadContextProvider } from '../../hooks/use_reload';
|
||||
|
||||
|
@ -87,7 +90,9 @@ export const ChangePointDetectionAppState: FC<ChangePointDetectionAppStateProps>
|
|||
<EuiSpacer />
|
||||
<ReloadContextProvider reload$={reload$}>
|
||||
<ChangePointDetectionContextProvider>
|
||||
<ChangePointDetectionPage />
|
||||
<ChangePointDetectionControlsContextProvider>
|
||||
<ChangePointDetectionPage />
|
||||
</ChangePointDetectionControlsContextProvider>
|
||||
</ChangePointDetectionContextProvider>
|
||||
</ReloadContextProvider>
|
||||
</DatePickerContextProvider>
|
||||
|
|
|
@ -16,6 +16,8 @@ export interface ChartComponentProps {
|
|||
annotation: ChangePointAnnotation;
|
||||
|
||||
interval: string;
|
||||
|
||||
onLoading?: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ChartComponentPropsAll {
|
||||
|
@ -29,7 +31,7 @@ export interface ChartComponentPropsAll {
|
|||
}
|
||||
|
||||
export const ChartComponent: FC<ChartComponentProps> = React.memo(
|
||||
({ annotation, fieldConfig, interval }) => {
|
||||
({ annotation, fieldConfig, interval, onLoading }) => {
|
||||
const {
|
||||
lens: { EmbeddableComponent },
|
||||
} = useAiopsAppContext();
|
||||
|
@ -55,6 +57,7 @@ export const ChartComponent: FC<ChartComponentProps> = React.memo(
|
|||
name: 'Change point detection',
|
||||
}}
|
||||
disableTriggers
|
||||
onLoad={onLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { type FC, useMemo, useState, useEffect, useRef } from 'react';
|
||||
import React, { type FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiDescriptionList,
|
||||
|
@ -42,10 +42,28 @@ interface ChartsGridProps {
|
|||
* @param changePoints
|
||||
* @constructor
|
||||
*/
|
||||
export const ChartsGrid: FC<{ changePoints: SelectedChangePoint[]; interval: string }> = ({
|
||||
changePoints,
|
||||
interval,
|
||||
}) => {
|
||||
export const ChartsGrid: FC<{
|
||||
changePoints: SelectedChangePoint[];
|
||||
interval: string;
|
||||
onRenderComplete?: () => void;
|
||||
}> = ({ changePoints, interval, onRenderComplete }) => {
|
||||
// Render is complete when all chart components in the grid are ready
|
||||
const loadCounter = useRef<Record<number, boolean>>(
|
||||
Object.fromEntries(changePoints.map((v, i) => [i, true]))
|
||||
);
|
||||
|
||||
const onLoadCallback = useCallback(
|
||||
(chartId: number, isLoading: boolean) => {
|
||||
if (!onRenderComplete) return;
|
||||
loadCounter.current[chartId] = isLoading;
|
||||
const isLoadComplete = Object.values(loadCounter.current).every((v) => !v);
|
||||
if (isLoadComplete) {
|
||||
onRenderComplete();
|
||||
}
|
||||
},
|
||||
[onRenderComplete]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGrid
|
||||
columns={changePoints.length >= 2 ? 2 : 1}
|
||||
|
@ -122,6 +140,9 @@ export const ChartsGrid: FC<{ changePoints: SelectedChangePoint[]; interval: str
|
|||
fieldConfig={{ splitField: v.splitField, fn: v.fn, metricField: v.metricField }}
|
||||
annotation={v}
|
||||
interval={interval}
|
||||
onLoading={(isLoading) => {
|
||||
onLoadCallback(index, isLoading);
|
||||
}}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -11,18 +11,15 @@ import {
|
|||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiContextMenu,
|
||||
EuiFieldNumber,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiPanel,
|
||||
EuiPopover,
|
||||
EuiProgress,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
@ -35,7 +32,7 @@ import {
|
|||
} from '@kbn/presentation-util-plugin/public';
|
||||
import { EuiContextMenuProps } from '@elastic/eui/src/components/context_menu/context_menu';
|
||||
import { isDefined } from '@kbn/ml-is-defined';
|
||||
import { numberValidator } from '@kbn/ml-agg-utils';
|
||||
import { MaxSeriesControl } from './max_series_control';
|
||||
import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../../common/constants';
|
||||
import { useCasesModal } from '../../hooks/use_cases_modal';
|
||||
import { type EmbeddableChangePointChartInput } from '../../embeddable/embeddable_change_point_chart';
|
||||
|
@ -54,7 +51,6 @@ import {
|
|||
} from './change_point_detection_context';
|
||||
import { useChangePointResults } from './use_change_point_agg_request';
|
||||
import { useSplitFieldCardinality } from './use_split_field_cardinality';
|
||||
import { MAX_SERIES } from '../../embeddable/const';
|
||||
|
||||
const selectControlCss = { width: '350px' };
|
||||
|
||||
|
@ -183,8 +179,8 @@ const FieldPanel: FC<FieldPanelProps> = ({
|
|||
const splitFieldCardinality = useSplitFieldCardinality(fieldConfig.splitField, combinedQuery);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
|
||||
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
|
||||
const [isDashboardFormValid, setIsDashboardFormValid] = useState(true);
|
||||
|
||||
const canEditDashboards = capabilities.dashboard?.createNew ?? false;
|
||||
const { create: canCreateCase, update: canUpdateCase } = cases?.helpers?.canUseCases() ?? {
|
||||
|
@ -218,13 +214,6 @@ const FieldPanel: FC<FieldPanelProps> = ({
|
|||
|
||||
const timeRange = useTimeRangeUpdates();
|
||||
|
||||
const maxSeriesValidator = useMemo(
|
||||
() => numberValidator({ min: 1, max: MAX_SERIES, integerOnly: true }),
|
||||
[]
|
||||
);
|
||||
|
||||
const maxSeriesInvalid = maxSeriesValidator(dashboardAttachment.maxSeriesToPlot) !== null;
|
||||
|
||||
const panels = useMemo<EuiContextMenuProps['panels']>(() => {
|
||||
return [
|
||||
{
|
||||
|
@ -348,54 +337,20 @@ const FieldPanel: FC<FieldPanelProps> = ({
|
|||
/>
|
||||
</EuiFormRow>
|
||||
{isDefined(fieldConfig.splitField) && selectedPartitions.length === 0 ? (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
isInvalid={maxSeriesInvalid}
|
||||
error={
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.changePointDetection.maxSeriesToPlotError"
|
||||
defaultMessage="Max series value must be between {minValue} and {maxValue}"
|
||||
values={{ minValue: 1, maxValue: MAX_SERIES }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<EuiFlexGroup gutterSize={'xs'} alignItems={'center'}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.changePointDetection.maxSeriesToPlotLabel"
|
||||
defaultMessage="Max series"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={i18n.translate(
|
||||
'xpack.aiops.changePointDetection.maxSeriesToPlotDescription',
|
||||
{
|
||||
defaultMessage: 'The maximum number of change points to visualize.',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiIcon type={'questionInCircle'} />
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
isInvalid={maxSeriesInvalid}
|
||||
value={dashboardAttachment.maxSeriesToPlot}
|
||||
onChange={(e) =>
|
||||
setDashboardAttachment((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
maxSeriesToPlot: Number(e.target.value),
|
||||
};
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={MAX_SERIES}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<MaxSeriesControl
|
||||
value={dashboardAttachment.maxSeriesToPlot}
|
||||
onChange={(v) => {
|
||||
setDashboardAttachment((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
maxSeriesToPlot: v,
|
||||
};
|
||||
});
|
||||
}}
|
||||
onValidationChange={(result) => {
|
||||
setIsDashboardFormValid(result === null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<EuiSpacer size={'m'} />
|
||||
|
@ -405,7 +360,7 @@ const FieldPanel: FC<FieldPanelProps> = ({
|
|||
type={'submit'}
|
||||
fullWidth
|
||||
onClick={setDashboardAttachmentReady.bind(null, true)}
|
||||
disabled={maxSeriesInvalid}
|
||||
disabled={!isDashboardFormValid}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.changePointDetection.submitDashboardAttachButtonLabel"
|
||||
|
@ -428,12 +383,12 @@ const FieldPanel: FC<FieldPanelProps> = ({
|
|||
fieldConfig.fn,
|
||||
fieldConfig.metricField,
|
||||
fieldConfig.splitField,
|
||||
isDashboardFormValid,
|
||||
onRemove,
|
||||
openCasesModalCallback,
|
||||
removeDisabled,
|
||||
selectedPartitions,
|
||||
timeRange,
|
||||
maxSeriesInvalid,
|
||||
]);
|
||||
|
||||
const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback(
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EuiFieldNumber, EuiFormRow, EuiIcon, EuiToolTip } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { type NumberValidationResult, numberValidator } from '@kbn/ml-agg-utils';
|
||||
import { MAX_SERIES } from '../../embeddable/const';
|
||||
|
||||
const maxSeriesValidator = numberValidator({ min: 1, max: MAX_SERIES, integerOnly: true });
|
||||
|
||||
export const MaxSeriesControl: FC<{
|
||||
disabled?: boolean;
|
||||
value: number;
|
||||
onChange: (update: number) => void;
|
||||
onValidationChange?: (result: NumberValidationResult | null) => void;
|
||||
inline?: boolean;
|
||||
}> = ({ value, onChange, onValidationChange, disabled, inline = true }) => {
|
||||
const maxSeriesValidationResult = maxSeriesValidator(value);
|
||||
const maxSeriesInvalid = maxSeriesValidationResult !== null;
|
||||
|
||||
const label = i18n.translate('xpack.aiops.changePointDetection.maxSeriesToPlotLabel', {
|
||||
defaultMessage: 'Max series',
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
isInvalid={maxSeriesInvalid}
|
||||
error={
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.changePointDetection.maxSeriesToPlotError"
|
||||
defaultMessage="Max series value must be between {minValue} and {maxValue}"
|
||||
values={{ minValue: 1, maxValue: MAX_SERIES }}
|
||||
/>
|
||||
}
|
||||
label={inline ? undefined : label}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
disabled={disabled}
|
||||
prepend={inline ? label : undefined}
|
||||
append={
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.aiops.changePointDetection.maxSeriesToPlotDescription', {
|
||||
defaultMessage: 'The maximum number of change points to visualize.',
|
||||
})}
|
||||
>
|
||||
<EuiIcon type={'questionInCircle'} />
|
||||
</EuiToolTip>
|
||||
}
|
||||
compressed
|
||||
fullWidth
|
||||
isInvalid={maxSeriesInvalid}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newValue = Number(e.target.value);
|
||||
onChange(newValue);
|
||||
if (onValidationChange) {
|
||||
onValidationChange(maxSeriesValidator(newValue));
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={MAX_SERIES}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -8,22 +8,23 @@
|
|||
import React, { type FC, useCallback, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiComboBox, type EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
|
||||
import { useChangePointDetectionContext } from './change_point_detection_context';
|
||||
import { useChangePointDetectionControlsContext } from './change_point_detection_context';
|
||||
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
|
||||
|
||||
interface MetricFieldSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const MetricFieldSelector: FC<MetricFieldSelectorProps> = React.memo(
|
||||
({ value, onChange }) => {
|
||||
({ value, onChange, inline = true }) => {
|
||||
const { fieldStats } = useAiopsAppContext();
|
||||
const { metricFieldOptions } = useChangePointDetectionContext();
|
||||
const { metricFieldOptions } = useChangePointDetectionControlsContext();
|
||||
|
||||
const { renderOption, closeFlyout } = fieldStats!.useFieldStatsTrigger();
|
||||
const { renderOption, closeFlyout } = fieldStats?.useFieldStatsTrigger() ?? {};
|
||||
|
||||
const options = useMemo<EuiComboBoxOptionOption[]>(() => {
|
||||
const options = useMemo<Array<EuiComboBoxOptionOption<string>>>(() => {
|
||||
return metricFieldOptions.map((v) => {
|
||||
return {
|
||||
value: v.name,
|
||||
|
@ -41,26 +42,30 @@ export const MetricFieldSelector: FC<MetricFieldSelectorProps> = React.memo(
|
|||
if (typeof option !== 'undefined') {
|
||||
onChange(option.value as string);
|
||||
}
|
||||
closeFlyout();
|
||||
if (closeFlyout) {
|
||||
closeFlyout();
|
||||
}
|
||||
},
|
||||
[onChange, closeFlyout]
|
||||
);
|
||||
|
||||
const label = i18n.translate('xpack.aiops.changePointDetection.selectMetricFieldLabel', {
|
||||
defaultMessage: 'Metric field',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow>
|
||||
<EuiFormRow fullWidth label={inline ? undefined : label}>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
compressed
|
||||
prepend={i18n.translate('xpack.aiops.changePointDetection.selectMetricFieldLabel', {
|
||||
defaultMessage: 'Metric field',
|
||||
})}
|
||||
prepend={inline ? label : undefined}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={options}
|
||||
selectedOptions={selection}
|
||||
onChange={onChangeCallback}
|
||||
isClearable={false}
|
||||
data-test-subj="aiopsChangePointMetricField"
|
||||
// @ts-ignore
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* 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, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { type SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
|
||||
import { debounce } from 'lodash';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
|
||||
import { useCancellableSearch } from '../../hooks/use_cancellable_search';
|
||||
import { useDataSource } from '../../hooks/use_data_source';
|
||||
|
||||
export interface PartitionsSelectorProps {
|
||||
splitField: string;
|
||||
value: string[];
|
||||
onChange: (update: string[]) => void;
|
||||
enableSearch?: boolean;
|
||||
}
|
||||
|
||||
function getQueryPayload(
|
||||
indexPattern: string,
|
||||
fieldName: string,
|
||||
queryString: string = '',
|
||||
selectedPartitions?: string[]
|
||||
) {
|
||||
return {
|
||||
params: {
|
||||
index: indexPattern,
|
||||
size: 0,
|
||||
...(selectedPartitions?.length
|
||||
? {
|
||||
query: {
|
||||
bool: {
|
||||
must_not: [
|
||||
{
|
||||
terms: {
|
||||
[fieldName]: selectedPartitions,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
aggs: {
|
||||
aggResults: {
|
||||
filter: {
|
||||
bool: {
|
||||
must: {
|
||||
wildcard: {
|
||||
[fieldName]: {
|
||||
value: `*${queryString}*`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
partitionValues: {
|
||||
terms: {
|
||||
field: fieldName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as SearchRequest,
|
||||
};
|
||||
}
|
||||
|
||||
interface PartitionsResponse {
|
||||
aggregations: {
|
||||
aggResults: {
|
||||
partitionValues: { buckets: Array<{ key: string }> };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const PartitionsSelector: FC<PartitionsSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
splitField,
|
||||
enableSearch = true,
|
||||
}) => {
|
||||
const { dataView } = useDataSource();
|
||||
const {
|
||||
notifications: { toasts },
|
||||
} = useAiopsAppContext();
|
||||
const prevSplitField = usePrevious(splitField);
|
||||
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
|
||||
const [isLoading, setIsLoading] = useState(enableSearch);
|
||||
const { runRequest, cancelRequest } = useCancellableSearch();
|
||||
|
||||
const fetchResults = useCallback(
|
||||
async (searchValue: string) => {
|
||||
if (!enableSearch) return;
|
||||
|
||||
cancelRequest();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const requestPayload = getQueryPayload(
|
||||
dataView.getIndexPattern(),
|
||||
splitField,
|
||||
searchValue,
|
||||
value
|
||||
);
|
||||
|
||||
const result = await runRequest<typeof requestPayload, { rawResponse: PartitionsResponse }>(
|
||||
requestPayload
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setOptions(
|
||||
result.rawResponse.aggregations.aggResults.partitionValues.buckets.map((v) => ({
|
||||
value: v.key,
|
||||
label: v.key,
|
||||
}))
|
||||
);
|
||||
} catch (e) {
|
||||
toasts.addError(e, {
|
||||
title: i18n.translate('xpack.aiops.changePointDetection.fetchPartitionsErrorTitle', {
|
||||
defaultMessage: 'Failed to fetch partitions',
|
||||
}),
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
[enableSearch, cancelRequest, dataView, splitField, value, runRequest, toasts]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function onSplitFieldChange() {
|
||||
if (splitField !== prevSplitField) {
|
||||
fetchResults('');
|
||||
onChange([]);
|
||||
}
|
||||
},
|
||||
[splitField, prevSplitField, fetchResults, onChange]
|
||||
);
|
||||
|
||||
const selectedOptions = useMemo<Array<EuiComboBoxOptionOption<string>>>(() => {
|
||||
return value.map((v) => ({ value: v, label: v }));
|
||||
}, [value]);
|
||||
|
||||
const onChangeCallback = useCallback(
|
||||
(udpate: EuiComboBoxOptionOption[]) => {
|
||||
onChange(udpate.map((v) => v.value as string));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const onSearchChange = useMemo(() => debounce(fetchResults, 500), [fetchResults]);
|
||||
|
||||
const onCreateOption = useCallback(
|
||||
(v: string) => {
|
||||
onChange([...value, v]);
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.translate('xpack.aiops.changePointDetection.partitionsLabel', {
|
||||
defaultMessage: 'Partitions',
|
||||
})}
|
||||
>
|
||||
<EuiComboBox<string>
|
||||
isLoading={isLoading}
|
||||
fullWidth
|
||||
compressed
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
onChange={onChangeCallback}
|
||||
onSearchChange={enableSearch ? onSearchChange : undefined}
|
||||
onCreateOption={!enableSearch ? onCreateOption : undefined}
|
||||
isClearable
|
||||
data-test-subj="aiopsChangePointPartitions"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -9,63 +9,70 @@ import React, { FC, useMemo, useCallback } from 'react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiComboBox, type EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
|
||||
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
|
||||
import { useChangePointDetectionContext } from './change_point_detection_context';
|
||||
import { useChangePointDetectionControlsContext } from './change_point_detection_context';
|
||||
|
||||
interface SplitFieldSelectorProps {
|
||||
value: string | undefined;
|
||||
onChange: (value: string | undefined) => void;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const SplitFieldSelector: FC<SplitFieldSelectorProps> = React.memo(({ value, onChange }) => {
|
||||
const { fieldStats } = useAiopsAppContext();
|
||||
const { renderOption, closeFlyout } = fieldStats!.useFieldStatsTrigger();
|
||||
export const SplitFieldSelector: FC<SplitFieldSelectorProps> = React.memo(
|
||||
({ value, onChange, inline = true }) => {
|
||||
const { fieldStats } = useAiopsAppContext();
|
||||
const { renderOption, closeFlyout } = fieldStats?.useFieldStatsTrigger() ?? {};
|
||||
|
||||
const { splitFieldsOptions } = useChangePointDetectionContext();
|
||||
const { splitFieldsOptions } = useChangePointDetectionControlsContext();
|
||||
|
||||
const options = useMemo<EuiComboBoxOptionOption[]>(() => {
|
||||
return [
|
||||
{
|
||||
value: undefined,
|
||||
label: i18n.translate('xpack.aiops.changePointDetection.notSelectedSplitFieldLabel', {
|
||||
defaultMessage: '--- Not selected ---',
|
||||
}),
|
||||
const options = useMemo<Array<EuiComboBoxOptionOption<string>>>(() => {
|
||||
return [
|
||||
{
|
||||
value: undefined,
|
||||
label: i18n.translate('xpack.aiops.changePointDetection.notSelectedSplitFieldLabel', {
|
||||
defaultMessage: '--- Not selected ---',
|
||||
}),
|
||||
},
|
||||
...splitFieldsOptions.map((v) => ({
|
||||
value: v.name,
|
||||
label: v.displayName,
|
||||
...(v.name ? { field: { id: v.name, type: v?.type } } : {}),
|
||||
})),
|
||||
];
|
||||
}, [splitFieldsOptions]);
|
||||
|
||||
const selection = options.filter((v) => v.value === value);
|
||||
|
||||
const onChangeCallback = useCallback(
|
||||
(selectedOptions: EuiComboBoxOptionOption[]) => {
|
||||
const option = selectedOptions[0];
|
||||
const newValue = option?.value as string;
|
||||
onChange(newValue);
|
||||
if (closeFlyout) {
|
||||
closeFlyout();
|
||||
}
|
||||
},
|
||||
...splitFieldsOptions.map((v) => ({
|
||||
value: v.name,
|
||||
label: v.displayName,
|
||||
...(v.name ? { field: { id: v.name, type: v?.type } } : {}),
|
||||
})),
|
||||
];
|
||||
}, [splitFieldsOptions]);
|
||||
[onChange, closeFlyout]
|
||||
);
|
||||
|
||||
const selection = options.filter((v) => v.value === value);
|
||||
const label = i18n.translate('xpack.aiops.changePointDetection.selectSpitFieldLabel', {
|
||||
defaultMessage: 'Split field',
|
||||
});
|
||||
|
||||
const onChangeCallback = useCallback(
|
||||
(selectedOptions: EuiComboBoxOptionOption[]) => {
|
||||
const option = selectedOptions[0];
|
||||
const newValue = option?.value as string;
|
||||
onChange(newValue);
|
||||
closeFlyout();
|
||||
},
|
||||
[onChange, closeFlyout]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow>
|
||||
<EuiComboBox
|
||||
compressed
|
||||
prepend={i18n.translate('xpack.aiops.changePointDetection.selectSpitFieldLabel', {
|
||||
defaultMessage: 'Split field',
|
||||
})}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={options}
|
||||
selectedOptions={selection}
|
||||
onChange={onChangeCallback}
|
||||
isClearable
|
||||
data-test-subj="aiopsChangePointSplitField"
|
||||
// @ts-ignore
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<EuiFormRow fullWidth label={inline ? undefined : label}>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
compressed
|
||||
prepend={inline ? label : undefined}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={options}
|
||||
selectedOptions={selection}
|
||||
onChange={onChangeCallback}
|
||||
isClearable
|
||||
data-test-subj="aiopsChangePointSplitField"
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -5,59 +5,97 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiHorizontalRule,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiFieldNumber,
|
||||
EuiFieldText,
|
||||
EuiModal,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { pick } from 'lodash';
|
||||
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
|
||||
import { ES_FIELD_TYPES } from '@kbn/field-types';
|
||||
import { PartitionsSelector } from '../components/change_point_detection/partitions_selector';
|
||||
import { DEFAULT_SERIES } from './const';
|
||||
import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component';
|
||||
import { type EmbeddableChangePointChartExplicitInput } from './types';
|
||||
import { MaxSeriesControl } from '../components/change_point_detection/max_series_control';
|
||||
import { SplitFieldSelector } from '../components/change_point_detection/split_field_selector';
|
||||
import { MetricFieldSelector } from '../components/change_point_detection/metric_field_selector';
|
||||
import {
|
||||
ChangePointDetectionControlsContextProvider,
|
||||
useChangePointDetectionControlsContext,
|
||||
} from '../components/change_point_detection/change_point_detection_context';
|
||||
import { useAiopsAppContext } from '../hooks/use_aiops_app_context';
|
||||
import { EmbeddableChangePointChartInput } from './embeddable_change_point_chart';
|
||||
import { FunctionPicker } from '../components/change_point_detection/function_picker';
|
||||
|
||||
export const MAX_ANOMALY_CHARTS_ALLOWED = 50;
|
||||
|
||||
export const DEFAULT_MAX_SERIES_TO_PLOT = 6;
|
||||
import { DataSourceContextProvider } from '../hooks/use_data_source';
|
||||
import { DEFAULT_AGG_FUNCTION } from '../components/change_point_detection/constants';
|
||||
|
||||
export interface AnomalyChartsInitializerProps {
|
||||
defaultTitle: string;
|
||||
initialInput?: Partial<EmbeddableChangePointChartInput>;
|
||||
onCreate: (props: { panelTitle: string; maxSeriesToPlot?: number }) => void;
|
||||
onCreate: (props: EmbeddableChangePointChartExplicitInput) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ChangePointChartInitializer: FC<AnomalyChartsInitializerProps> = ({
|
||||
defaultTitle,
|
||||
initialInput,
|
||||
onCreate,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [panelTitle, setPanelTitle] = useState(defaultTitle);
|
||||
const [maxSeriesToPlot, setMaxSeriesToPlot] = useState(
|
||||
initialInput?.maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT
|
||||
const {
|
||||
unifiedSearch: {
|
||||
ui: { IndexPatternSelect },
|
||||
},
|
||||
} = useAiopsAppContext();
|
||||
|
||||
const [dataViewId, setDataViewId] = useState(initialInput?.dataViewId ?? '');
|
||||
|
||||
const [formInput, setFormInput] = useState<FormControlsProps>(
|
||||
pick(initialInput ?? {}, [
|
||||
'fn',
|
||||
'metricField',
|
||||
'splitField',
|
||||
'maxSeriesToPlot',
|
||||
'partitions',
|
||||
]) as FormControlsProps
|
||||
);
|
||||
|
||||
const [fn, setFn] = useState<string>(initialInput?.fn ?? 'avg');
|
||||
const [isFormValid, setIsFormValid] = useState(true);
|
||||
|
||||
const isPanelTitleValid = panelTitle.length > 0;
|
||||
const isMaxSeriesToPlotValid =
|
||||
maxSeriesToPlot >= 1 && maxSeriesToPlot <= MAX_ANOMALY_CHARTS_ALLOWED;
|
||||
const isFormValid = isPanelTitleValid && isMaxSeriesToPlotValid;
|
||||
const updatedProps = useMemo(() => {
|
||||
return {
|
||||
...formInput,
|
||||
title: isPopulatedObject(formInput)
|
||||
? i18n.translate('xpack.aiops.changePointDetection.attachmentTitle', {
|
||||
defaultMessage: 'Change point: {function}({metric}){splitBy}',
|
||||
values: {
|
||||
function: formInput.fn,
|
||||
metric: formInput?.metricField,
|
||||
splitBy: formInput?.splitField
|
||||
? i18n.translate('xpack.aiops.changePointDetection.splitByTitle', {
|
||||
defaultMessage: ' split by "{splitField}"',
|
||||
values: { splitField: formInput.splitField },
|
||||
})
|
||||
: '',
|
||||
},
|
||||
})
|
||||
: '',
|
||||
dataViewId,
|
||||
};
|
||||
}, [formInput, dataViewId]);
|
||||
|
||||
return (
|
||||
<EuiModal
|
||||
initialFocus="[name=panelTitle]"
|
||||
onClose={onCancel}
|
||||
data-test-subj={'aiopsChangePointChartEmbeddableInitializer'}
|
||||
>
|
||||
<EuiModal onClose={onCancel} data-test-subj={'aiopsChangePointChartEmbeddableInitializer'}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
|
@ -70,67 +108,46 @@ export const ChangePointChartInitializer: FC<AnomalyChartsInitializerProps> = ({
|
|||
<EuiModalBody>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.embeddableChangePointChart.panelTitleLabel"
|
||||
defaultMessage="Panel title"
|
||||
/>
|
||||
}
|
||||
isInvalid={!isPanelTitleValid}
|
||||
fullWidth
|
||||
label={i18n.translate('xpack.aiops.embeddableChangePointChart.dataViewLabel', {
|
||||
defaultMessage: 'Data view',
|
||||
})}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="panelTitleInput"
|
||||
id="panelTitle"
|
||||
name="panelTitle"
|
||||
value={panelTitle}
|
||||
onChange={(e) => setPanelTitle(e.target.value)}
|
||||
isInvalid={!isPanelTitleValid}
|
||||
<IndexPatternSelect
|
||||
autoFocus={!dataViewId}
|
||||
fullWidth
|
||||
compressed
|
||||
indexPatternId={dataViewId}
|
||||
placeholder={i18n.translate(
|
||||
'xpack.aiops.embeddableChangePointChart.dataViewSelectorPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Select data view',
|
||||
}
|
||||
)}
|
||||
onChange={(newId) => {
|
||||
setDataViewId(newId ?? '');
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.embeddableChangePointChart.functionLabel"
|
||||
defaultMessage="Function"
|
||||
<DataSourceContextProvider dataViewId={dataViewId}>
|
||||
<EuiHorizontalRule margin={'s'} />
|
||||
<ChangePointDetectionControlsContextProvider>
|
||||
<FormControls
|
||||
formInput={formInput}
|
||||
onChange={setFormInput}
|
||||
onValidationChange={setIsFormValid}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FunctionPicker value={fn} onChange={setFn} />
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
isInvalid={!isMaxSeriesToPlotValid}
|
||||
error={
|
||||
!isMaxSeriesToPlotValid ? (
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.embeddableChangePointChart.maxSeriesToPlotError"
|
||||
defaultMessage="Maximum number of series to plot must be between 1 and 50."
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.embeddableChangePointChart.maxSeriesToPlotLabel"
|
||||
defaultMessage="Maximum number of series to plot"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
data-test-subj="mlAnomalyChartsInitializerMaxSeries"
|
||||
id="selectMaxSeriesToPlot"
|
||||
name="selectMaxSeriesToPlot"
|
||||
value={maxSeriesToPlot}
|
||||
onChange={(e) => setMaxSeriesToPlot(parseInt(e.target.value, 10))}
|
||||
min={1}
|
||||
max={MAX_ANOMALY_CHARTS_ALLOWED}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</ChangePointDetectionControlsContextProvider>
|
||||
</DataSourceContextProvider>
|
||||
</EuiForm>
|
||||
</EuiModalBody>
|
||||
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={onCancel} data-test-subj="mlAnomalyChartsInitializerCancelButton">
|
||||
<EuiButtonEmpty
|
||||
onClick={onCancel}
|
||||
data-test-subj="aiopsChangePointChartsInitializerCancelButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.embeddableChangePointChart.setupModal.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
|
@ -138,12 +155,9 @@ export const ChangePointChartInitializer: FC<AnomalyChartsInitializerProps> = ({
|
|||
</EuiButtonEmpty>
|
||||
|
||||
<EuiButton
|
||||
data-test-subj="mlAnomalyChartsInitializerConfirmButton"
|
||||
isDisabled={!isFormValid}
|
||||
onClick={onCreate.bind(null, {
|
||||
panelTitle,
|
||||
maxSeriesToPlot,
|
||||
})}
|
||||
data-test-subj="aiopsChangePointChartsInitializerConfirmButton"
|
||||
isDisabled={!isFormValid || !dataViewId}
|
||||
onClick={onCreate.bind(null, updatedProps)}
|
||||
fill
|
||||
>
|
||||
<FormattedMessage
|
||||
|
@ -155,3 +169,109 @@ export const ChangePointChartInitializer: FC<AnomalyChartsInitializerProps> = ({
|
|||
</EuiModal>
|
||||
);
|
||||
};
|
||||
|
||||
export type FormControlsProps = Pick<
|
||||
EmbeddableChangePointChartProps,
|
||||
'metricField' | 'splitField' | 'fn' | 'maxSeriesToPlot' | 'partitions'
|
||||
>;
|
||||
|
||||
export const FormControls: FC<{
|
||||
formInput?: FormControlsProps;
|
||||
onChange: (update: FormControlsProps) => void;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
}> = ({ formInput, onChange, onValidationChange }) => {
|
||||
const { metricFieldOptions, splitFieldsOptions } = useChangePointDetectionControlsContext();
|
||||
const prevMetricFieldOptions = usePrevious(metricFieldOptions);
|
||||
|
||||
const enableSearch = useMemo<boolean>(() => {
|
||||
const field = splitFieldsOptions.find((v) => v.name === formInput?.splitField);
|
||||
if (field && field.esTypes) {
|
||||
return field.esTypes?.some((t) => t === ES_FIELD_TYPES.KEYWORD);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, [splitFieldsOptions, formInput?.splitField]);
|
||||
|
||||
useEffect(
|
||||
function setDefaultOnDataViewChange() {
|
||||
if (!isPopulatedObject(formInput)) {
|
||||
onChange({
|
||||
fn: DEFAULT_AGG_FUNCTION,
|
||||
metricField: metricFieldOptions[0]?.name,
|
||||
splitField: undefined,
|
||||
partitions: undefined,
|
||||
maxSeriesToPlot: DEFAULT_SERIES,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (metricFieldOptions === prevMetricFieldOptions) return;
|
||||
|
||||
onChange({
|
||||
fn: formInput.fn,
|
||||
metricField: metricFieldOptions[0]?.name,
|
||||
splitField: undefined,
|
||||
partitions: undefined,
|
||||
maxSeriesToPlot: formInput.maxSeriesToPlot,
|
||||
});
|
||||
},
|
||||
[metricFieldOptions, prevMetricFieldOptions, formInput, onChange]
|
||||
);
|
||||
|
||||
const updateCallback = useCallback(
|
||||
(update: Partial<FormControlsProps>) => {
|
||||
onChange({
|
||||
...formInput,
|
||||
...update,
|
||||
} as FormControlsProps);
|
||||
},
|
||||
[formInput, onChange]
|
||||
);
|
||||
|
||||
if (!isPopulatedObject(formInput)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.embeddableChangePointChart.functionLabel"
|
||||
defaultMessage="Function"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FunctionPicker value={formInput.fn} onChange={(v) => updateCallback({ fn: v })} />
|
||||
</EuiFormRow>
|
||||
|
||||
<MetricFieldSelector
|
||||
inline={false}
|
||||
value={formInput.metricField}
|
||||
onChange={(v) => updateCallback({ metricField: v })}
|
||||
/>
|
||||
|
||||
<SplitFieldSelector
|
||||
inline={false}
|
||||
value={formInput.splitField}
|
||||
onChange={(v) => updateCallback({ splitField: v })}
|
||||
/>
|
||||
|
||||
{formInput.splitField ? (
|
||||
<PartitionsSelector
|
||||
value={formInput.partitions ?? []}
|
||||
onChange={(v) => updateCallback({ partitions: v })}
|
||||
splitField={formInput.splitField}
|
||||
enableSearch={enableSearch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MaxSeriesControl
|
||||
inline={false}
|
||||
disabled={!!formInput?.partitions?.length}
|
||||
value={formInput.maxSeriesToPlot!}
|
||||
onChange={(v) => updateCallback({ maxSeriesToPlot: v })}
|
||||
onValidationChange={(result) => onValidationChange(result === null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,3 +6,5 @@
|
|||
*/
|
||||
|
||||
export const MAX_SERIES = 50;
|
||||
|
||||
export const DEFAULT_SERIES = 6;
|
||||
|
|
|
@ -21,6 +21,7 @@ import { DatePickerContextProvider } from '@kbn/ml-date-picker';
|
|||
import { pick } from 'lodash';
|
||||
import { LensPublicStart } from '@kbn/lens-plugin/public';
|
||||
import { Subject } from 'rxjs';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { EmbeddableInputTracker } from './embeddable_chart_component_wrapper';
|
||||
import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../common/constants';
|
||||
import { AiopsAppContext, type AiopsAppDependencies } from '../hooks/use_aiops_app_context';
|
||||
|
@ -29,7 +30,7 @@ import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart
|
|||
|
||||
export type EmbeddableChangePointChartInput = EmbeddableInput & EmbeddableChangePointChartProps;
|
||||
|
||||
export type EmbeddableChangePointChartOutput = EmbeddableOutput;
|
||||
export type EmbeddableChangePointChartOutput = EmbeddableOutput & { indexPatterns?: DataView[] };
|
||||
|
||||
export interface EmbeddableChangePointChartDeps {
|
||||
theme: ThemeServiceStart;
|
||||
|
@ -113,6 +114,10 @@ export class EmbeddableChangePointChart extends AbstractEmbeddable<
|
|||
input$={input$}
|
||||
initialInput={input}
|
||||
reload$={this.reload$}
|
||||
onOutputChange={this.updateOutput.bind(this)}
|
||||
onRenderComplete={this.onRenderComplete.bind(this)}
|
||||
onLoading={this.onLoading.bind(this)}
|
||||
onError={this.onError.bind(this)}
|
||||
/>
|
||||
</Suspense>
|
||||
</DatePickerContextProvider>
|
||||
|
|
|
@ -44,11 +44,8 @@ export class EmbeddableChangePointChartFactory implements EmbeddableFactoryDefin
|
|||
private readonly getStartServices: StartServicesAccessor<AiopsPluginStartDeps, AiopsPluginStart>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
public isEditable = async () => {
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
getDisplayName() {
|
||||
|
@ -57,19 +54,16 @@ export class EmbeddableChangePointChartFactory implements EmbeddableFactoryDefin
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
canCreateNew() {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getExplicitInput(): Promise<Partial<EmbeddableChangePointChartInput>> {
|
||||
const [coreStart] = await this.getStartServices();
|
||||
const [coreStart, pluginStart] = await this.getStartServices();
|
||||
|
||||
try {
|
||||
const { resolveEmbeddableChangePointUserInput } = await import('./handle_explicit_input');
|
||||
return await resolveEmbeddableChangePointUserInput(coreStart);
|
||||
return await resolveEmbeddableChangePointUserInput(coreStart, pluginStart);
|
||||
} catch (e) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { type Observable } from 'rxjs';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTimefilter } from '@kbn/ml-date-picker';
|
||||
import { css } from '@emotion/react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
@ -15,10 +15,17 @@ import type {
|
|||
ChangePointAnnotation,
|
||||
ChangePointDetectionRequestParams,
|
||||
} from '../components/change_point_detection/change_point_detection_context';
|
||||
import { EmbeddableChangePointChartInput } from './embeddable_change_point_chart';
|
||||
import type {
|
||||
EmbeddableChangePointChartInput,
|
||||
EmbeddableChangePointChartOutput,
|
||||
} from './embeddable_change_point_chart';
|
||||
import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component';
|
||||
import { FilterQueryContextProvider, useFilerQueryUpdates } from '../hooks/use_filters_query';
|
||||
import { DataSourceContextProvider, useDataSource } from '../hooks/use_data_source';
|
||||
import {
|
||||
DataSourceContextProvider,
|
||||
type DataSourceContextProviderProps,
|
||||
useDataSource,
|
||||
} from '../hooks/use_data_source';
|
||||
import { useAiopsAppContext } from '../hooks/use_aiops_app_context';
|
||||
import { useTimeBuckets } from '../hooks/use_time_buckets';
|
||||
import { createMergedEsQuery } from '../application/utils/search_utils';
|
||||
|
@ -31,16 +38,37 @@ const defaultSort = {
|
|||
direction: 'asc',
|
||||
};
|
||||
|
||||
export const EmbeddableInputTracker: FC<{
|
||||
export interface EmbeddableInputTrackerProps {
|
||||
input$: Observable<EmbeddableChangePointChartInput>;
|
||||
initialInput: EmbeddableChangePointChartInput;
|
||||
reload$: Observable<number>;
|
||||
}> = ({ input$, initialInput, reload$ }) => {
|
||||
onOutputChange: (output: Partial<EmbeddableChangePointChartOutput>) => void;
|
||||
onRenderComplete: () => void;
|
||||
onLoading: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const EmbeddableInputTracker: FC<EmbeddableInputTrackerProps> = ({
|
||||
input$,
|
||||
initialInput,
|
||||
reload$,
|
||||
onOutputChange,
|
||||
onRenderComplete,
|
||||
onLoading,
|
||||
onError,
|
||||
}) => {
|
||||
const input = useObservable(input$, initialInput);
|
||||
|
||||
const onChange = useCallback<Exclude<DataSourceContextProviderProps['onChange'], undefined>>(
|
||||
({ dataViews }) => {
|
||||
onOutputChange({ indexPatterns: dataViews });
|
||||
},
|
||||
[onOutputChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<ReloadContextProvider reload$={reload$}>
|
||||
<DataSourceContextProvider dataViewId={input.dataViewId}>
|
||||
<DataSourceContextProvider dataViewId={input.dataViewId} onChange={onChange}>
|
||||
<FilterQueryContextProvider timeRange={input.timeRange}>
|
||||
<ChartGridEmbeddableWrapper
|
||||
timeRange={input.timeRange}
|
||||
|
@ -50,6 +78,9 @@ export const EmbeddableInputTracker: FC<{
|
|||
maxSeriesToPlot={input.maxSeriesToPlot}
|
||||
dataViewId={input.dataViewId}
|
||||
partitions={input.partitions}
|
||||
onLoading={onLoading}
|
||||
onRenderComplete={onRenderComplete}
|
||||
onError={onError}
|
||||
/>
|
||||
</FilterQueryContextProvider>
|
||||
</DataSourceContextProvider>
|
||||
|
@ -68,12 +99,21 @@ export const EmbeddableInputTracker: FC<{
|
|||
* @param partitions
|
||||
* @constructor
|
||||
*/
|
||||
export const ChartGridEmbeddableWrapper: FC<EmbeddableChangePointChartProps> = ({
|
||||
export const ChartGridEmbeddableWrapper: FC<
|
||||
EmbeddableChangePointChartProps & {
|
||||
onRenderComplete: () => void;
|
||||
onLoading: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
> = ({
|
||||
fn,
|
||||
metricField,
|
||||
maxSeriesToPlot,
|
||||
splitField,
|
||||
partitions,
|
||||
onError,
|
||||
onLoading,
|
||||
onRenderComplete,
|
||||
}) => {
|
||||
const { filters, query, timeRange } = useFilerQueryUpdates();
|
||||
|
||||
|
@ -134,7 +174,18 @@ export const ChartGridEmbeddableWrapper: FC<EmbeddableChangePointChartProps> = (
|
|||
return { interval } as ChangePointDetectionRequestParams;
|
||||
}, [interval]);
|
||||
|
||||
const { results } = useChangePointResults(fieldConfig, requestParams, combinedQuery, 10000);
|
||||
const { results, isLoading } = useChangePointResults(
|
||||
fieldConfig,
|
||||
requestParams,
|
||||
combinedQuery,
|
||||
10000
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
onLoading();
|
||||
}
|
||||
}, [onLoading, isLoading]);
|
||||
|
||||
const changePoints = useMemo<ChangePointAnnotation[]>(() => {
|
||||
let resultChangePoints: ChangePointAnnotation[] = results.sort((a, b) => {
|
||||
|
@ -163,6 +214,7 @@ export const ChartGridEmbeddableWrapper: FC<EmbeddableChangePointChartProps> = (
|
|||
<ChartsGrid
|
||||
changePoints={changePoints.map((r) => ({ ...r, ...fieldConfig }))}
|
||||
interval={requestParams.interval}
|
||||
onRenderComplete={onRenderComplete}
|
||||
/>
|
||||
) : (
|
||||
<NoChangePointsWarning />
|
||||
|
|
|
@ -6,41 +6,47 @@
|
|||
*/
|
||||
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import React from 'react';
|
||||
import { EmbeddableChangePointChartExplicitInput } from './types';
|
||||
import { AiopsAppDependencies } from '..';
|
||||
import { AiopsAppContext } from '../hooks/use_aiops_app_context';
|
||||
import type { AiopsPluginStartDeps } from '../types';
|
||||
import { ChangePointChartInitializer } from './change_point_chart_initializer';
|
||||
import { EmbeddableChangePointChartInput } from './embeddable_change_point_chart';
|
||||
import type { EmbeddableChangePointChartInput } from './embeddable_change_point_chart';
|
||||
|
||||
export async function resolveEmbeddableChangePointUserInput(
|
||||
coreStart: CoreStart,
|
||||
pluginStart: AiopsPluginStartDeps,
|
||||
input?: EmbeddableChangePointChartInput
|
||||
): Promise<Partial<EmbeddableChangePointChartInput>> {
|
||||
): Promise<EmbeddableChangePointChartExplicitInput> {
|
||||
const { overlays } = coreStart;
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const title = input?.title;
|
||||
const { theme$ } = coreStart.theme;
|
||||
const modalSession = overlays.openModal(
|
||||
toMountPoint(
|
||||
wrapWithTheme(
|
||||
<AiopsAppContext.Provider
|
||||
value={
|
||||
{
|
||||
...coreStart,
|
||||
...pluginStart,
|
||||
} as unknown as AiopsAppDependencies
|
||||
}
|
||||
>
|
||||
<ChangePointChartInitializer
|
||||
defaultTitle={title ?? ''}
|
||||
initialInput={input}
|
||||
onCreate={({ panelTitle, maxSeriesToPlot }) => {
|
||||
onCreate={(update: EmbeddableChangePointChartExplicitInput) => {
|
||||
modalSession.close();
|
||||
resolve({
|
||||
title: panelTitle,
|
||||
maxSeriesToPlot,
|
||||
});
|
||||
resolve(update);
|
||||
}}
|
||||
onCancel={() => {
|
||||
modalSession.close();
|
||||
reject();
|
||||
}}
|
||||
/>,
|
||||
theme$
|
||||
)
|
||||
/>
|
||||
</AiopsAppContext.Provider>,
|
||||
{ theme: coreStart.theme, i18n: coreStart.i18n }
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
21
x-pack/plugins/aiops/public/embeddable/types.ts
Normal file
21
x-pack/plugins/aiops/public/embeddable/types.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 { IEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
EmbeddableChangePointChartInput,
|
||||
EmbeddableChangePointChartOutput,
|
||||
} from './embeddable_change_point_chart';
|
||||
import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component';
|
||||
|
||||
export type EmbeddableChangePointChartExplicitInput = {
|
||||
title: string;
|
||||
} & Omit<EmbeddableChangePointChartProps, 'timeRange'>;
|
||||
|
||||
export interface EditChangePointChartsPanelContext {
|
||||
embeddable: IEmbeddable<EmbeddableChangePointChartInput, EmbeddableChangePointChartOutput>;
|
||||
}
|
|
@ -28,16 +28,24 @@ export interface DataViewAndSavedSearch {
|
|||
dataView: DataView;
|
||||
}
|
||||
|
||||
export interface DataSourceContextProviderProps {
|
||||
dataViewId?: string;
|
||||
savedSearchId?: string;
|
||||
/** Output resolves data view objects */
|
||||
onChange?: (update: { dataViews: DataView[] }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provider that resolves current data view and the saved search
|
||||
*
|
||||
* @param children
|
||||
* @constructor
|
||||
*/
|
||||
export const DataSourceContextProvider: FC<{ dataViewId?: string; savedSearchId?: string }> = ({
|
||||
export const DataSourceContextProvider: FC<DataSourceContextProviderProps> = ({
|
||||
dataViewId,
|
||||
savedSearchId,
|
||||
children,
|
||||
onChange,
|
||||
}) => {
|
||||
const [value, setValue] = useState<DataViewAndSavedSearch>();
|
||||
const [error, setError] = useState<Error>();
|
||||
|
@ -59,7 +67,7 @@ export const DataSourceContextProvider: FC<{ dataViewId?: string; savedSearchId?
|
|||
};
|
||||
|
||||
// support only data views for now
|
||||
if (dataViewId !== undefined) {
|
||||
if (dataViewId) {
|
||||
dataViewAndSavedSearch.dataView = await dataViews.get(dataViewId);
|
||||
}
|
||||
|
||||
|
@ -74,14 +82,18 @@ export const DataSourceContextProvider: FC<{ dataViewId?: string; savedSearchId?
|
|||
useEffect(() => {
|
||||
resolveDataSource()
|
||||
.then((result) => {
|
||||
setError(undefined);
|
||||
setValue(result);
|
||||
if (onChange) {
|
||||
onChange({ dataViews: [result.dataView] });
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e);
|
||||
});
|
||||
}, [resolveDataSource]);
|
||||
}, [resolveDataSource, onChange, dataViewId]);
|
||||
|
||||
if (!value && !error) return null;
|
||||
if ((!value || !value?.dataView) && !error) return null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
|
@ -16,12 +16,14 @@ import type {
|
|||
} from './types';
|
||||
import { getEmbeddableChangePointChart } from './embeddable/embeddable_change_point_chart_component';
|
||||
|
||||
export type AiopsCoreSetup = CoreSetup<AiopsPluginStartDeps, AiopsPluginStart>;
|
||||
|
||||
export class AiopsPlugin
|
||||
implements Plugin<AiopsPluginSetup, AiopsPluginStart, AiopsPluginSetupDeps, AiopsPluginStartDeps>
|
||||
{
|
||||
public setup(
|
||||
core: CoreSetup<AiopsPluginStartDeps, AiopsPluginStart>,
|
||||
{ embeddable, cases, licensing }: AiopsPluginSetupDeps
|
||||
core: AiopsCoreSetup,
|
||||
{ embeddable, cases, licensing, uiActions }: AiopsPluginSetupDeps
|
||||
) {
|
||||
firstValueFrom(licensing.license$).then(async (license) => {
|
||||
if (license.hasAtLeast('platinum')) {
|
||||
|
@ -30,6 +32,11 @@ export class AiopsPlugin
|
|||
registerEmbeddable(core, embeddable);
|
||||
}
|
||||
|
||||
if (uiActions) {
|
||||
const { registerAiopsUiActions } = await import('./ui_actions');
|
||||
registerAiopsUiActions(uiActions, core);
|
||||
}
|
||||
|
||||
if (cases) {
|
||||
const [coreStart, pluginStart] = await core.getStartServices();
|
||||
const { registerChangePointChartsAttachment } = await import(
|
||||
|
|
|
@ -13,7 +13,7 @@ import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
|
|||
import type { LensPublicStart } from '@kbn/lens-plugin/public';
|
||||
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
|
||||
import type { SharePluginStart } from '@kbn/share-plugin/public';
|
||||
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import type { UiActionsStart, UiActionsSetup } from '@kbn/ui-actions-plugin/public';
|
||||
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
|
||||
import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
|
||||
import type { CasesUiSetup } from '@kbn/cases-plugin/public';
|
||||
|
@ -24,6 +24,8 @@ export interface AiopsPluginSetupDeps {
|
|||
embeddable: EmbeddableSetup;
|
||||
cases: CasesUiSetup;
|
||||
licensing: LicensingPluginSetup;
|
||||
|
||||
uiActions: UiActionsSetup;
|
||||
}
|
||||
|
||||
export interface AiopsPluginStartDeps {
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 type { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/common';
|
||||
import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../common/constants';
|
||||
import type { EditChangePointChartsPanelContext } from '../embeddable/types';
|
||||
import type { AiopsCoreSetup } from '../plugin';
|
||||
|
||||
export const EDIT_CHANGE_POINT_CHARTS_ACTION = 'editChangePointChartsPanelAction';
|
||||
|
||||
export function createEditChangePointChartsPanelAction(
|
||||
getStartServices: AiopsCoreSetup['getStartServices']
|
||||
): UiActionsActionDefinition<EditChangePointChartsPanelContext> {
|
||||
return {
|
||||
id: 'edit-change-point-charts',
|
||||
type: EDIT_CHANGE_POINT_CHARTS_ACTION,
|
||||
getIconType(context): string {
|
||||
return 'pencil';
|
||||
},
|
||||
getDisplayName: () =>
|
||||
i18n.translate('xpack.aiops.actions.editChangePointChartsName', {
|
||||
defaultMessage: 'Edit change point charts',
|
||||
}),
|
||||
async execute({ embeddable }) {
|
||||
if (!embeddable) {
|
||||
throw new Error('Not possible to execute an action without the embeddable context');
|
||||
}
|
||||
|
||||
const [coreStart, pluginStart] = await getStartServices();
|
||||
|
||||
try {
|
||||
const { resolveEmbeddableChangePointUserInput } = await import(
|
||||
'../embeddable/handle_explicit_input'
|
||||
);
|
||||
|
||||
const result = await resolveEmbeddableChangePointUserInput(
|
||||
coreStart,
|
||||
pluginStart,
|
||||
embeddable.getInput()
|
||||
);
|
||||
embeddable.updateInput(result);
|
||||
} catch (e) {
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
async isCompatible({ embeddable }) {
|
||||
return (
|
||||
embeddable.type === EMBEDDABLE_CHANGE_POINT_CHART_TYPE &&
|
||||
embeddable.getInput().viewMode === ViewMode.EDIT
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
22
x-pack/plugins/aiops/public/ui_actions/index.ts
Normal file
22
x-pack/plugins/aiops/public/ui_actions/index.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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 type { UiActionsSetup } from '@kbn/ui-actions-plugin/public';
|
||||
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
|
||||
import { createEditChangePointChartsPanelAction } from './edit_change_point_charts_panel';
|
||||
import type { AiopsCoreSetup } from '../plugin';
|
||||
|
||||
export function registerAiopsUiActions(uiActions: UiActionsSetup, core: AiopsCoreSetup) {
|
||||
// Initialize actions
|
||||
const editChangePointChartPanelAction = createEditChangePointChartsPanelAction(
|
||||
core.getStartServices
|
||||
);
|
||||
// Register actions
|
||||
uiActions.registerAction(editChangePointChartPanelAction);
|
||||
// Assign and register triggers
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, editChangePointChartPanelAction.id);
|
||||
}
|
|
@ -62,6 +62,7 @@
|
|||
"@kbn/core-theme-browser",
|
||||
"@kbn/core-lifecycle-browser",
|
||||
"@kbn/cases-plugin",
|
||||
"@kbn/react-kibana-mount",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue