[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.


![65DAE59F-0B13-460E-A494-3922AB786430-89938-000086F7584D624E](https://user-images.githubusercontent.com/22172091/233629040-6226788c-3ec1-4668-9635-d5baf57cae4e.gif)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
James Gowdy 2023-04-25 16:22:09 +01:00 committed by GitHub
parent 03889a4ce5
commit 55ae242053
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1918 additions and 314 deletions

View file

@ -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()}
/>
</>
);

View file

@ -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';

View file

@ -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;
}

View file

@ -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.',
};

View file

@ -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';

View file

@ -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';

View file

@ -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);
});
});
});

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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>
</>
);
};

View file

@ -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';

View file

@ -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;
};

View file

@ -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} />;
};

View file

@ -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';

View file

@ -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();

View file

@ -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;
}

View file

@ -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,

View file

@ -34,8 +34,8 @@ export {
type FieldPopoverProps,
FieldPopoverHeader,
type FieldPopoverHeaderProps,
FieldPopoverVisualize,
type FieldPopoverVisualizeProps,
FieldPopoverFooter,
type FieldPopoverFooterProps,
} from './components/field_popover';
export {
FieldVisualizeButton,

View file

@ -12,6 +12,7 @@
"data",
"lens",
"licensing",
"uiActions",
"unifiedFieldList",
],
"requiredBundles": [

View file

@ -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
);

View file

@ -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);
},
});

View file

@ -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;
}}
/>
</>

View file

@ -6,4 +6,3 @@
*/
export { CategoryTable } from './category_table';
export type { QueryMode } from './category_table';

View file

@ -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),
},
};
}

View file

@ -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>
</>
);
};

View file

@ -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(() => {

View file

@ -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

View file

@ -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>
}

View file

@ -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>
</>
);

View file

@ -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>
</>
);
};

View file

@ -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}

View file

@ -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';

View file

@ -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;
}
}

View file

@ -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>
);
};

View file

@ -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;
};

View file

@ -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);
}
});
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { TechnicalPreviewBadge } from './technical_preview_badge';

View file

@ -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'}
/>
);
};

View file

@ -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
? {}

View file

@ -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,
},
};
}

View file

@ -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()),
};
};

View file

@ -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() {}
}

View 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 {}

View file

@ -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;

View file

@ -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/**/*",

View file

@ -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 () => {

View file

@ -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}

View file

@ -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 lanalyse",

View file

@ -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": "分析を再実行",

View file

@ -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": "重新运行分析",