mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[ML] Log pattern analysis in Discover (#153449)
Adding ML's log pattern analysis results into a Discover by triggering a flyout from the stats menu. Only text fields contain the run pattern analysis button. A filter can then be applied from one or more patterns which will cause Discover to only show documents which match that category.  --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
03889a4ce5
commit
55ae242053
51 changed files with 1918 additions and 314 deletions
|
@ -19,7 +19,7 @@ import {
|
|||
FieldPopover,
|
||||
FieldPopoverHeader,
|
||||
FieldPopoverHeaderProps,
|
||||
FieldPopoverVisualize,
|
||||
FieldPopoverFooter,
|
||||
} from '@kbn/unified-field-list-plugin/public';
|
||||
import { DragDrop } from '@kbn/dom-drag-drop';
|
||||
import { DiscoverFieldStats } from './discover_field_stats';
|
||||
|
@ -307,7 +307,7 @@ function DiscoverFieldComponent({
|
|||
</>
|
||||
)}
|
||||
|
||||
<FieldPopoverVisualize
|
||||
<FieldPopoverFooter
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
multiFields={rawMultiFields}
|
||||
|
@ -315,6 +315,7 @@ function DiscoverFieldComponent({
|
|||
contextualFields={contextualFields}
|
||||
originatingApp={PLUGIN_ID}
|
||||
uiActions={getUiActions()}
|
||||
closePopover={() => closePopover()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -31,11 +31,14 @@ export {
|
|||
visualizeGeoFieldTrigger,
|
||||
ROW_CLICK_TRIGGER,
|
||||
rowClickTrigger,
|
||||
CATEGORIZE_FIELD_TRIGGER,
|
||||
categorizeFieldTrigger,
|
||||
} from './triggers';
|
||||
export type { VisualizeFieldContext } from './types';
|
||||
export type { VisualizeFieldContext, CategorizeFieldContext } from './types';
|
||||
export {
|
||||
ACTION_VISUALIZE_FIELD,
|
||||
ACTION_VISUALIZE_GEO_FIELD,
|
||||
ACTION_VISUALIZE_LENS_FIELD,
|
||||
ACTION_CATEGORIZE_FIELD,
|
||||
} from './types';
|
||||
export type { ActionExecutionContext, ActionExecutionMeta, ActionMenuItemProps } from './actions';
|
||||
|
|
|
@ -9,7 +9,12 @@
|
|||
import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public';
|
||||
import { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import { UiActionsService } from './service';
|
||||
import { rowClickTrigger, visualizeFieldTrigger, visualizeGeoFieldTrigger } from './triggers';
|
||||
import {
|
||||
categorizeFieldTrigger,
|
||||
rowClickTrigger,
|
||||
visualizeFieldTrigger,
|
||||
visualizeGeoFieldTrigger,
|
||||
} from './triggers';
|
||||
import { setTheme } from './services';
|
||||
|
||||
export type UiActionsSetup = Pick<
|
||||
|
@ -34,6 +39,7 @@ export class UiActionsPlugin implements Plugin<UiActionsSetup, UiActionsStart> {
|
|||
this.service.registerTrigger(rowClickTrigger);
|
||||
this.service.registerTrigger(visualizeFieldTrigger);
|
||||
this.service.registerTrigger(visualizeGeoFieldTrigger);
|
||||
this.service.registerTrigger(categorizeFieldTrigger);
|
||||
return this.service;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Trigger } from '.';
|
||||
|
||||
export const CATEGORIZE_FIELD_TRIGGER = 'CATEGORIZE_FIELD_TRIGGER';
|
||||
export const categorizeFieldTrigger: Trigger = {
|
||||
id: CATEGORIZE_FIELD_TRIGGER,
|
||||
title: 'Run pattern analysis',
|
||||
description: 'Triggered when user wants to run pattern analysis on a field.',
|
||||
};
|
|
@ -13,3 +13,4 @@ export * from './row_click_trigger';
|
|||
export * from './visualize_field_trigger';
|
||||
export * from './visualize_geo_field_trigger';
|
||||
export * from './default_trigger';
|
||||
export * from './categorize_field_trigger';
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import type { AggregateQuery } from '@kbn/es-query';
|
||||
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
|
||||
import type { DataViewField, DataViewSpec, DataView } from '@kbn/data-views-plugin/public';
|
||||
import { ActionInternal } from './actions/action_internal';
|
||||
import { TriggerInternal } from './triggers/trigger_internal';
|
||||
|
||||
|
@ -23,6 +23,13 @@ export interface VisualizeFieldContext {
|
|||
query?: AggregateQuery;
|
||||
}
|
||||
|
||||
export interface CategorizeFieldContext {
|
||||
field: DataViewField;
|
||||
dataView: DataView;
|
||||
originatingApp?: string;
|
||||
}
|
||||
|
||||
export const ACTION_VISUALIZE_FIELD = 'ACTION_VISUALIZE_FIELD';
|
||||
export const ACTION_VISUALIZE_GEO_FIELD = 'ACTION_VISUALIZE_GEO_FIELD';
|
||||
export const ACTION_VISUALIZE_LENS_FIELD = 'ACTION_VISUALIZE_LENS_FIELD';
|
||||
export const ACTION_CATEGORIZE_FIELD = 'ACTION_CATEGORIZE_FIELD';
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { Action, UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { canCategorize } from './categorize_trigger_utils';
|
||||
|
||||
const textField = {
|
||||
name: 'fieldName',
|
||||
type: 'string',
|
||||
esTypes: ['text'],
|
||||
count: 1,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
visualizable: true,
|
||||
} as DataViewField;
|
||||
|
||||
const numberField = {
|
||||
name: 'fieldName',
|
||||
type: 'number',
|
||||
esTypes: ['double'],
|
||||
count: 1,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
visualizable: true,
|
||||
} as DataViewField;
|
||||
|
||||
const mockGetActions = jest.fn<Promise<Array<Action<object>>>, [string, { fieldName: string }]>(
|
||||
() => Promise.resolve([])
|
||||
);
|
||||
|
||||
const uiActions = {
|
||||
getTriggerCompatibleActions: mockGetActions,
|
||||
} as unknown as UiActionsStart;
|
||||
|
||||
const action: Action = {
|
||||
id: 'action',
|
||||
type: 'CATEGORIZE_FIELD',
|
||||
getIconType: () => undefined,
|
||||
getDisplayName: () => 'Action',
|
||||
isCompatible: () => Promise.resolve(true),
|
||||
execute: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
const dataViewMock = { id: '1', toSpec: () => ({}) } as DataView;
|
||||
|
||||
describe('categorize_trigger_utils', () => {
|
||||
afterEach(() => {
|
||||
mockGetActions.mockReset();
|
||||
});
|
||||
|
||||
describe('getCategorizeInformation', () => {
|
||||
it('should return true for a categorizable field with an action', async () => {
|
||||
mockGetActions.mockResolvedValue([action]);
|
||||
const resp = await canCategorize(uiActions, textField, dataViewMock);
|
||||
expect(resp).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a non-categorizable field with an action', async () => {
|
||||
mockGetActions.mockResolvedValue([action]);
|
||||
const resp = await canCategorize(uiActions, numberField, dataViewMock);
|
||||
expect(resp).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
type UiActionsStart,
|
||||
CATEGORIZE_FIELD_TRIGGER,
|
||||
CategorizeFieldContext,
|
||||
} from '@kbn/ui-actions-plugin/public';
|
||||
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
|
||||
|
||||
async function getCompatibleActions(
|
||||
uiActions: UiActionsStart,
|
||||
field: DataViewField,
|
||||
dataView: DataView,
|
||||
trigger: typeof CATEGORIZE_FIELD_TRIGGER
|
||||
) {
|
||||
const compatibleActions = await uiActions.getTriggerCompatibleActions(trigger, {
|
||||
dataView,
|
||||
field,
|
||||
});
|
||||
return compatibleActions;
|
||||
}
|
||||
|
||||
export function triggerCategorizeActions(
|
||||
uiActions: UiActionsStart,
|
||||
field: DataViewField,
|
||||
originatingApp: string,
|
||||
dataView?: DataView
|
||||
) {
|
||||
if (!dataView) return;
|
||||
const triggerOptions: CategorizeFieldContext = {
|
||||
dataView,
|
||||
field,
|
||||
originatingApp,
|
||||
};
|
||||
uiActions.getTrigger(CATEGORIZE_FIELD_TRIGGER).exec(triggerOptions);
|
||||
}
|
||||
|
||||
export async function canCategorize(
|
||||
uiActions: UiActionsStart,
|
||||
field: DataViewField,
|
||||
dataView: DataView | undefined
|
||||
): Promise<boolean> {
|
||||
if (field.name === '_id' || !dataView?.id || !field.esTypes?.includes('text')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actions = await getCompatibleActions(uiActions, field, dataView, CATEGORIZE_FIELD_TRIGGER);
|
||||
|
||||
return actions.length > 0;
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { ActionInternal } from '@kbn/ui-actions-plugin/public';
|
||||
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
|
||||
import { getFieldCategorizeButton } from './field_categorize_button';
|
||||
import { ACTION_CATEGORIZE_FIELD, CategorizeFieldContext } from '@kbn/ui-actions-plugin/public';
|
||||
import { CATEGORIZE_FIELD_TRIGGER, TriggerContract } from '@kbn/ui-actions-plugin/public/triggers';
|
||||
|
||||
const ORIGINATING_APP = 'test';
|
||||
const mockExecuteAction = jest.fn();
|
||||
const uiActions = uiActionsPluginMock.createStartContract();
|
||||
const categorizeAction = new ActionInternal({
|
||||
type: ACTION_CATEGORIZE_FIELD,
|
||||
id: ACTION_CATEGORIZE_FIELD,
|
||||
getDisplayName: () => 'test',
|
||||
isCompatible: async () => true,
|
||||
execute: async (context: CategorizeFieldContext) => {
|
||||
mockExecuteAction(context);
|
||||
},
|
||||
getHref: async () => '/app/test',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(uiActions, 'getTriggerCompatibleActions')
|
||||
.mockResolvedValue([categorizeAction as ActionInternal<object>]);
|
||||
jest.spyOn(uiActions, 'getTrigger').mockReturnValue({
|
||||
id: ACTION_CATEGORIZE_FIELD,
|
||||
exec: mockExecuteAction,
|
||||
} as unknown as TriggerContract<object>);
|
||||
|
||||
describe('UnifiedFieldList <FieldCategorizeButton />', () => {
|
||||
it('should render correctly', async () => {
|
||||
const fieldName = 'extension';
|
||||
const field = dataView.fields.find((f) => f.name === fieldName)!;
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
const button = await getFieldCategorizeButton({
|
||||
field,
|
||||
dataView,
|
||||
originatingApp: ORIGINATING_APP,
|
||||
uiActions,
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = await mountWithIntl(button!);
|
||||
});
|
||||
|
||||
await wrapper!.update();
|
||||
|
||||
expect(uiActions.getTriggerCompatibleActions).toHaveBeenCalledWith(CATEGORIZE_FIELD_TRIGGER, {
|
||||
dataView,
|
||||
field,
|
||||
});
|
||||
|
||||
expect(wrapper!.text()).toBe('Run pattern analysis');
|
||||
wrapper!.find(`button[data-test-subj="fieldCategorize-${fieldName}"]`).simulate('click');
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith({
|
||||
dataView,
|
||||
field,
|
||||
originatingApp: ORIGINATING_APP,
|
||||
});
|
||||
|
||||
expect(wrapper!.find(EuiButton).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render for non text field', async () => {
|
||||
const fieldName = 'phpmemory';
|
||||
const field = dataView.fields.find((f) => f.name === fieldName)!;
|
||||
|
||||
const button = await getFieldCategorizeButton({
|
||||
field,
|
||||
dataView,
|
||||
originatingApp: ORIGINATING_APP,
|
||||
uiActions,
|
||||
});
|
||||
|
||||
expect(button).toBe(null);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiButtonProps } from '@elastic/eui';
|
||||
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { FieldCategorizeButtonInner } from './field_categorize_button_inner';
|
||||
import { triggerCategorizeActions, canCategorize } from './categorize_trigger_utils';
|
||||
|
||||
export interface FieldCategorizeButtonProps {
|
||||
field: DataViewField;
|
||||
dataView: DataView;
|
||||
originatingApp: string; // plugin id
|
||||
uiActions: UiActionsStart;
|
||||
contextualFields?: string[]; // names of fields which were also selected (like columns in Discover grid)
|
||||
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
|
||||
buttonProps?: Partial<EuiButtonProps>;
|
||||
closePopover?: () => void;
|
||||
}
|
||||
|
||||
export const FieldCategorizeButton: React.FC<FieldCategorizeButtonProps> = React.memo(
|
||||
({ field, dataView, trackUiMetric, originatingApp, uiActions, buttonProps, closePopover }) => {
|
||||
const handleVisualizeLinkClick = async (
|
||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||
) => {
|
||||
// regular link click. let the uiActions code handle the navigation and show popup if needed
|
||||
event.preventDefault();
|
||||
const triggerVisualization = (updatedDataView: DataView) => {
|
||||
trackUiMetric?.(METRIC_TYPE.CLICK, 'categorize_link_click');
|
||||
triggerCategorizeActions(uiActions, field, originatingApp, updatedDataView);
|
||||
};
|
||||
triggerVisualization(dataView);
|
||||
if (closePopover) {
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldCategorizeButtonInner
|
||||
fieldName={field.name}
|
||||
handleVisualizeLinkClick={handleVisualizeLinkClick}
|
||||
buttonProps={buttonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export async function getFieldCategorizeButton(props: FieldCategorizeButtonProps) {
|
||||
const showButton = await canCategorize(props.uiActions, props.field, props.dataView);
|
||||
return showButton ? <FieldCategorizeButton {...props} /> : null;
|
||||
}
|
|
@ -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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiButton, EuiButtonProps } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
interface FieldVisualizeButtonInnerProps {
|
||||
fieldName: string;
|
||||
handleVisualizeLinkClick: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
|
||||
buttonProps?: Partial<EuiButtonProps>;
|
||||
}
|
||||
|
||||
export const FieldCategorizeButtonInner: React.FC<FieldVisualizeButtonInnerProps> = ({
|
||||
fieldName,
|
||||
handleVisualizeLinkClick,
|
||||
buttonProps,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<EuiButton
|
||||
fullWidth
|
||||
size="s"
|
||||
data-test-subj={`fieldCategorize-${fieldName}`}
|
||||
{...(buttonProps || {})}
|
||||
onClick={handleVisualizeLinkClick}
|
||||
iconSide="left"
|
||||
iconType="machineLearningApp"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="unifiedFieldList.fieldCategorizeButton.label"
|
||||
defaultMessage="Run pattern analysis"
|
||||
/>
|
||||
</EuiButton>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export {
|
||||
type FieldCategorizeButtonProps,
|
||||
FieldCategorizeButton,
|
||||
getFieldCategorizeButton,
|
||||
} from './field_categorize_button';
|
||||
|
||||
export { triggerCategorizeActions, canCategorize } from './categorize_trigger_utils';
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { EuiPopoverFooter, EuiSpacer } from '@elastic/eui';
|
||||
import { type FieldVisualizeButtonProps, getFieldVisualizeButton } from '../field_visualize_button';
|
||||
import { FieldCategorizeButtonProps, getFieldCategorizeButton } from '../field_categorize_button';
|
||||
|
||||
export type FieldPopoverFooterProps = FieldVisualizeButtonProps | FieldCategorizeButtonProps;
|
||||
|
||||
export const FieldPopoverFooter: React.FC<FieldPopoverFooterProps> = (props) => {
|
||||
const [visualizeButton, setVisualizeButton] = useState<JSX.Element | null>(null);
|
||||
const [categorizeButton, setCategorizeButton] = useState<JSX.Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getFieldVisualizeButton(props).then(setVisualizeButton);
|
||||
getFieldCategorizeButton(props).then(setCategorizeButton);
|
||||
}, [props]);
|
||||
|
||||
return visualizeButton || categorizeButton ? (
|
||||
<EuiPopoverFooter>
|
||||
{visualizeButton}
|
||||
{visualizeButton && categorizeButton ? <EuiSpacer size="s" /> : null}
|
||||
{categorizeButton}
|
||||
</EuiPopoverFooter>
|
||||
) : null;
|
||||
};
|
|
@ -1,21 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiPopoverFooter } from '@elastic/eui';
|
||||
import { FieldVisualizeButton, type FieldVisualizeButtonProps } from '../field_visualize_button';
|
||||
|
||||
export type FieldPopoverVisualizeProps = Omit<FieldVisualizeButtonProps, 'wrapInContainer'>;
|
||||
|
||||
const wrapInContainer = (element: React.ReactElement): React.ReactElement => {
|
||||
return <EuiPopoverFooter>{element}</EuiPopoverFooter>;
|
||||
};
|
||||
|
||||
export const FieldPopoverVisualize: React.FC<FieldPopoverVisualizeProps> = (props) => {
|
||||
return <FieldVisualizeButton {...props} wrapInContainer={wrapInContainer} />;
|
||||
};
|
|
@ -8,4 +8,4 @@
|
|||
|
||||
export { type FieldPopoverProps, FieldPopover } from './field_popover';
|
||||
export { type FieldPopoverHeaderProps, FieldPopoverHeader } from './field_popover_header';
|
||||
export { type FieldPopoverVisualizeProps, FieldPopoverVisualize } from './field_popover_visualize';
|
||||
export { type FieldPopoverFooterProps, FieldPopoverFooter } from './field_popover_footer';
|
||||
|
|
|
@ -6,15 +6,14 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { EuiButton, EuiPopoverFooter } from '@elastic/eui';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { ActionInternal } from '@kbn/ui-actions-plugin/public';
|
||||
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
|
||||
import { FieldVisualizeButton } from './field_visualize_button';
|
||||
import { getFieldVisualizeButton } from './field_visualize_button';
|
||||
import {
|
||||
ACTION_VISUALIZE_LENS_FIELD,
|
||||
VISUALIZE_FIELD_TRIGGER,
|
||||
|
@ -56,18 +55,16 @@ describe('UnifiedFieldList <FieldVisualizeButton />', () => {
|
|||
jest.spyOn(fieldKeyword, 'visualizable', 'get').mockImplementationOnce(() => true);
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
const button = await getFieldVisualizeButton({
|
||||
field,
|
||||
dataView,
|
||||
multiFields: [fieldKeyword],
|
||||
contextualFields,
|
||||
originatingApp: ORIGINATING_APP,
|
||||
uiActions,
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = await mountWithIntl(
|
||||
<FieldVisualizeButton
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
multiFields={[fieldKeyword]}
|
||||
contextualFields={contextualFields}
|
||||
originatingApp={ORIGINATING_APP}
|
||||
uiActions={uiActions}
|
||||
wrapInContainer={(element) => <EuiPopoverFooter>{element}</EuiPopoverFooter>}
|
||||
/>
|
||||
);
|
||||
wrapper = await mountWithIntl(button!);
|
||||
});
|
||||
|
||||
await wrapper!.update();
|
||||
|
@ -89,7 +86,6 @@ describe('UnifiedFieldList <FieldVisualizeButton />', () => {
|
|||
});
|
||||
|
||||
expect(wrapper!.find(EuiButton).prop('href')).toBe('/app/test');
|
||||
expect(wrapper!.find(EuiPopoverFooter).find(EuiButton).exists()).toBeTruthy(); // wrapped in a container
|
||||
});
|
||||
|
||||
it('should render correctly for geo fields', async () => {
|
||||
|
@ -98,15 +94,14 @@ describe('UnifiedFieldList <FieldVisualizeButton />', () => {
|
|||
jest.spyOn(field, 'visualizable', 'get').mockImplementationOnce(() => true);
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
const button = await getFieldVisualizeButton({
|
||||
field,
|
||||
dataView,
|
||||
originatingApp: ORIGINATING_APP,
|
||||
uiActions,
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = await mountWithIntl(
|
||||
<FieldVisualizeButton
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
originatingApp={ORIGINATING_APP}
|
||||
uiActions={uiActions}
|
||||
/>
|
||||
);
|
||||
wrapper = await mountWithIntl(button!);
|
||||
});
|
||||
|
||||
await wrapper!.update();
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { EuiButtonProps } from '@elastic/eui';
|
||||
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
|
@ -27,7 +27,7 @@ export interface FieldVisualizeButtonProps {
|
|||
contextualFields?: string[]; // names of fields which were also selected (like columns in Discover grid)
|
||||
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
|
||||
buttonProps?: Partial<EuiButtonProps>;
|
||||
wrapInContainer?: (element: React.ReactElement) => React.ReactElement;
|
||||
visualizeInfo?: VisualizeInformation;
|
||||
}
|
||||
|
||||
export const FieldVisualizeButton: React.FC<FieldVisualizeButtonProps> = React.memo(
|
||||
|
@ -40,20 +40,11 @@ export const FieldVisualizeButton: React.FC<FieldVisualizeButtonProps> = React.m
|
|||
originatingApp,
|
||||
uiActions,
|
||||
buttonProps,
|
||||
wrapInContainer,
|
||||
visualizeInfo,
|
||||
}) => {
|
||||
const [visualizeInfo, setVisualizeInfo] = useState<VisualizeInformation>();
|
||||
|
||||
useEffect(() => {
|
||||
getVisualizeInformation(uiActions, field, dataView, contextualFields, multiFields).then(
|
||||
setVisualizeInfo
|
||||
);
|
||||
}, [contextualFields, field, dataView, multiFields, uiActions]);
|
||||
|
||||
if (!visualizeInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleVisualizeLinkClick = async (
|
||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||
) => {
|
||||
|
@ -73,7 +64,7 @@ export const FieldVisualizeButton: React.FC<FieldVisualizeButtonProps> = React.m
|
|||
triggerVisualization(dataView);
|
||||
};
|
||||
|
||||
const element = (
|
||||
return (
|
||||
<FieldVisualizeButtonInner
|
||||
field={field}
|
||||
visualizeInfo={visualizeInfo}
|
||||
|
@ -81,7 +72,16 @@ export const FieldVisualizeButton: React.FC<FieldVisualizeButtonProps> = React.m
|
|||
buttonProps={buttonProps}
|
||||
/>
|
||||
);
|
||||
|
||||
return wrapInContainer?.(element) || element;
|
||||
}
|
||||
);
|
||||
|
||||
export async function getFieldVisualizeButton(props: FieldVisualizeButtonProps) {
|
||||
const visualizeInfo = await getVisualizeInformation(
|
||||
props.uiActions,
|
||||
props.field,
|
||||
props.dataView,
|
||||
props.contextualFields,
|
||||
props.multiFields
|
||||
);
|
||||
return visualizeInfo ? <FieldVisualizeButton {...props} visualizeInfo={visualizeInfo} /> : null;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { type FieldVisualizeButtonProps, FieldVisualizeButton } from './field_visualize_button';
|
||||
export {
|
||||
type FieldVisualizeButtonProps,
|
||||
FieldVisualizeButton,
|
||||
getFieldVisualizeButton,
|
||||
} from './field_visualize_button';
|
||||
|
||||
export {
|
||||
triggerVisualizeActions,
|
||||
|
|
|
@ -34,8 +34,8 @@ export {
|
|||
type FieldPopoverProps,
|
||||
FieldPopoverHeader,
|
||||
type FieldPopoverHeaderProps,
|
||||
FieldPopoverVisualize,
|
||||
type FieldPopoverVisualizeProps,
|
||||
FieldPopoverFooter,
|
||||
type FieldPopoverFooterProps,
|
||||
} from './components/field_popover';
|
||||
export {
|
||||
FieldVisualizeButton,
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"data",
|
||||
"lens",
|
||||
"licensing",
|
||||
"uiActions",
|
||||
"unifiedFieldList",
|
||||
],
|
||||
"requiredBundles": [
|
||||
|
|
|
@ -116,9 +116,9 @@ export const ExplainLogRateSpikesPage: FC = () => {
|
|||
searchQuery,
|
||||
} = useData(
|
||||
{ selectedDataView: dataView, selectedSavedSearch },
|
||||
'explain_log_rage_spikes',
|
||||
aiopsListState,
|
||||
setGlobalState,
|
||||
'explain_log_rage_spikes',
|
||||
currentSelectedSignificantTerm,
|
||||
currentSelectedGroup
|
||||
);
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
createAction,
|
||||
ACTION_CATEGORIZE_FIELD,
|
||||
type CategorizeFieldContext,
|
||||
} from '@kbn/ui-actions-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import { AiopsPluginStartDeps } from '../../types';
|
||||
import { showCategorizeFlyout } from './show_flyout';
|
||||
|
||||
export const categorizeFieldAction = (coreStart: CoreStart, plugins: AiopsPluginStartDeps) =>
|
||||
createAction<CategorizeFieldContext>({
|
||||
type: ACTION_CATEGORIZE_FIELD,
|
||||
id: ACTION_CATEGORIZE_FIELD,
|
||||
getDisplayName: () =>
|
||||
i18n.translate('xpack.aiops.categorizeFieldAction.displayName', {
|
||||
defaultMessage: 'Categorize field',
|
||||
}),
|
||||
isCompatible: async ({ field }: CategorizeFieldContext) => {
|
||||
return field.esTypes?.includes('text') === true;
|
||||
},
|
||||
execute: async (context: CategorizeFieldContext) => {
|
||||
const { field, dataView } = context;
|
||||
showCategorizeFlyout(field, dataView, coreStart, plugins);
|
||||
},
|
||||
});
|
|
@ -5,48 +5,46 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import React, { FC, useMemo, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { TimefilterContract } from '@kbn/data-plugin/public';
|
||||
import {
|
||||
useEuiBackgroundColor,
|
||||
EuiButton,
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiInMemoryTable,
|
||||
EuiBasicTableColumn,
|
||||
EuiCode,
|
||||
EuiText,
|
||||
EuiTableSelectionType,
|
||||
EuiHorizontalRule,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { useDiscoverLinks } from '../use_discover_links';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { useDiscoverLinks, createFilter, QueryMode, QUERY_MODE } from '../use_discover_links';
|
||||
import { MiniHistogram } from '../../mini_histogram';
|
||||
import { useEuiTheme } from '../../../hooks/use_eui_theme';
|
||||
import type { AiOpsFullIndexBasedAppState } from '../../../application/utils/url_state';
|
||||
import type { EventRate, Category, SparkLinesPerCategory } from '../use_categorize_request';
|
||||
import { useTableState } from './use_table_state';
|
||||
|
||||
const QUERY_MODE = {
|
||||
INCLUDE: 'should',
|
||||
EXCLUDE: 'must_not',
|
||||
} as const;
|
||||
export type QueryMode = typeof QUERY_MODE[keyof typeof QUERY_MODE];
|
||||
import { getLabels } from './labels';
|
||||
import { TableHeader } from './table_header';
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
sparkLines: SparkLinesPerCategory;
|
||||
eventRate: EventRate;
|
||||
dataViewId: string;
|
||||
selectedField: string | undefined;
|
||||
selectedField: DataViewField | string | undefined;
|
||||
timefilter: TimefilterContract;
|
||||
aiopsListState: AiOpsFullIndexBasedAppState;
|
||||
pinnedCategory: Category | null;
|
||||
setPinnedCategory: (category: Category | null) => void;
|
||||
selectedCategory: Category | null;
|
||||
setSelectedCategory: (category: Category | null) => void;
|
||||
onAddFilter?: (values: Filter, alias?: string) => void;
|
||||
onClose?: () => void;
|
||||
enableRowActions?: boolean;
|
||||
}
|
||||
|
||||
export const CategoryTable: FC<Props> = ({
|
||||
|
@ -61,6 +59,9 @@ export const CategoryTable: FC<Props> = ({
|
|||
setPinnedCategory,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
onAddFilter,
|
||||
onClose = () => {},
|
||||
enableRowActions = true,
|
||||
}) => {
|
||||
const euiTheme = useEuiTheme();
|
||||
const primaryBackgroundColor = useEuiBackgroundColor('primary');
|
||||
|
@ -68,7 +69,25 @@ export const CategoryTable: FC<Props> = ({
|
|||
const [selectedCategories, setSelectedCategories] = useState<Category[]>([]);
|
||||
const { onTableChange, pagination, sorting } = useTableState<Category>(categories ?? [], 'key');
|
||||
|
||||
const labels = useMemo(
|
||||
() => getLabels(onAddFilter !== undefined && onClose !== undefined),
|
||||
[onAddFilter, onClose]
|
||||
);
|
||||
|
||||
const openInDiscover = (mode: QueryMode, category?: Category) => {
|
||||
if (
|
||||
onAddFilter !== undefined &&
|
||||
selectedField !== undefined &&
|
||||
typeof selectedField !== 'string'
|
||||
) {
|
||||
onAddFilter(
|
||||
createFilter('', selectedField.name, selectedCategories, mode, category),
|
||||
`Patterns - ${selectedField.name}`
|
||||
);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const timefilterActiveBounds = timefilter.getActiveBounds();
|
||||
if (timefilterActiveBounds === undefined || selectedField === undefined) {
|
||||
return;
|
||||
|
@ -76,7 +95,7 @@ export const CategoryTable: FC<Props> = ({
|
|||
|
||||
openInDiscoverWithFilter(
|
||||
dataViewId,
|
||||
selectedField,
|
||||
typeof selectedField === 'string' ? selectedField : selectedField.name,
|
||||
selectedCategories,
|
||||
aiopsListState,
|
||||
timefilterActiveBounds,
|
||||
|
@ -99,7 +118,7 @@ export const CategoryTable: FC<Props> = ({
|
|||
name: i18n.translate('xpack.aiops.logCategorization.column.logRate', {
|
||||
defaultMessage: 'Log rate',
|
||||
}),
|
||||
sortable: true,
|
||||
sortable: false,
|
||||
width: '100px',
|
||||
render: (_, { key }) => {
|
||||
const sparkLine = sparkLines[key];
|
||||
|
@ -128,57 +147,39 @@ export const CategoryTable: FC<Props> = ({
|
|||
defaultMessage: 'Examples',
|
||||
}),
|
||||
sortable: true,
|
||||
style: { display: 'block' },
|
||||
render: (examples: string[]) => (
|
||||
<div style={{ display: 'block' }}>
|
||||
<>
|
||||
{examples.map((e) => (
|
||||
<>
|
||||
<EuiText size="s">
|
||||
<EuiCode language="log" transparentBackground>
|
||||
{e}
|
||||
</EuiCode>
|
||||
</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
<EuiText size="s" key={e}>
|
||||
<EuiCode language="log" transparentBackground css={{ paddingInline: '0px' }}>
|
||||
{e}
|
||||
</EuiCode>
|
||||
</EuiText>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Actions',
|
||||
name: i18n.translate('xpack.aiops.logCategorization.column.actions', {
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
sortable: false,
|
||||
width: '60px',
|
||||
actions: [
|
||||
{
|
||||
name: i18n.translate('xpack.aiops.logCategorization.showInDiscover', {
|
||||
defaultMessage: 'Show these in Discover',
|
||||
}),
|
||||
description: i18n.translate('xpack.aiops.logCategorization.showInDiscover', {
|
||||
defaultMessage: 'Show these in Discover',
|
||||
}),
|
||||
icon: 'discoverApp',
|
||||
name: labels.singleSelect.in,
|
||||
description: labels.singleSelect.in,
|
||||
icon: 'plusInCircle',
|
||||
type: 'icon',
|
||||
onClick: (category) => openInDiscover(QUERY_MODE.INCLUDE, category),
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.aiops.logCategorization.filterOutInDiscover', {
|
||||
defaultMessage: 'Filter out in Discover',
|
||||
}),
|
||||
description: i18n.translate('xpack.aiops.logCategorization.filterOutInDiscover', {
|
||||
defaultMessage: 'Filter out in Discover',
|
||||
}),
|
||||
icon: 'filter',
|
||||
name: labels.singleSelect.out,
|
||||
description: labels.singleSelect.out,
|
||||
icon: 'minusInCircle',
|
||||
type: 'icon',
|
||||
onClick: (category) => openInDiscover(QUERY_MODE.EXCLUDE, category),
|
||||
},
|
||||
// Disabled for now
|
||||
// {
|
||||
// name: i18n.translate('xpack.aiops.logCategorization.openInDataViz', {
|
||||
// defaultMessage: 'Open in data visualizer',
|
||||
// }),
|
||||
// icon: 'stats',
|
||||
// type: 'icon',
|
||||
// onClick: () => {},
|
||||
// },
|
||||
],
|
||||
},
|
||||
] as Array<EuiBasicTableColumn<Category>>;
|
||||
|
@ -212,28 +213,15 @@ export const CategoryTable: FC<Props> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{selectedCategories.length > 0 ? (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton size="s" onClick={() => openInDiscover(QUERY_MODE.INCLUDE)}>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.showInDiscover"
|
||||
defaultMessage="Show these in Discover"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton size="s" onClick={() => openInDiscover(QUERY_MODE.EXCLUDE)}>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.filterOutInDiscover"
|
||||
defaultMessage="Filter out in Discover"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
) : null}
|
||||
<TableHeader
|
||||
categoriesCount={categories.length}
|
||||
selectedCategoriesCount={selectedCategories.length}
|
||||
labels={labels}
|
||||
openInDiscover={(queryMode: QueryMode) => openInDiscover(queryMode)}
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiHorizontalRule margin="none" />
|
||||
|
||||
<EuiInMemoryTable<Category>
|
||||
compressed
|
||||
items={categories}
|
||||
|
@ -245,22 +233,24 @@ export const CategoryTable: FC<Props> = ({
|
|||
pagination={pagination}
|
||||
sorting={sorting}
|
||||
rowProps={(category) => {
|
||||
return {
|
||||
onClick: () => {
|
||||
if (category.key === pinnedCategory?.key) {
|
||||
setPinnedCategory(null);
|
||||
} else {
|
||||
setPinnedCategory(category);
|
||||
return enableRowActions
|
||||
? {
|
||||
onClick: () => {
|
||||
if (category.key === pinnedCategory?.key) {
|
||||
setPinnedCategory(null);
|
||||
} else {
|
||||
setPinnedCategory(category);
|
||||
}
|
||||
},
|
||||
onMouseEnter: () => {
|
||||
setSelectedCategory(category);
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
setSelectedCategory(null);
|
||||
},
|
||||
style: getRowStyle(category),
|
||||
}
|
||||
},
|
||||
onMouseEnter: () => {
|
||||
setSelectedCategory(category);
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
setSelectedCategory(null);
|
||||
},
|
||||
style: getRowStyle(category),
|
||||
};
|
||||
: undefined;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -6,4 +6,3 @@
|
|||
*/
|
||||
|
||||
export { CategoryTable } from './category_table';
|
||||
export type { QueryMode } from './category_table';
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export function getLabels(isFlyout: boolean) {
|
||||
const flyoutFilterIn = (single: boolean) =>
|
||||
i18n.translate('xpack.aiops.logCategorization.flyout.filterIn', {
|
||||
defaultMessage: 'Filter for {values, plural, one {pattern} other {patterns}}',
|
||||
values: {
|
||||
values: single ? 1 : 2,
|
||||
},
|
||||
});
|
||||
const flyoutFilterOut = (single: boolean) =>
|
||||
i18n.translate('xpack.aiops.logCategorization.flyout.filterOut', {
|
||||
defaultMessage: 'Filter out {values, plural, one {pattern} other {patterns}}',
|
||||
values: {
|
||||
values: single ? 1 : 2,
|
||||
},
|
||||
});
|
||||
|
||||
const aiopsFilterIn = (single: boolean) =>
|
||||
i18n.translate('xpack.aiops.logCategorization.filterIn', {
|
||||
defaultMessage: 'Filter for {values, plural, one {pattern} other {patterns}} in Discover',
|
||||
values: {
|
||||
values: single ? 1 : 2,
|
||||
},
|
||||
});
|
||||
const aiopsFilterOut = (single: boolean) =>
|
||||
i18n.translate('xpack.aiops.logCategorization.filterOut', {
|
||||
defaultMessage: 'Filter out {values, plural, one {pattern} other {patterns}} in Discover',
|
||||
values: {
|
||||
values: single ? 1 : 2,
|
||||
},
|
||||
});
|
||||
|
||||
return isFlyout
|
||||
? {
|
||||
multiSelect: {
|
||||
in: flyoutFilterIn(false),
|
||||
out: flyoutFilterOut(false),
|
||||
},
|
||||
singleSelect: {
|
||||
in: flyoutFilterIn(true),
|
||||
out: flyoutFilterOut(true),
|
||||
},
|
||||
}
|
||||
: {
|
||||
multiSelect: {
|
||||
in: aiopsFilterIn(false),
|
||||
out: aiopsFilterOut(false),
|
||||
},
|
||||
singleSelect: {
|
||||
in: aiopsFilterIn(true),
|
||||
out: aiopsFilterOut(true),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useEuiTheme } from '../../../hooks/use_eui_theme';
|
||||
import { getLabels } from './labels';
|
||||
import { QueryMode, QUERY_MODE } from '../use_discover_links';
|
||||
|
||||
interface Props {
|
||||
categoriesCount: number;
|
||||
selectedCategoriesCount: number;
|
||||
labels: ReturnType<typeof getLabels>;
|
||||
openInDiscover: (mode: QueryMode) => void;
|
||||
}
|
||||
|
||||
export const TableHeader: FC<Props> = ({
|
||||
categoriesCount,
|
||||
selectedCategoriesCount,
|
||||
labels,
|
||||
openInDiscover,
|
||||
}) => {
|
||||
const euiTheme = useEuiTheme();
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup gutterSize="none" alignItems="center" css={{ minHeight: euiTheme.euiSizeXL }}>
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.counts"
|
||||
defaultMessage="{count} patterns found"
|
||||
values={{ count: categoriesCount }}
|
||||
/>
|
||||
{selectedCategoriesCount > 0 ? (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.selectedCounts"
|
||||
defaultMessage=" | {count} selected"
|
||||
values={{ count: selectedCategoriesCount }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
{selectedCategoriesCount > 0 ? (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
onClick={() => openInDiscover(QUERY_MODE.INCLUDE)}
|
||||
iconType="plusInCircle"
|
||||
iconSide="left"
|
||||
>
|
||||
{labels.multiSelect.in}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
onClick={() => openInDiscover(QUERY_MODE.EXCLUDE)}
|
||||
iconType="minusInCircle"
|
||||
iconSide="left"
|
||||
>
|
||||
{labels.multiSelect.out}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -34,7 +34,7 @@ export const DocumentCountChart: FC<Props> = ({
|
|||
const chartPointsSplitLabel = i18n.translate(
|
||||
'xpack.aiops.logCategorization.chartPointsSplitLabel',
|
||||
{
|
||||
defaultMessage: 'Selected category',
|
||||
defaultMessage: 'Selected pattern',
|
||||
}
|
||||
);
|
||||
const chartPoints = useMemo(() => {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
export type { LogCategorizationAppStateProps } from './log_categorization_app_state';
|
||||
import { LogCategorizationAppState } from './log_categorization_app_state';
|
||||
export { categorizeFieldAction } from './categorize_field_actions';
|
||||
|
||||
// required for dynamic import using React.lazy()
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
|
|
|
@ -66,7 +66,7 @@ export const InformationText: FC<Props> = ({
|
|||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.emptyPromptBody"
|
||||
defaultMessage="Log pattern analysis groups messages into common categories."
|
||||
defaultMessage="Log pattern analysis groups messages into common patterns."
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ export const InformationText: FC<Props> = ({
|
|||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.noCategoriesTitle"
|
||||
defaultMessage="No categories found"
|
||||
defaultMessage="No patterns found"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiButton,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLoadingElastic,
|
||||
EuiText,
|
||||
EuiFlexGrid,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const LoadingCategorization: FC<Props> = ({ onClose }) => (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup justifyContent="spaceAround">
|
||||
<EuiFlexItem grow={false} css={{ textAlign: 'center' }}>
|
||||
<EuiFlexGrid columns={1}>
|
||||
<EuiFlexItem>
|
||||
<EuiLoadingElastic size="xxl" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.categorizeFlyout.loading.title"
|
||||
defaultMessage="Loading pattern analysis"
|
||||
/>
|
||||
</h2>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup justifyContent="spaceAround">
|
||||
<EuiFlexItem grow={false} css={{ textAlign: 'center' }}>
|
||||
<EuiButton onClick={() => onClose()}>Cancel</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGrid>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
|
@ -0,0 +1,264 @@
|
|||
/*
|
||||
* 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, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { SavedSearch } from '@kbn/discover-plugin/public';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import {
|
||||
EuiTitle,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutBody,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { buildEmptyFilter, Filter } from '@kbn/es-query';
|
||||
|
||||
import { usePageUrlState } from '@kbn/ml-url-state';
|
||||
import { useData } from '../../hooks/use_data';
|
||||
import { useCategorizeRequest } from './use_categorize_request';
|
||||
import type { EventRate, Category, SparkLinesPerCategory } from './use_categorize_request';
|
||||
import { CategoryTable } from './category_table';
|
||||
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
|
||||
import { InformationText } from './information_text';
|
||||
import { createMergedEsQuery } from '../../application/utils/search_utils';
|
||||
import { SamplingMenu } from './sampling_menu';
|
||||
import { TechnicalPreviewBadge } from './technical_preview_badge';
|
||||
import { LoadingCategorization } from './loading_categorization';
|
||||
import {
|
||||
type AiOpsPageUrlState,
|
||||
getDefaultAiOpsListState,
|
||||
isFullAiOpsListState,
|
||||
} from '../../application/utils/url_state';
|
||||
|
||||
export interface LogCategorizationPageProps {
|
||||
dataView: DataView;
|
||||
savedSearch: SavedSearch | null;
|
||||
selectedField: DataViewField;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BAR_TARGET = 20;
|
||||
|
||||
export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
|
||||
dataView,
|
||||
savedSearch,
|
||||
selectedField,
|
||||
onClose,
|
||||
}) => {
|
||||
const {
|
||||
notifications: { toasts },
|
||||
data: {
|
||||
query: { getState, filterManager },
|
||||
},
|
||||
uiSettings,
|
||||
} = useAiopsAppContext();
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const mounted = useRef(false);
|
||||
const { runCategorizeRequest, cancelRequest, randomSampler } = useCategorizeRequest();
|
||||
const [aiopsListState, setAiopsListState] = usePageUrlState<AiOpsPageUrlState>(
|
||||
'AIOPS_INDEX_VIEWER',
|
||||
getDefaultAiOpsListState()
|
||||
);
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
||||
const [selectedSavedSearch /* , setSelectedSavedSearch*/] = useState(savedSearch);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [eventRate, setEventRate] = useState<EventRate>([]);
|
||||
const [pinnedCategory, setPinnedCategory] = useState<Category | null>(null);
|
||||
const [data, setData] = useState<{
|
||||
categories: Category[];
|
||||
sparkLines: SparkLinesPerCategory;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(
|
||||
function cancelRequestOnLeave() {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
cancelRequest();
|
||||
};
|
||||
},
|
||||
[cancelRequest, mounted]
|
||||
);
|
||||
|
||||
const {
|
||||
documentStats,
|
||||
timefilter,
|
||||
earliest,
|
||||
latest,
|
||||
searchQueryLanguage,
|
||||
searchString,
|
||||
searchQuery,
|
||||
intervalMs,
|
||||
forceRefresh,
|
||||
} = useData(
|
||||
{ selectedDataView: dataView, selectedSavedSearch },
|
||||
'log_categorization',
|
||||
aiopsListState,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BAR_TARGET,
|
||||
true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { filters, query } = getState();
|
||||
|
||||
setAiopsListState({
|
||||
...aiopsListState,
|
||||
searchQuery: createMergedEsQuery(query, filters, dataView, uiSettings),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
const { title: index, timeFieldName: timeField } = dataView;
|
||||
|
||||
if (selectedField === undefined || timeField === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRequest();
|
||||
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
|
||||
try {
|
||||
const { categories, sparkLinesPerCategory: sparkLines } = await runCategorizeRequest(
|
||||
index,
|
||||
selectedField.name,
|
||||
timeField,
|
||||
earliest,
|
||||
latest,
|
||||
searchQuery,
|
||||
intervalMs
|
||||
);
|
||||
|
||||
if (mounted.current === true) {
|
||||
setData({ categories, sparkLines });
|
||||
}
|
||||
} catch (error) {
|
||||
toasts.addError(error, {
|
||||
title: i18n.translate('xpack.aiops.logCategorization.errorLoadingCategories', {
|
||||
defaultMessage: 'Error loading categories',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted.current === true) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
dataView,
|
||||
selectedField,
|
||||
cancelRequest,
|
||||
runCategorizeRequest,
|
||||
earliest,
|
||||
latest,
|
||||
searchQuery,
|
||||
intervalMs,
|
||||
toasts,
|
||||
]);
|
||||
|
||||
const onAddFilter = useCallback(
|
||||
(values: Filter, alias?: string) => {
|
||||
const filter = buildEmptyFilter(false, dataView.id);
|
||||
if (alias) {
|
||||
filter.meta.alias = alias;
|
||||
}
|
||||
filter.query = values.query;
|
||||
filterManager.addFilters([filter]);
|
||||
},
|
||||
[dataView, filterManager]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentStats.documentCountStats?.buckets) {
|
||||
randomSampler.setDocCount(documentStats.totalCount);
|
||||
setEventRate(
|
||||
Object.entries(documentStats.documentCountStats.buckets).map(([key, docCount]) => ({
|
||||
key: +key,
|
||||
docCount,
|
||||
}))
|
||||
);
|
||||
setData(null);
|
||||
loadCategories();
|
||||
}
|
||||
}, [
|
||||
documentStats,
|
||||
earliest,
|
||||
latest,
|
||||
searchQueryLanguage,
|
||||
searchString,
|
||||
searchQuery,
|
||||
loadCategories,
|
||||
randomSampler,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="m">
|
||||
<h2 id="flyoutTitle">
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.categorizeFlyout.title"
|
||||
defaultMessage="Pattern analysis of {name}"
|
||||
values={{ name: selectedField.name }}
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} css={{ marginTop: euiTheme.size.xs }}>
|
||||
<TechnicalPreviewBadge />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem />
|
||||
<EuiFlexItem grow={false}>
|
||||
<SamplingMenu randomSampler={randomSampler} reload={() => forceRefresh()} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody data-test-subj={'mlJobSelectorFlyoutBody'}>
|
||||
{loading === true ? <LoadingCategorization onClose={onClose} /> : null}
|
||||
|
||||
<InformationText
|
||||
loading={loading}
|
||||
categoriesLength={data?.categories?.length ?? null}
|
||||
eventRateLength={eventRate.length}
|
||||
fieldSelected={selectedField !== null}
|
||||
/>
|
||||
|
||||
{loading === false &&
|
||||
data !== null &&
|
||||
data.categories.length > 0 &&
|
||||
isFullAiOpsListState(aiopsListState) ? (
|
||||
<CategoryTable
|
||||
categories={data.categories}
|
||||
aiopsListState={aiopsListState}
|
||||
dataViewId={dataView.id!}
|
||||
eventRate={eventRate}
|
||||
sparkLines={data.sparkLines}
|
||||
selectedField={selectedField}
|
||||
pinnedCategory={pinnedCategory}
|
||||
setPinnedCategory={setPinnedCategory}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
timefilter={timefilter}
|
||||
onAddFilter={onAddFilter}
|
||||
onClose={onClose}
|
||||
enableRowActions={false}
|
||||
/>
|
||||
) : null}
|
||||
</EuiFlyoutBody>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -43,6 +43,7 @@ import { useCategorizeRequest } from './use_categorize_request';
|
|||
import { CategoryTable } from './category_table';
|
||||
import { DocumentCountChart } from './document_count_chart';
|
||||
import { InformationText } from './information_text';
|
||||
import { SamplingMenu } from './sampling_menu';
|
||||
|
||||
const BAR_TARGET = 20;
|
||||
|
||||
|
@ -52,7 +53,7 @@ export const LogCategorizationPage: FC = () => {
|
|||
} = useAiopsAppContext();
|
||||
const { dataView, savedSearch } = useDataSource();
|
||||
|
||||
const { runCategorizeRequest, cancelRequest } = useCategorizeRequest();
|
||||
const { runCategorizeRequest, cancelRequest, randomSampler } = useCategorizeRequest();
|
||||
const [aiopsListState, setAiopsListState] = usePageUrlState<AiOpsPageUrlState>(
|
||||
'AIOPS_INDEX_VIEWER',
|
||||
getDefaultAiOpsListState()
|
||||
|
@ -60,13 +61,15 @@ export const LogCategorizationPage: FC = () => {
|
|||
const [globalState, setGlobalState] = useUrlState('_g');
|
||||
const [selectedField, setSelectedField] = useState<string | undefined>();
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
||||
const [categories, setCategories] = useState<Category[] | null>(null);
|
||||
const [selectedSavedSearch, setSelectedDataView] = useState(savedSearch);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [eventRate, setEventRate] = useState<EventRate>([]);
|
||||
const [pinnedCategory, setPinnedCategory] = useState<Category | null>(null);
|
||||
const [sparkLines, setSparkLines] = useState<SparkLinesPerCategory>({});
|
||||
const [data, setData] = useState<{
|
||||
categories: Category[];
|
||||
sparkLines: SparkLinesPerCategory;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (savedSearch) {
|
||||
|
@ -118,9 +121,9 @@ export const LogCategorizationPage: FC = () => {
|
|||
intervalMs,
|
||||
} = useData(
|
||||
{ selectedDataView: dataView, selectedSavedSearch },
|
||||
'log_categorization',
|
||||
aiopsListState,
|
||||
setGlobalState,
|
||||
'log_categorization',
|
||||
undefined,
|
||||
undefined,
|
||||
BAR_TARGET
|
||||
|
@ -160,20 +163,29 @@ export const LogCategorizationPage: FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (documentStats.documentCountStats?.buckets) {
|
||||
randomSampler.setDocCount(documentStats.totalCount);
|
||||
setEventRate(
|
||||
Object.entries(documentStats.documentCountStats.buckets).map(([key, docCount]) => ({
|
||||
key: +key,
|
||||
docCount,
|
||||
}))
|
||||
);
|
||||
setCategories(null);
|
||||
setData(null);
|
||||
setTotalCount(documentStats.totalCount);
|
||||
}
|
||||
}, [documentStats, earliest, latest, searchQueryLanguage, searchString, searchQuery]);
|
||||
}, [
|
||||
documentStats,
|
||||
earliest,
|
||||
latest,
|
||||
searchQueryLanguage,
|
||||
searchString,
|
||||
searchQuery,
|
||||
randomSampler,
|
||||
]);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setCategories(null);
|
||||
setData(null);
|
||||
const { title: index, timeFieldName: timeField } = dataView;
|
||||
|
||||
if (selectedField === undefined || timeField === undefined) {
|
||||
|
@ -193,8 +205,7 @@ export const LogCategorizationPage: FC = () => {
|
|||
intervalMs
|
||||
);
|
||||
|
||||
setCategories(resp.categories);
|
||||
setSparkLines(resp.sparkLinesPerCategory);
|
||||
setData({ categories: resp.categories, sparkLines: resp.sparkLinesPerCategory });
|
||||
} catch (error) {
|
||||
toasts.addError(error, {
|
||||
title: i18n.translate('xpack.aiops.logCategorization.errorLoadingCategories', {
|
||||
|
@ -217,7 +228,7 @@ export const LogCategorizationPage: FC = () => {
|
|||
]);
|
||||
|
||||
const onFieldChange = (value: EuiComboBoxOptionOption[] | undefined) => {
|
||||
setCategories(null);
|
||||
setData(null);
|
||||
setSelectedField(value && value.length ? value[0].label : undefined);
|
||||
};
|
||||
|
||||
|
@ -263,15 +274,17 @@ export const LogCategorizationPage: FC = () => {
|
|||
>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.runButton"
|
||||
defaultMessage="Run categorization"
|
||||
defaultMessage="Run pattern analysis"
|
||||
/>
|
||||
</EuiButton>
|
||||
) : (
|
||||
<EuiButton onClick={() => cancelRequest()}>Cancel</EuiButton>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} css={{ marginTop: 'auto' }} />
|
||||
<EuiFlexItem />
|
||||
<EuiFlexItem grow={false} css={{ marginTop: 'auto' }}>
|
||||
<SamplingMenu randomSampler={randomSampler} reload={() => loadCategories()} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
{eventRate.length ? (
|
||||
|
@ -281,7 +294,7 @@ export const LogCategorizationPage: FC = () => {
|
|||
eventRate={eventRate}
|
||||
pinnedCategory={pinnedCategory}
|
||||
selectedCategory={selectedCategory}
|
||||
sparkLines={sparkLines}
|
||||
sparkLines={data?.sparkLines ?? {}}
|
||||
totalCount={totalCount}
|
||||
documentCountStats={documentStats.documentCountStats}
|
||||
/>
|
||||
|
@ -293,21 +306,21 @@ export const LogCategorizationPage: FC = () => {
|
|||
|
||||
<InformationText
|
||||
loading={loading}
|
||||
categoriesLength={categories?.length ?? null}
|
||||
categoriesLength={data?.categories?.length ?? null}
|
||||
eventRateLength={eventRate.length}
|
||||
fieldSelected={selectedField !== null}
|
||||
/>
|
||||
|
||||
{selectedField !== undefined &&
|
||||
categories !== null &&
|
||||
categories.length > 0 &&
|
||||
data !== null &&
|
||||
data.categories.length > 0 &&
|
||||
isFullAiOpsListState(aiopsListState) ? (
|
||||
<CategoryTable
|
||||
categories={categories}
|
||||
categories={data.categories}
|
||||
aiopsListState={aiopsListState}
|
||||
dataViewId={dataView.id!}
|
||||
eventRate={eventRate}
|
||||
sparkLines={sparkLines}
|
||||
sparkLines={data.sparkLines}
|
||||
selectedField={selectedField}
|
||||
pinnedCategory={pinnedCategory}
|
||||
setPinnedCategory={setPinnedCategory}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 { SamplingMenu } from './sampling_menu';
|
||||
export { RandomSampler } from './random_sampler';
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* 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 { BehaviorSubject } from 'rxjs';
|
||||
import { createRandomSamplerWrapper } from '@kbn/ml-random-sampler-utils';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const RANDOM_SAMPLER_PROBABILITIES = [
|
||||
0.00001, 0.00005, 0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5,
|
||||
].map((n) => n * 100);
|
||||
|
||||
export const MIN_SAMPLER_PROBABILITY = 0.00001;
|
||||
export const RANDOM_SAMPLER_STEP = MIN_SAMPLER_PROBABILITY * 100;
|
||||
export const DEFAULT_PROBABILITY = 0.001;
|
||||
|
||||
export const RANDOM_SAMPLER_OPTION = {
|
||||
ON_AUTOMATIC: 'on_automatic',
|
||||
ON_MANUAL: 'on_manual',
|
||||
OFF: 'off',
|
||||
} as const;
|
||||
|
||||
export type RandomSamplerOption = typeof RANDOM_SAMPLER_OPTION[keyof typeof RANDOM_SAMPLER_OPTION];
|
||||
export type RandomSamplerProbability = number | null;
|
||||
|
||||
export const RANDOM_SAMPLER_SELECT_OPTIONS: Array<{
|
||||
value: RandomSamplerOption;
|
||||
text: string;
|
||||
'data-test-subj': string;
|
||||
}> = [
|
||||
{
|
||||
'data-test-subj': 'aiopsRandomSamplerOptionOnAutomatic',
|
||||
value: RANDOM_SAMPLER_OPTION.ON_AUTOMATIC,
|
||||
text: i18n.translate('xpack.aiops.logCategorization.randomSamplerPreference.onAutomaticLabel', {
|
||||
defaultMessage: 'On - automatic',
|
||||
}),
|
||||
},
|
||||
{
|
||||
'data-test-subj': 'aiopsRandomSamplerOptionOnManual',
|
||||
value: RANDOM_SAMPLER_OPTION.ON_MANUAL,
|
||||
text: i18n.translate('xpack.aiops.logCategorization.randomSamplerPreference.onManualLabel', {
|
||||
defaultMessage: 'On - manual',
|
||||
}),
|
||||
},
|
||||
{
|
||||
'data-test-subj': 'aiopsRandomSamplerOptionOff',
|
||||
value: RANDOM_SAMPLER_OPTION.OFF,
|
||||
text: i18n.translate('xpack.aiops.logCategorization.randomSamplerPreference.offLabel', {
|
||||
defaultMessage: 'Off',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export class RandomSampler {
|
||||
private docCount$ = new BehaviorSubject<number>(0);
|
||||
private mode$ = new BehaviorSubject<RandomSamplerOption>(RANDOM_SAMPLER_OPTION.ON_AUTOMATIC);
|
||||
private probability$ = new BehaviorSubject<RandomSamplerProbability>(DEFAULT_PROBABILITY);
|
||||
private setRandomSamplerModeInStorage: (mode: RandomSamplerOption) => void;
|
||||
private setRandomSamplerProbabilityInStorage: (prob: RandomSamplerProbability) => void;
|
||||
|
||||
constructor(
|
||||
randomSamplerMode: RandomSamplerOption,
|
||||
setRandomSamplerMode: (mode: RandomSamplerOption) => void,
|
||||
randomSamplerProbability: RandomSamplerProbability,
|
||||
setRandomSamplerProbability: (prob: RandomSamplerProbability) => void
|
||||
) {
|
||||
this.mode$.next(randomSamplerMode);
|
||||
this.setRandomSamplerModeInStorage = setRandomSamplerMode;
|
||||
this.probability$.next(randomSamplerProbability);
|
||||
this.setRandomSamplerProbabilityInStorage = setRandomSamplerProbability;
|
||||
}
|
||||
|
||||
setDocCount(docCount: number) {
|
||||
return this.docCount$.next(docCount);
|
||||
}
|
||||
|
||||
getDocCount() {
|
||||
return this.docCount$.getValue();
|
||||
}
|
||||
|
||||
public setMode(mode: RandomSamplerOption) {
|
||||
this.setRandomSamplerModeInStorage(mode);
|
||||
return this.mode$.next(mode);
|
||||
}
|
||||
|
||||
public getMode$() {
|
||||
return this.mode$.asObservable();
|
||||
}
|
||||
|
||||
public getMode() {
|
||||
return this.mode$.getValue();
|
||||
}
|
||||
|
||||
public setProbability(probability: RandomSamplerProbability) {
|
||||
this.setRandomSamplerProbabilityInStorage(probability);
|
||||
return this.probability$.next(probability);
|
||||
}
|
||||
|
||||
public getProbability$() {
|
||||
return this.probability$.asObservable();
|
||||
}
|
||||
|
||||
public getProbability() {
|
||||
return this.probability$.getValue();
|
||||
}
|
||||
|
||||
public createRandomSamplerWrapper() {
|
||||
const mode = this.getMode();
|
||||
const probability = this.getProbability();
|
||||
|
||||
let prob = {};
|
||||
if (mode === RANDOM_SAMPLER_OPTION.ON_MANUAL) {
|
||||
prob = { probability };
|
||||
} else if (mode === RANDOM_SAMPLER_OPTION.OFF) {
|
||||
prob = { probability: 1 };
|
||||
}
|
||||
|
||||
const wrapper = createRandomSamplerWrapper({
|
||||
...prob,
|
||||
totalNumDocs: this.getDocCount(),
|
||||
});
|
||||
this.setProbability(wrapper.probability);
|
||||
return wrapper;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* 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 { EuiButton, EuiFlexItem, EuiFormRow, EuiRange, EuiSpacer } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { isDefined } from '@kbn/ml-is-defined';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { roundToDecimalPlace } from '@kbn/ml-number-utils';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
MIN_SAMPLER_PROBABILITY,
|
||||
RANDOM_SAMPLER_PROBABILITIES,
|
||||
RANDOM_SAMPLER_STEP,
|
||||
} from './random_sampler';
|
||||
|
||||
export const RandomSamplerRangeSlider = ({
|
||||
samplingProbability,
|
||||
setSamplingProbability,
|
||||
}: {
|
||||
samplingProbability?: number | null;
|
||||
setSamplingProbability?: (value: number | null) => void;
|
||||
}) => {
|
||||
// Keep track of the input in sampling probability slider when mode is on - manual
|
||||
// before 'Apply' is clicked
|
||||
const [samplingProbabilityInput, setSamplingProbabilityInput] = useState(samplingProbability);
|
||||
|
||||
const isInvalidSamplingProbabilityInput =
|
||||
!isDefined(samplingProbabilityInput) ||
|
||||
isNaN(samplingProbabilityInput) ||
|
||||
samplingProbabilityInput < MIN_SAMPLER_PROBABILITY ||
|
||||
samplingProbabilityInput > 0.5;
|
||||
|
||||
const inputValue = (samplingProbabilityInput ?? MIN_SAMPLER_PROBABILITY) * 100;
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.randomSamplerPercentageRowLabel',
|
||||
{
|
||||
defaultMessage: 'Sampling percentage',
|
||||
}
|
||||
)}
|
||||
helpText={i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.randomSamplerPercentageRowHelpText',
|
||||
{
|
||||
defaultMessage: 'Choose a value between 0.001% and 50% to randomly sample data.',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiRange
|
||||
fullWidth
|
||||
showValue
|
||||
showRange
|
||||
showLabels
|
||||
showInput="inputWithPopover"
|
||||
min={RANDOM_SAMPLER_STEP}
|
||||
max={50}
|
||||
// Rounding to 0 decimal place because sometimes js results in weird number when multiplying fractions
|
||||
// e.g. 0.07 * 100 yields 7.000000000000001
|
||||
value={
|
||||
inputValue >= 1
|
||||
? roundToDecimalPlace(inputValue, 0)
|
||||
: roundToDecimalPlace(inputValue, 3)
|
||||
}
|
||||
ticks={RANDOM_SAMPLER_PROBABILITIES.map((d) => ({
|
||||
value: d,
|
||||
label: d === 0.001 || d >= 5 ? `${d}` : '',
|
||||
}))}
|
||||
isInvalid={isInvalidSamplingProbabilityInput}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat((e.target as HTMLInputElement).value);
|
||||
const prevValue = samplingProbabilityInput ? samplingProbabilityInput * 100 : value;
|
||||
|
||||
if (value > 0 && value <= 1) {
|
||||
setSamplingProbabilityInput(value / 100);
|
||||
} else {
|
||||
// Because the incremental step is very small (0.0001),
|
||||
// every time user clicks the ^/∨ in the numerical input
|
||||
// we need to make sure it rounds up or down to the next whole number
|
||||
const nearestInt = value > prevValue ? Math.ceil(value) : Math.floor(value);
|
||||
setSamplingProbabilityInput(nearestInt / 100);
|
||||
}
|
||||
}}
|
||||
step={RANDOM_SAMPLER_STEP}
|
||||
data-test-subj="dvRandomSamplerProbabilityRange"
|
||||
append={
|
||||
<EuiButton
|
||||
disabled={isInvalidSamplingProbabilityInput}
|
||||
onClick={() => {
|
||||
if (setSamplingProbability && isDefined(samplingProbabilityInput)) {
|
||||
setSamplingProbability(samplingProbabilityInput);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.randomSamplerSettingsPopUp.randomSamplerPercentageApply"
|
||||
defaultMessage="Apply"
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,185 @@
|
|||
/*
|
||||
* 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, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiCallOut,
|
||||
EuiSelect,
|
||||
EuiFormRow,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
||||
import { RandomSamplerRangeSlider } from './random_sampler_range_slider';
|
||||
import {
|
||||
RandomSampler,
|
||||
RandomSamplerOption,
|
||||
RANDOM_SAMPLER_OPTION,
|
||||
RANDOM_SAMPLER_SELECT_OPTIONS,
|
||||
} from './random_sampler';
|
||||
|
||||
interface Props {
|
||||
randomSampler: RandomSampler;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export const SamplingMenu: FC<Props> = ({ randomSampler, reload }) => {
|
||||
const [showSamplingOptionsPopover, setShowSamplingOptionsPopover] = useState(false);
|
||||
|
||||
const samplingProbability = useObservable(
|
||||
randomSampler.getProbability$(),
|
||||
randomSampler.getProbability()
|
||||
);
|
||||
const setSamplingProbability = useCallback(
|
||||
(probability: number | null) => {
|
||||
randomSampler.setProbability(probability);
|
||||
reload();
|
||||
},
|
||||
[reload, randomSampler]
|
||||
);
|
||||
|
||||
const randomSamplerPreference = useObservable(randomSampler.getMode$(), randomSampler.getMode());
|
||||
const setRandomSamplerPreference = useCallback(
|
||||
(mode: RandomSamplerOption) => {
|
||||
randomSampler.setMode(mode);
|
||||
reload();
|
||||
},
|
||||
[randomSampler, reload]
|
||||
);
|
||||
|
||||
const { calloutInfoMessage, buttonText } = useMemo(() => {
|
||||
switch (randomSamplerPreference) {
|
||||
case RANDOM_SAMPLER_OPTION.OFF:
|
||||
return {
|
||||
calloutInfoMessage: i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.offCallout.message',
|
||||
{
|
||||
defaultMessage:
|
||||
'Random sampling can be turned on to increase the speed of analysis, although some accuracy will be lost.',
|
||||
}
|
||||
),
|
||||
buttonText: i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.offCallout.button',
|
||||
{
|
||||
defaultMessage: 'No sampling',
|
||||
}
|
||||
),
|
||||
};
|
||||
case RANDOM_SAMPLER_OPTION.ON_AUTOMATIC:
|
||||
return {
|
||||
calloutInfoMessage: i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.onAutomaticCallout.message',
|
||||
{
|
||||
defaultMessage:
|
||||
'The pattern analysis will use random sampler aggregations. The probability is automatically set to balance accuracy and speed.',
|
||||
}
|
||||
),
|
||||
buttonText: i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.onAutomaticCallout.button',
|
||||
{
|
||||
defaultMessage: 'Auto sampling',
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
case RANDOM_SAMPLER_OPTION.ON_MANUAL:
|
||||
default:
|
||||
return {
|
||||
calloutInfoMessage: i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.onManualCallout.message',
|
||||
{
|
||||
defaultMessage:
|
||||
'The pattern analysis will use random sampler aggregations. A lower percentage probability increases performance, but some accuracy is lost.',
|
||||
}
|
||||
),
|
||||
buttonText: i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.onManualCallout.button',
|
||||
{
|
||||
defaultMessage: 'Manual sampling',
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
}, [randomSamplerPreference]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
data-test-subj="aiopsRandomSamplerOptionsPopover"
|
||||
id="aiopsSamplingOptions"
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
onClick={() => setShowSamplingOptionsPopover(!showSamplingOptionsPopover)}
|
||||
iconSide="right"
|
||||
iconType="arrowDown"
|
||||
>
|
||||
{buttonText}
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={showSamplingOptionsPopover}
|
||||
closePopover={() => setShowSamplingOptionsPopover(false)}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
<EuiPanel style={{ maxWidth: 400 }}>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiCallOut size="s" color={'primary'} title={calloutInfoMessage} />
|
||||
</EuiFlexItem>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFormRow
|
||||
data-test-subj="aiopsRandomSamplerOptionsFormRow"
|
||||
label={i18n.translate(
|
||||
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.randomSamplerRowLabel',
|
||||
{
|
||||
defaultMessage: 'Random sampling',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiSelect
|
||||
data-test-subj="aiopsRandomSamplerOptionsSelect"
|
||||
options={RANDOM_SAMPLER_SELECT_OPTIONS}
|
||||
value={randomSamplerPreference}
|
||||
onChange={(e) => setRandomSamplerPreference(e.target.value as RandomSamplerOption)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
{randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_MANUAL ? (
|
||||
<RandomSamplerRangeSlider
|
||||
samplingProbability={samplingProbability}
|
||||
setSamplingProbability={setSamplingProbability}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_AUTOMATIC ? (
|
||||
<ProbabilityUsedMessage samplingProbability={samplingProbability} />
|
||||
) : null}
|
||||
</EuiPanel>
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
const ProbabilityUsedMessage: FC<{ samplingProbability: number | null }> = ({
|
||||
samplingProbability,
|
||||
}) => {
|
||||
return samplingProbability !== null ? (
|
||||
<div data-test-subj="aiopsRandomSamplerProbabilityUsedMsg">
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<FormattedMessage
|
||||
id="xpack.aiops.logCategorization.randomSamplerSettingsPopUp.probabilityLabel"
|
||||
defaultMessage="Probability used: {samplingProbability}%"
|
||||
values={{ samplingProbability: samplingProbability * 100 }}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import { takeUntil, distinctUntilChanged, skip } from 'rxjs/operators';
|
||||
import { from } from 'rxjs';
|
||||
import { pick } from 'lodash';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
import {
|
||||
toMountPoint,
|
||||
wrapWithTheme,
|
||||
KibanaContextProvider,
|
||||
} from '@kbn/kibana-react-plugin/public';
|
||||
import type { DataViewField, DataView } from '@kbn/data-views-plugin/common';
|
||||
import { UI_SETTINGS } from '@kbn/data-plugin/public';
|
||||
import { DatePickerContextProvider } from '@kbn/ml-date-picker';
|
||||
import { StorageContextProvider } from '@kbn/ml-local-storage';
|
||||
import type { AiopsPluginStartDeps } from '../../types';
|
||||
import { AiopsAppContext } from '../../hooks/use_aiops_app_context';
|
||||
import { LogCategorizationFlyout } from './log_categorization_for_flyout';
|
||||
import { AIOPS_STORAGE_KEYS } from '../../types/storage';
|
||||
|
||||
const localStorage = new Storage(window.localStorage);
|
||||
|
||||
export async function showCategorizeFlyout(
|
||||
field: DataViewField,
|
||||
dataView: DataView,
|
||||
coreStart: CoreStart,
|
||||
plugins: AiopsPluginStartDeps
|
||||
): Promise<void> {
|
||||
const { http, theme, overlays, application, notifications, uiSettings } = coreStart;
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const onFlyoutClose = () => {
|
||||
flyoutSession.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const appDependencies = {
|
||||
notifications,
|
||||
uiSettings,
|
||||
http,
|
||||
theme,
|
||||
application,
|
||||
...plugins,
|
||||
};
|
||||
const datePickerDeps = {
|
||||
...pick(appDependencies, ['data', 'http', 'notifications', 'theme', 'uiSettings']),
|
||||
toMountPoint,
|
||||
wrapWithTheme,
|
||||
uiSettingsKeys: UI_SETTINGS,
|
||||
};
|
||||
|
||||
const flyoutSession = overlays.openFlyout(
|
||||
toMountPoint(
|
||||
wrapWithTheme(
|
||||
<KibanaContextProvider
|
||||
services={{
|
||||
...coreStart,
|
||||
}}
|
||||
>
|
||||
<AiopsAppContext.Provider value={appDependencies}>
|
||||
<DatePickerContextProvider {...datePickerDeps}>
|
||||
<StorageContextProvider storage={localStorage} storageKeys={AIOPS_STORAGE_KEYS}>
|
||||
<LogCategorizationFlyout
|
||||
dataView={dataView}
|
||||
savedSearch={null}
|
||||
selectedField={field}
|
||||
onClose={onFlyoutClose}
|
||||
/>
|
||||
</StorageContextProvider>
|
||||
</DatePickerContextProvider>
|
||||
</AiopsAppContext.Provider>
|
||||
</KibanaContextProvider>,
|
||||
theme.theme$
|
||||
)
|
||||
),
|
||||
{
|
||||
'data-test-subj': 'aiopsCategorizeFlyout',
|
||||
ownFocus: true,
|
||||
closeButtonAriaLabel: 'aiopsCategorizeFlyout',
|
||||
onClose: onFlyoutClose,
|
||||
size: 'l',
|
||||
}
|
||||
);
|
||||
|
||||
// Close the flyout when user navigates out of the current plugin
|
||||
application.currentAppId$
|
||||
.pipe(skip(1), takeUntil(from(flyoutSession.onClose)), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
flyoutSession.close();
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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 { TechnicalPreviewBadge } from './technical_preview_badge';
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { EuiBetaBadge } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const TechnicalPreviewBadge: FC = () => {
|
||||
return (
|
||||
<EuiBetaBadge
|
||||
label={i18n.translate('xpack.aiops.techPreviewBadge.label', {
|
||||
defaultMessage: 'Technical preview',
|
||||
})}
|
||||
size="m"
|
||||
color="hollow"
|
||||
tooltipContent={i18n.translate('xpack.aiops.techPreviewBadge.tooltip', {
|
||||
defaultMessage:
|
||||
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
|
||||
})}
|
||||
tooltipPosition={'right'}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -6,30 +6,47 @@
|
|||
*/
|
||||
|
||||
import { cloneDeep, get } from 'lodash';
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/public';
|
||||
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
import { createRandomSamplerWrapper } from '@kbn/ml-random-sampler-utils';
|
||||
import { estypes } from '@elastic/elasticsearch';
|
||||
import { useStorage } from '@kbn/ml-local-storage';
|
||||
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
|
||||
import { RandomSampler } from './sampling_menu';
|
||||
import {
|
||||
type AiOpsKey,
|
||||
type AiOpsStorageMapped,
|
||||
AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE,
|
||||
AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE,
|
||||
} from '../../types/storage';
|
||||
import { RANDOM_SAMPLER_OPTION, DEFAULT_PROBABILITY } from './sampling_menu/random_sampler';
|
||||
|
||||
const CATEGORY_LIMIT = 1000;
|
||||
const EXAMPLE_LIMIT = 1;
|
||||
|
||||
interface CatResponse {
|
||||
rawResponse: {
|
||||
aggregations: {
|
||||
categories: {
|
||||
buckets: Array<{
|
||||
key: string;
|
||||
doc_count: number;
|
||||
hit: { hits: { hits: Array<{ _source: { message: string } }> } };
|
||||
sparkline: { buckets: Array<{ key_as_string: string; key: number; doc_count: number }> };
|
||||
}>;
|
||||
interface CategoriesAgg {
|
||||
categories: {
|
||||
buckets: Array<{
|
||||
key: string;
|
||||
doc_count: number;
|
||||
hit: { hits: { hits: Array<{ _source: { message: string } }> } };
|
||||
sparkline: {
|
||||
buckets: Array<{ key_as_string: string; key: number; doc_count: number }>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CategoriesSampleAgg {
|
||||
sample: CategoriesAgg;
|
||||
}
|
||||
|
||||
interface CatResponse {
|
||||
rawResponse: estypes.SearchResponseBody<unknown, CategoriesAgg | CategoriesSampleAgg>;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
key: string;
|
||||
count: number;
|
||||
|
@ -45,10 +62,32 @@ export type EventRate = Array<{
|
|||
export type SparkLinesPerCategory = Record<string, Record<number, number>>;
|
||||
|
||||
export function useCategorizeRequest() {
|
||||
const [randomSamplerMode, setRandomSamplerMode] = useStorage<
|
||||
AiOpsKey,
|
||||
AiOpsStorageMapped<typeof AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE>
|
||||
>(AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE, RANDOM_SAMPLER_OPTION.ON_AUTOMATIC);
|
||||
|
||||
const [randomSamplerProbability, setRandomSamplerProbability] = useStorage<
|
||||
AiOpsKey,
|
||||
AiOpsStorageMapped<typeof AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE>
|
||||
>(AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE, DEFAULT_PROBABILITY);
|
||||
|
||||
const { data } = useAiopsAppContext();
|
||||
|
||||
const abortController = useRef(new AbortController());
|
||||
|
||||
const randomSampler = useMemo(
|
||||
() =>
|
||||
new RandomSampler(
|
||||
randomSamplerMode,
|
||||
setRandomSamplerMode,
|
||||
randomSamplerProbability,
|
||||
setRandomSamplerProbability
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const runCategorizeRequest = useCallback(
|
||||
(
|
||||
index: string,
|
||||
|
@ -59,16 +98,18 @@ export function useCategorizeRequest() {
|
|||
query: QueryDslQueryContainer,
|
||||
intervalMs?: number
|
||||
): Promise<{ categories: Category[]; sparkLinesPerCategory: SparkLinesPerCategory }> => {
|
||||
const { wrap, unwrap } = randomSampler.createRandomSamplerWrapper();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
data.search
|
||||
.search<ReturnType<typeof createCategoryRequest>, CatResponse>(
|
||||
createCategoryRequest(index, field, timeField, from, to, query, intervalMs),
|
||||
createCategoryRequest(index, field, timeField, from, to, query, wrap, intervalMs),
|
||||
{ abortSignal: abortController.current.signal }
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
if (isCompleteResponse(result)) {
|
||||
resolve(processCategoryResults(result, field));
|
||||
resolve(processCategoryResults(result, field, unwrap));
|
||||
} else if (isErrorResponse(result)) {
|
||||
reject(result);
|
||||
} else {
|
||||
|
@ -86,7 +127,7 @@ export function useCategorizeRequest() {
|
|||
});
|
||||
});
|
||||
},
|
||||
[data.search]
|
||||
[data.search, randomSampler]
|
||||
);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
|
@ -94,7 +135,7 @@ export function useCategorizeRequest() {
|
|||
abortController.current = new AbortController();
|
||||
}, []);
|
||||
|
||||
return { runCategorizeRequest, cancelRequest };
|
||||
return { runCategorizeRequest, cancelRequest, randomSampler };
|
||||
}
|
||||
|
||||
function createCategoryRequest(
|
||||
|
@ -104,6 +145,7 @@ function createCategoryRequest(
|
|||
from: number | undefined,
|
||||
to: number | undefined,
|
||||
queryIn: QueryDslQueryContainer,
|
||||
wrap: ReturnType<typeof createRandomSamplerWrapper>['wrap'],
|
||||
intervalMs?: number
|
||||
) {
|
||||
const query = cloneDeep(queryIn);
|
||||
|
@ -134,52 +176,64 @@ function createCategoryRequest(
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
const aggs = {
|
||||
categories: {
|
||||
categorize_text: {
|
||||
field,
|
||||
size: CATEGORY_LIMIT,
|
||||
},
|
||||
aggs: {
|
||||
hit: {
|
||||
top_hits: {
|
||||
size: EXAMPLE_LIMIT,
|
||||
sort: [timeField],
|
||||
_source: field,
|
||||
},
|
||||
},
|
||||
...(intervalMs
|
||||
? {
|
||||
sparkline: {
|
||||
date_histogram: {
|
||||
field: timeField,
|
||||
fixed_interval: `${intervalMs}ms`,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
params: {
|
||||
index,
|
||||
size: 0,
|
||||
body: {
|
||||
query,
|
||||
aggs: {
|
||||
categories: {
|
||||
categorize_text: {
|
||||
field,
|
||||
size: CATEGORY_LIMIT,
|
||||
},
|
||||
aggs: {
|
||||
hit: {
|
||||
top_hits: {
|
||||
size: EXAMPLE_LIMIT,
|
||||
sort: [timeField],
|
||||
_source: field,
|
||||
},
|
||||
},
|
||||
...(intervalMs
|
||||
? {
|
||||
sparkline: {
|
||||
date_histogram: {
|
||||
field: timeField,
|
||||
fixed_interval: `${intervalMs}ms`,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: wrap(aggs),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function processCategoryResults(result: CatResponse, field: string) {
|
||||
function processCategoryResults(
|
||||
result: CatResponse,
|
||||
field: string,
|
||||
unwrap: ReturnType<typeof createRandomSamplerWrapper>['unwrap']
|
||||
) {
|
||||
const sparkLinesPerCategory: SparkLinesPerCategory = {};
|
||||
|
||||
if (result.rawResponse.aggregations === undefined) {
|
||||
const { aggregations } = result.rawResponse;
|
||||
if (aggregations === undefined) {
|
||||
throw new Error('processCategoryResults failed, did not return aggregations.');
|
||||
}
|
||||
const {
|
||||
categories: { buckets },
|
||||
} = unwrap(
|
||||
aggregations as unknown as Record<string, estypes.AggregationsAggregate>
|
||||
) as CategoriesAgg;
|
||||
|
||||
const categories: Category[] = result.rawResponse.aggregations.categories.buckets.map((b) => {
|
||||
const categories: Category[] = buckets.map((b) => {
|
||||
sparkLinesPerCategory[b.key] =
|
||||
b.sparkline === undefined
|
||||
? {}
|
||||
|
|
|
@ -10,10 +10,16 @@ import moment from 'moment';
|
|||
|
||||
import type { TimeRangeBounds } from '@kbn/data-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import type { AiOpsIndexBasedAppState } from '../../application/utils/url_state';
|
||||
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
|
||||
import type { Category } from './use_categorize_request';
|
||||
import type { QueryMode } from './category_table';
|
||||
|
||||
export const QUERY_MODE = {
|
||||
INCLUDE: 'should',
|
||||
EXCLUDE: 'must_not',
|
||||
} as const;
|
||||
export type QueryMode = typeof QUERY_MODE[keyof typeof QUERY_MODE];
|
||||
|
||||
export function useDiscoverLinks() {
|
||||
const {
|
||||
|
@ -29,8 +35,6 @@ export function useDiscoverLinks() {
|
|||
mode: QueryMode,
|
||||
category?: Category
|
||||
) => {
|
||||
const selectedRows = category === undefined ? selection : [category];
|
||||
|
||||
const _g = rison.encode({
|
||||
time: {
|
||||
from: moment(timefilterActiveBounds.min?.valueOf()).toISOString(),
|
||||
|
@ -39,35 +43,7 @@ export function useDiscoverLinks() {
|
|||
});
|
||||
|
||||
const _a = rison.encode({
|
||||
filters: [
|
||||
...aiopsListState.filters,
|
||||
{
|
||||
query: {
|
||||
bool: {
|
||||
[mode]: selectedRows.map(({ key: query }) => ({
|
||||
match: {
|
||||
[field]: {
|
||||
auto_generate_synonyms_phrase_query: false,
|
||||
fuzziness: 0,
|
||||
operator: 'and',
|
||||
query,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
alias: i18n.translate('xpack.aiops.logCategorization.filterAliasLabel', {
|
||||
defaultMessage: 'Categorization - {field}',
|
||||
values: {
|
||||
field,
|
||||
},
|
||||
}),
|
||||
index,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
filters: [...aiopsListState.filters, createFilter(index, field, selection, mode, category)],
|
||||
index,
|
||||
interval: 'auto',
|
||||
query: {
|
||||
|
@ -85,3 +61,39 @@ export function useDiscoverLinks() {
|
|||
|
||||
return { openInDiscoverWithFilter };
|
||||
}
|
||||
|
||||
export function createFilter(
|
||||
index: string,
|
||||
field: string,
|
||||
selection: Category[],
|
||||
mode: QueryMode,
|
||||
category?: Category
|
||||
): Filter {
|
||||
const selectedRows = category === undefined ? selection : [category];
|
||||
return {
|
||||
query: {
|
||||
bool: {
|
||||
[mode]: selectedRows.map(({ key: query }) => ({
|
||||
match: {
|
||||
[field]: {
|
||||
auto_generate_synonyms_phrase_query: false,
|
||||
fuzziness: 0,
|
||||
operator: 'and',
|
||||
query,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
alias: i18n.translate('xpack.aiops.logCategorization.filterAliasLabel', {
|
||||
defaultMessage: 'Categorization - {field}',
|
||||
values: {
|
||||
field,
|
||||
},
|
||||
}),
|
||||
index,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -34,12 +34,13 @@ export const useData = (
|
|||
selectedDataView,
|
||||
selectedSavedSearch,
|
||||
}: { selectedDataView: DataView; selectedSavedSearch: SavedSearch | null },
|
||||
aiopsListState: AiOpsIndexBasedAppState,
|
||||
onUpdate: (params: Dictionary<unknown>) => void,
|
||||
contextId: string,
|
||||
aiopsListState: AiOpsIndexBasedAppState,
|
||||
onUpdate?: (params: Dictionary<unknown>) => void,
|
||||
selectedSignificantTerm?: SignificantTerm,
|
||||
selectedGroup?: GroupTableItem | null,
|
||||
barTarget: number = DEFAULT_BAR_TARGET
|
||||
barTarget: number = DEFAULT_BAR_TARGET,
|
||||
readOnly: boolean = false
|
||||
) => {
|
||||
const {
|
||||
executionContext,
|
||||
|
@ -67,7 +68,7 @@ export const useData = (
|
|||
});
|
||||
|
||||
if (searchData === undefined || aiopsListState.searchString !== '') {
|
||||
if (aiopsListState.filters) {
|
||||
if (aiopsListState.filters && readOnly === false) {
|
||||
const globalFilters = filterManager?.getGlobalFilters();
|
||||
|
||||
if (filterManager) filterManager.setFilters(aiopsListState.filters);
|
||||
|
@ -162,8 +163,8 @@ export const useData = (
|
|||
time: timefilter.getTime(),
|
||||
refreshInterval: timefilter.getRefreshInterval(),
|
||||
});
|
||||
setLastRefresh(Date.now());
|
||||
}
|
||||
setLastRefresh(Date.now());
|
||||
});
|
||||
|
||||
// This listens just for an initial update of the timefilter to be switched on.
|
||||
|
@ -191,5 +192,6 @@ export const useData = (
|
|||
searchQueryLanguage,
|
||||
searchString,
|
||||
searchQuery,
|
||||
forceRefresh: () => setLastRefresh(Date.now()),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -5,10 +5,45 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Plugin } from '@kbn/core/public';
|
||||
import type { CoreStart, Plugin } from '@kbn/core/public';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
AiopsPluginSetup,
|
||||
AiopsPluginSetupDeps,
|
||||
AiopsPluginStart,
|
||||
AiopsPluginStartDeps,
|
||||
} from './types';
|
||||
|
||||
export class AiopsPlugin
|
||||
implements Plugin<AiopsPluginSetup, AiopsPluginStart, AiopsPluginSetupDeps, AiopsPluginStartDeps>
|
||||
{
|
||||
public setup() {
|
||||
return {};
|
||||
}
|
||||
|
||||
public start(core: CoreStart, plugins: AiopsPluginStartDeps) {
|
||||
// importing async to keep the aiops plugin size to a minimum
|
||||
Promise.all([
|
||||
import('@kbn/ui-actions-plugin/public'),
|
||||
import('./components/log_categorization'),
|
||||
firstValueFrom(plugins.licensing.license$),
|
||||
]).then(([uiActionsImports, { categorizeFieldAction }, license]) => {
|
||||
if (license.hasAtLeast('platinum')) {
|
||||
const { ACTION_CATEGORIZE_FIELD, CATEGORIZE_FIELD_TRIGGER } = uiActionsImports;
|
||||
if (plugins.uiActions.hasAction(ACTION_CATEGORIZE_FIELD)) {
|
||||
plugins.uiActions.unregisterAction(ACTION_CATEGORIZE_FIELD);
|
||||
}
|
||||
|
||||
plugins.uiActions.addTriggerAction(
|
||||
CATEGORIZE_FIELD_TRIGGER,
|
||||
categorizeFieldAction(core, plugins)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export class AiopsPlugin implements Plugin {
|
||||
public setup() {}
|
||||
public start() {}
|
||||
public stop() {}
|
||||
}
|
||||
|
|
41
x-pack/plugins/aiops/public/types.ts
Executable file
41
x-pack/plugins/aiops/public/types.ts
Executable file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 { ChartsPluginStart } from '@kbn/charts-plugin/public';
|
||||
import type { ExecutionContextStart } from '@kbn/core-execution-context-browser';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
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 { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface AiopsPluginSetupDeps {}
|
||||
|
||||
export interface AiopsPluginStartDeps {
|
||||
data: DataPublicPluginStart;
|
||||
charts: ChartsPluginStart;
|
||||
uiActions: UiActionsStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
lens: LensPublicStart;
|
||||
share: SharePluginStart;
|
||||
unifiedSearch: UnifiedSearchPublicPluginStart;
|
||||
storage: IStorageWrapper;
|
||||
licensing: LicensingPluginStart;
|
||||
executionContext: ExecutionContextStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* aiops plugin server setup contract
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface AiopsPluginSetup {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface AiopsPluginStart {}
|
|
@ -6,17 +6,34 @@
|
|||
*/
|
||||
|
||||
import { type FrozenTierPreference } from '@kbn/ml-date-picker';
|
||||
import {
|
||||
type RandomSamplerOption,
|
||||
type RandomSamplerProbability,
|
||||
} from '../components/log_categorization/sampling_menu/random_sampler';
|
||||
|
||||
export const AIOPS_FROZEN_TIER_PREFERENCE = 'aiops.frozenDataTierPreference';
|
||||
export const AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE = 'aiops.randomSamplingModePreference';
|
||||
export const AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE =
|
||||
'aiops.randomSamplingProbabilityPreference';
|
||||
|
||||
export type AiOps = Partial<{
|
||||
[AIOPS_FROZEN_TIER_PREFERENCE]: FrozenTierPreference;
|
||||
[AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE]: RandomSamplerOption;
|
||||
[AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE]: number;
|
||||
}> | null;
|
||||
|
||||
export type AiOpsKey = keyof Exclude<AiOps, null>;
|
||||
|
||||
export type AiOpsStorageMapped<T extends AiOpsKey> = T extends typeof AIOPS_FROZEN_TIER_PREFERENCE
|
||||
? FrozenTierPreference | undefined
|
||||
: T extends typeof AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE
|
||||
? RandomSamplerOption
|
||||
: T extends typeof AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE
|
||||
? RandomSamplerProbability
|
||||
: null;
|
||||
|
||||
export const AIOPS_STORAGE_KEYS = [AIOPS_FROZEN_TIER_PREFERENCE] as const;
|
||||
export const AIOPS_STORAGE_KEYS = [
|
||||
AIOPS_FROZEN_TIER_PREFERENCE,
|
||||
AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE,
|
||||
AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE,
|
||||
] as const;
|
||||
|
|
|
@ -12,46 +12,48 @@
|
|||
"types/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/unified-search-plugin",
|
||||
"@kbn/charts-plugin",
|
||||
"@kbn/discover-plugin",
|
||||
"@kbn/lens-plugin",
|
||||
"@kbn/datemath",
|
||||
"@kbn/field-formats-plugin",
|
||||
"@kbn/ml-agg-utils",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/ml-is-populated-object",
|
||||
"@kbn/es-query",
|
||||
"@kbn/share-plugin",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/saved-search-plugin",
|
||||
"@kbn/i18n",
|
||||
"@kbn/ml-string-hash",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/rison",
|
||||
"@kbn/aiops-components",
|
||||
"@kbn/aiops-utils",
|
||||
"@kbn/licensing-plugin",
|
||||
"@kbn/field-types",
|
||||
"@kbn/logging",
|
||||
"@kbn/charts-plugin",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/core-elasticsearch-server",
|
||||
"@kbn/core-execution-context-browser",
|
||||
"@kbn/core",
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/datemath",
|
||||
"@kbn/discover-plugin",
|
||||
"@kbn/es-query",
|
||||
"@kbn/es-types",
|
||||
"@kbn/ml-url-state",
|
||||
"@kbn/ml-local-storage",
|
||||
"@kbn/field-formats-plugin",
|
||||
"@kbn/field-types",
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/i18n",
|
||||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/lens-plugin",
|
||||
"@kbn/licensing-plugin",
|
||||
"@kbn/logging",
|
||||
"@kbn/ml-agg-utils",
|
||||
"@kbn/ml-date-picker",
|
||||
"@kbn/ml-local-storage",
|
||||
"@kbn/ml-query-utils",
|
||||
"@kbn/ml-is-defined",
|
||||
"@kbn/ml-route-utils",
|
||||
"@kbn/unified-field-list-plugin",
|
||||
"@kbn/ml-random-sampler-utils",
|
||||
"@kbn/utility-types",
|
||||
"@kbn/ml-error-utils",
|
||||
"@kbn/ml-is-defined",
|
||||
"@kbn/ml-is-populated-object",
|
||||
"@kbn/ml-local-storage",
|
||||
"@kbn/ml-number-utils",
|
||||
"@kbn/ml-query-utils",
|
||||
"@kbn/ml-random-sampler-utils",
|
||||
"@kbn/ml-route-utils",
|
||||
"@kbn/ml-string-hash",
|
||||
"@kbn/ml-url-state",
|
||||
"@kbn/rison",
|
||||
"@kbn/saved-search-plugin",
|
||||
"@kbn/share-plugin",
|
||||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/unified-field-list-plugin",
|
||||
"@kbn/unified-search-plugin",
|
||||
"@kbn/utility-types",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -23,9 +23,8 @@ import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
|||
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
|
||||
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { loadFieldStats } from '@kbn/unified-field-list-plugin/public/services/field_stats';
|
||||
import { FieldIcon } from '@kbn/unified-field-list-plugin/public';
|
||||
import { DOCUMENT_FIELD_NAME } from '../../../common/constants';
|
||||
import { FieldStats, FieldVisualizeButton } from '@kbn/unified-field-list-plugin/public';
|
||||
import { FieldIcon, FieldStats, FieldPopoverFooter } from '@kbn/unified-field-list-plugin/public';
|
||||
|
||||
jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
|
||||
loadFieldStats: jest.fn().mockResolvedValue({}),
|
||||
|
@ -451,7 +450,7 @@ describe('Lens Field Item', () => {
|
|||
expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true);
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||
expect(wrapper.find(FieldStats).text()).toBe('Analysis is not available for this field.');
|
||||
expect(wrapper.find(FieldVisualizeButton).exists()).toBeFalsy();
|
||||
expect(wrapper.find(FieldPopoverFooter).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should request examples for geo fields and render Visualize button', async () => {
|
||||
|
@ -476,7 +475,7 @@ describe('Lens Field Item', () => {
|
|||
expect(wrapper.find(FieldStats).text()).toBe(
|
||||
'Lens is unable to create visualizations with this field because it does not contain data. To create a visualization, drag and drop a different field.'
|
||||
);
|
||||
expect(wrapper.find(FieldVisualizeButton).exists()).toBeTruthy();
|
||||
expect(wrapper.find(FieldPopoverFooter).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display Explore in discover button', async () => {
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
FieldStats,
|
||||
FieldPopover,
|
||||
FieldPopoverHeader,
|
||||
FieldPopoverVisualize,
|
||||
FieldPopoverFooter,
|
||||
FieldItemButton,
|
||||
type GetCustomFieldType,
|
||||
} from '@kbn/unified-field-list-plugin/public';
|
||||
|
@ -342,7 +342,7 @@ function FieldItemPopoverContents(
|
|||
/>
|
||||
|
||||
{dataViewField.type === 'geo_point' || dataViewField.type === 'geo_shape' ? (
|
||||
<FieldPopoverVisualize
|
||||
<FieldPopoverFooter
|
||||
field={dataViewField}
|
||||
dataView={{ ...indexPattern, toSpec: () => indexPattern.spec } as unknown as DataView}
|
||||
originatingApp={APP_ID}
|
||||
|
|
|
@ -7028,13 +7028,11 @@
|
|||
"xpack.aiops.logCategorization.emptyPromptBody": "Loguez les messages de groupes d'analyse de modèle dans les catégories courantes.",
|
||||
"xpack.aiops.logCategorization.emptyPromptTitle": "Sélectionner un champ de texte et cliquez sur Exécuter la catégorisation pour démarrer l'analyse",
|
||||
"xpack.aiops.logCategorization.errorLoadingCategories": "Erreur lors du chargement des catégories",
|
||||
"xpack.aiops.logCategorization.filterOutInDiscover": "Filtrer dans Discover",
|
||||
"xpack.aiops.logCategorization.noCategoriesBody": "Assurez-vous que le champ sélectionné est rempli dans la plage temporelle sélectionnée.",
|
||||
"xpack.aiops.logCategorization.noCategoriesTitle": "Aucune catégorie trouvée",
|
||||
"xpack.aiops.logCategorization.noDocsBody": "Assurez-vous que la plage temporelle sélectionnée contient des documents.",
|
||||
"xpack.aiops.logCategorization.noDocsTitle": "Aucun document trouvé",
|
||||
"xpack.aiops.logCategorization.runButton": "Exécuter la catégorisation",
|
||||
"xpack.aiops.logCategorization.showInDiscover": "Les afficher dans Discover",
|
||||
"xpack.aiops.miniHistogram.noDataLabel": "S. O.",
|
||||
"xpack.aiops.progressAriaLabel": "Progression",
|
||||
"xpack.aiops.rerunAnalysisButtonTitle": "Relancer l’analyse",
|
||||
|
|
|
@ -7029,13 +7029,11 @@
|
|||
"xpack.aiops.logCategorization.emptyPromptBody": "ログパターン分析では、メッセージが共通のカテゴリーにグループ化されます。",
|
||||
"xpack.aiops.logCategorization.emptyPromptTitle": "テキストフィールドを選択し、[カテゴリー分けの実行]をクリックすると、分析が開始します",
|
||||
"xpack.aiops.logCategorization.errorLoadingCategories": "カテゴリーの読み込みエラー",
|
||||
"xpack.aiops.logCategorization.filterOutInDiscover": "Discoverで除外",
|
||||
"xpack.aiops.logCategorization.noCategoriesBody": "選択したフィールドが選択した時間範囲で入力されていることを確認してください。",
|
||||
"xpack.aiops.logCategorization.noCategoriesTitle": "カテゴリーが見つかりません",
|
||||
"xpack.aiops.logCategorization.noDocsBody": "選択した日付範囲にドキュメントが含まれていることを確認してください。",
|
||||
"xpack.aiops.logCategorization.noDocsTitle": "ドキュメントが見つかりませんでした",
|
||||
"xpack.aiops.logCategorization.runButton": "カテゴリー分けの実行",
|
||||
"xpack.aiops.logCategorization.showInDiscover": "Discoverでこれらを表示",
|
||||
"xpack.aiops.miniHistogram.noDataLabel": "N/A",
|
||||
"xpack.aiops.progressAriaLabel": "進捗",
|
||||
"xpack.aiops.rerunAnalysisButtonTitle": "分析を再実行",
|
||||
|
|
|
@ -7029,13 +7029,11 @@
|
|||
"xpack.aiops.logCategorization.emptyPromptBody": "日志模式分析会将消息分组到常用类别。",
|
||||
"xpack.aiops.logCategorization.emptyPromptTitle": "选择文本字段,然后单击“运行归类”以开始分析",
|
||||
"xpack.aiops.logCategorization.errorLoadingCategories": "加载类别时出错",
|
||||
"xpack.aiops.logCategorization.filterOutInDiscover": "在 Discover 中筛除",
|
||||
"xpack.aiops.logCategorization.noCategoriesBody": "确保在选定时间范围内填充所选字段。",
|
||||
"xpack.aiops.logCategorization.noCategoriesTitle": "找不到类别",
|
||||
"xpack.aiops.logCategorization.noDocsBody": "确保选定时间范围包含文档。",
|
||||
"xpack.aiops.logCategorization.noDocsTitle": "找不到文档",
|
||||
"xpack.aiops.logCategorization.runButton": "运行归类",
|
||||
"xpack.aiops.logCategorization.showInDiscover": "在 Discover 中显示这些项",
|
||||
"xpack.aiops.miniHistogram.noDataLabel": "不可用",
|
||||
"xpack.aiops.progressAriaLabel": "进度",
|
||||
"xpack.aiops.rerunAnalysisButtonTitle": "重新运行分析",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue