[ML] [AIOps] Pattern analysis tab in Discover (#178916)

Closes https://github.com/elastic/kibana/issues/178534

Replaces the pattern analysis flyout in Discover with a tab which sits
alongside the Documents and Field statistics tabs.


![image](027f7751-c61e-4b7e-9625-dd876730ff2e)



**Field selection**
Lists all of the text fields in the index. Auto selects `message`, then
`error.message`, then `event.original` and if none of these fields are
available, it just selects the first field in the list.


![image](6ee0eb75-ed13-4c16-beb6-3de357dc182c)



The Options menu provides some configuration options:

**Minimum time range**
Sets the minimum time range used for the pattern analysis search. The
pattern matching results results will be more accurate the more data it
sees, so if the user has selected e.g. last 15mins in the time picker,
this settings will ensure a wider time range is used to improve the
accuracy of the patterns. If the time picker has a larger time range
than this setting, the larger time range will be used.

**Random sampling**
Improves the search performance by using a random sampler. This is the
same setting as before.


![image](7a2580f6-61f7-4053-9ac1-93a8e8e2f01c)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: István Zoltán Szabó <istvan.szabo@elastic.co>
This commit is contained in:
James Gowdy 2024-05-22 14:13:07 +01:00 committed by GitHub
parent 9e8bf2d8c6
commit a69f24b2af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 2655 additions and 1016 deletions

View file

@ -1,74 +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 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: () => ({}), isTimeBased: () => true } 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

@ -1,58 +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 type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { CATEGORIZE_FIELD_TRIGGER, type CategorizeFieldContext } from '@kbn/ml-ui-actions';
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 ||
!dataView.isTimeBased() ||
!field.esTypes?.includes('text')
) {
return false;
}
const actions = await getCompatibleActions(uiActions, field, dataView, CATEGORIZE_FIELD_TRIGGER);
return actions.length > 0;
}

View file

@ -1,94 +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 { 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 {
CATEGORIZE_FIELD_TRIGGER,
ACTION_CATEGORIZE_FIELD,
type CategorizeFieldContext,
} from '@kbn/ml-ui-actions';
import { 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

@ -1,58 +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 { 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

@ -1,42 +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 { 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

@ -7,16 +7,14 @@
*/
import React, { useEffect, useState } from 'react';
import { EuiPopoverFooter, EuiSpacer } from '@elastic/eui';
import { EuiPopoverFooter } from '@elastic/eui';
import { type FieldVisualizeButtonProps, getFieldVisualizeButton } from '../field_visualize_button';
import { FieldCategorizeButtonProps, getFieldCategorizeButton } from '../field_categorize_button';
import { ErrorBoundary } from '../error_boundary';
export type FieldPopoverFooterProps = FieldVisualizeButtonProps | FieldCategorizeButtonProps;
export type FieldPopoverFooterProps = FieldVisualizeButtonProps;
const FieldPopoverFooterComponent: React.FC<FieldPopoverFooterProps> = (props) => {
const [visualizeButton, setVisualizeButton] = useState<JSX.Element | null>(null);
const [categorizeButton, setCategorizeButton] = useState<JSX.Element | null>(null);
useEffect(() => {
getFieldVisualizeButton(props)
@ -25,21 +23,9 @@ const FieldPopoverFooterComponent: React.FC<FieldPopoverFooterProps> = (props) =
// eslint-disable-next-line no-console
console.error(error);
});
getFieldCategorizeButton(props)
.then(setCategorizeButton)
.catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
});
}, [props]);
return visualizeButton || categorizeButton ? (
<EuiPopoverFooter>
{visualizeButton}
{visualizeButton && categorizeButton ? <EuiSpacer size="s" /> : null}
{categorizeButton}
</EuiPopoverFooter>
) : null;
return visualizeButton ? <EuiPopoverFooter>{visualizeButton}</EuiPopoverFooter> : null;
};
export const FieldPopoverFooter: React.FC<FieldPopoverFooterProps> = (props) => {

View file

@ -309,7 +309,6 @@ function UnifiedFieldListItemComponent({
contextualFields={workspaceSelectedFieldNames}
originatingApp={stateService.creationOptions.originatingApp}
uiActions={services.uiActions}
closePopover={() => closePopover()}
/>
)}
</>

View file

@ -31,7 +31,6 @@
"@kbn/ebt-tools",
"@kbn/shared-ux-button-toolbar",
"@kbn/field-utils",
"@kbn/ml-ui-actions",
"@kbn/visualization-utils",
"@kbn/esql-utils",
"@kbn/search-types"

View file

@ -138,7 +138,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"risk-engine-configuration": "aea0c371a462e6d07c3ceb3aff11891b47feb09d",
"rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f",
"sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5",
"search": "7598e4a701ddcaa5e3f44f22e797618a48595e6f",
"search": "4579401660a4089d5122b2fc8624825cb97b0480",
"search-session": "b2fcd840e12a45039ada50b1355faeafa39876d1",
"search-telemetry": "b568601618744720b5662946d3103e3fb75fe8ee",
"security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f",

View file

@ -15,6 +15,7 @@ export const ROWS_PER_PAGE_OPTIONS = [10, 25, 50, DEFAULT_ROWS_PER_PAGE, 250, 50
export enum VIEW_MODE {
DOCUMENT_LEVEL = 'documents',
AGGREGATED_LEVEL = 'aggregated',
PATTERN_LEVEL = 'patterns',
}
export const getDefaultRowsPerPage = (uiSettings: IUiSettingsClient): number => {

View file

@ -39,7 +39,8 @@
"lens",
"noDataPage",
"globalSearch",
"observabilityAIAssistant"
"observabilityAIAssistant",
"aiops"
],
"requiredBundles": ["kibanaUtils", "kibanaReact", "unifiedSearch", "savedObjects"],
"extraPublicDirs": ["common"]

View file

@ -156,6 +156,10 @@ export function createDiscoverServicesMock(): DiscoverServices {
dataVisualizer: {
FieldStatisticsTable: jest.fn(() => createElement('div')),
},
aiops: {
getPatternAnalysisAvailable: jest.fn().mockResolvedValue(jest.fn().mockResolvedValue(true)),
PatternAnalysisComponent: jest.fn(() => createElement('div')),
},
docLinks: docLinksServiceMock.createStartContract(),
capabilities: {
visualize: {

View file

@ -87,6 +87,9 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
]);
const isEsqlMode = useIsEsqlMode();
const viewMode: VIEW_MODE = useAppStateSelector((state) => {
if (state.viewMode === VIEW_MODE.DOCUMENT_LEVEL || state.viewMode === VIEW_MODE.PATTERN_LEVEL) {
return state.viewMode;
}
if (uiSettings.get(SHOW_FIELD_STATISTICS) !== true || isEsqlMode)
return VIEW_MODE.DOCUMENT_LEVEL;
return state.viewMode ?? VIEW_MODE.DOCUMENT_LEVEL;

View file

@ -30,6 +30,7 @@ import { DocumentViewModeToggle } from '../../../../components/view_mode_toggle'
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { DiscoverDocuments } from './discover_documents';
import { FieldStatisticsTab } from '../field_stats_table';
import { PatternAnalysisTab } from '../pattern_analysis';
import { DiscoverMainProvider } from '../../state_management/discover_state_provider';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { PanelsToggle } from '../../../../components/panels_toggle';
@ -189,13 +190,22 @@ describe('Discover main content component', () => {
it('should show DiscoverDocuments when VIEW_MODE is DOCUMENT_LEVEL', async () => {
const component = await mountComponent();
expect(component.find(DiscoverDocuments).exists()).toBe(true);
expect(component.find(PatternAnalysisTab).exists()).toBe(false);
expect(component.find(FieldStatisticsTab).exists()).toBe(false);
});
it('should show FieldStatisticsTableMemoized when VIEW_MODE is not DOCUMENT_LEVEL', async () => {
it('should show FieldStatisticsTab when VIEW_MODE is AGGREGATED_LEVEL', async () => {
const component = await mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL });
expect(component.find(DiscoverDocuments).exists()).toBe(false);
expect(component.find(PatternAnalysisTab).exists()).toBe(false);
expect(component.find(FieldStatisticsTab).exists()).toBe(true);
});
it('should show PatternAnalysisTab when VIEW_MODE is PATTERN_LEVEL', async () => {
const component = await mountComponent({ viewMode: VIEW_MODE.PATTERN_LEVEL });
expect(component.find(DiscoverDocuments).exists()).toBe(false);
expect(component.find(PatternAnalysisTab).exists()).toBe(true);
expect(component.find(FieldStatisticsTab).exists()).toBe(false);
});
});
});

View file

@ -22,6 +22,8 @@ import { DiscoverDocuments } from './discover_documents';
import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stats_table/constants';
import { useAppStateSelector } from '../../state_management/discover_app_state_container';
import type { PanelsToggleProps } from '../../../../components/panels_toggle';
import { PatternAnalysisTab } from '../pattern_analysis/pattern_analysis_tab';
import { PATTERN_ANALYSIS_VIEW_CLICK } from '../pattern_analysis/constants';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
const DROP_PROPS = {
@ -60,9 +62,8 @@ export const DiscoverMainContent = ({
panelsToggle,
isChartAvailable,
}: DiscoverMainContentProps) => {
const { trackUiMetric, dataVisualizer: dataVisualizerService } = useDiscoverServices();
const { trackUiMetric } = useDiscoverServices();
const isEsqlMode = useIsEsqlMode();
const shouldShowViewModeToggle = dataVisualizerService !== undefined;
const setDiscoverViewMode = useCallback(
(mode: VIEW_MODE) => {
@ -71,6 +72,8 @@ export const DiscoverMainContent = ({
if (trackUiMetric) {
if (mode === VIEW_MODE.AGGREGATED_LEVEL) {
trackUiMetric(METRIC_TYPE.CLICK, FIELD_STATISTICS_VIEW_CLICK);
} else if (mode === VIEW_MODE.PATTERN_LEVEL) {
trackUiMetric(METRIC_TYPE.CLICK, PATTERN_ANALYSIS_VIEW_CLICK);
} else {
trackUiMetric(METRIC_TYPE.CLICK, DOCUMENTS_VIEW_CLICK);
}
@ -81,31 +84,36 @@ export const DiscoverMainContent = ({
const isDropAllowed = Boolean(onDropFieldToTable);
const viewModeToggle = useMemo(() => {
return shouldShowViewModeToggle ? (
<DocumentViewModeToggle
viewMode={viewMode}
isEsqlMode={isEsqlMode}
stateContainer={stateContainer}
setDiscoverViewMode={setDiscoverViewMode}
prepend={
React.isValidElement(panelsToggle)
? React.cloneElement(panelsToggle, { renderedFor: 'tabs', isChartAvailable })
: undefined
}
/>
) : (
<React.Fragment />
);
}, [
shouldShowViewModeToggle,
viewMode,
isEsqlMode,
stateContainer,
setDiscoverViewMode,
panelsToggle,
isChartAvailable,
]);
const renderViewModeToggle = useCallback(
(patternCount?: number) => {
return (
<DocumentViewModeToggle
viewMode={viewMode}
isEsqlMode={isEsqlMode}
stateContainer={stateContainer}
setDiscoverViewMode={setDiscoverViewMode}
patternCount={patternCount}
dataView={dataView}
prepend={
React.isValidElement(panelsToggle)
? React.cloneElement(panelsToggle, { renderedFor: 'tabs', isChartAvailable })
: undefined
}
/>
);
},
[
viewMode,
isEsqlMode,
stateContainer,
setDiscoverViewMode,
dataView,
panelsToggle,
isChartAvailable,
]
);
const viewModeToggle = useMemo(() => renderViewModeToggle(), [renderViewModeToggle]);
const showChart = useAppStateSelector((state) => !state.hideChart);
@ -133,7 +141,8 @@ export const DiscoverMainContent = ({
stateContainer={stateContainer}
onFieldEdited={!isEsqlMode ? onFieldEdited : undefined}
/>
) : (
) : null}
{viewMode === VIEW_MODE.AGGREGATED_LEVEL ? (
<>
<EuiFlexItem grow={false}>{viewModeToggle}</EuiFlexItem>
<FieldStatisticsTab
@ -144,7 +153,16 @@ export const DiscoverMainContent = ({
trackUiMetric={trackUiMetric}
/>
</>
)}
) : null}
{viewMode === VIEW_MODE.PATTERN_LEVEL ? (
<PatternAnalysisTab
dataView={dataView}
stateContainer={stateContainer}
switchToDocumentView={() => setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL)}
trackUiMetric={trackUiMetric}
renderViewModeToggle={renderViewModeToggle}
/>
) : null}
</EuiFlexGroup>
</DropOverlayWrapper>
</Droppable>

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
/** Telemetry related to field statistics table **/
export const PATTERN_ANALYSIS_LOADED = 'pattern_analysis_loaded';
export const PATTERN_ANALYSIS_VIEW_CLICK = 'pattern_analysis_view_click';

View file

@ -6,10 +6,5 @@
* Side Public License, v 1.
*/
export {
type FieldCategorizeButtonProps,
FieldCategorizeButton,
getFieldCategorizeButton,
} from './field_categorize_button';
export { triggerCategorizeActions, canCategorize } from './categorize_trigger_utils';
export { PatternAnalysisTable } from './pattern_analysis_table';
export { PatternAnalysisTab } from './pattern_analysis_tab';

View file

@ -0,0 +1,36 @@
/*
* 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, { memo, type FC } from 'react';
import { useQuerySubscriber } from '@kbn/unified-field-list/src/hooks/use_query_subscriber';
import { useSavedSearch } from '../../state_management/discover_state_provider';
import { PatternAnalysisTable, type PatternAnalysisTableProps } from './pattern_analysis_table';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
export const PatternAnalysisTab: FC<Omit<PatternAnalysisTableProps, 'query' | 'filters'>> = memo(
(props) => {
const services = useDiscoverServices();
const querySubscriberResult = useQuerySubscriber({
data: services.data,
});
const savedSearch = useSavedSearch();
return (
<PatternAnalysisTable
dataView={props.dataView}
filters={querySubscriberResult.filters}
query={querySubscriberResult.query}
switchToDocumentView={props.switchToDocumentView}
savedSearch={savedSearch}
stateContainer={props.stateContainer}
trackUiMetric={props.trackUiMetric}
renderViewModeToggle={props.renderViewModeToggle}
/>
);
}
);

View file

@ -0,0 +1,71 @@
/*
* 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, useMemo } from 'react';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import { type EmbeddablePatternAnalysisInput } from '@kbn/aiops-log-pattern-analysis/embeddable';
import { pick } from 'lodash';
import type { LogCategorizationEmbeddableProps } from '@kbn/aiops-plugin/public/components/log_categorization/log_categorization_for_embeddable/log_categorization_for_embeddable';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import type { DiscoverStateContainer } from '../../state_management/discover_state';
import { PATTERN_ANALYSIS_LOADED } from './constants';
export type PatternAnalysisTableProps = EmbeddablePatternAnalysisInput & {
stateContainer?: DiscoverStateContainer;
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
renderViewModeToggle: (patternCount?: number) => React.ReactElement;
};
export const PatternAnalysisTable = (props: PatternAnalysisTableProps) => {
const [lastReloadRequestTime, setLastReloadRequestTime] = useState<number | undefined>(undefined);
const services = useDiscoverServices();
const aiopsService = services.aiops;
const { trackUiMetric, stateContainer } = props;
useEffect(() => {
const refetch = stateContainer?.dataState.refetch$.subscribe(() => {
setLastReloadRequestTime(Date.now());
});
return () => {
refetch?.unsubscribe();
};
}, [stateContainer]);
useEffect(() => {
// Track should only be called once when component is loaded
if (aiopsService) {
trackUiMetric?.(METRIC_TYPE.LOADED, PATTERN_ANALYSIS_LOADED);
}
}, [aiopsService, trackUiMetric]);
const patternAnalysisComponentProps: LogCategorizationEmbeddableProps = useMemo(
() => ({
input: Object.assign(
{},
pick(props, ['dataView', 'savedSearch', 'query', 'filters', 'switchToDocumentView']),
{ lastReloadRequestTime }
),
renderViewModeToggle: props.renderViewModeToggle,
}),
[lastReloadRequestTime, props]
);
if (!aiopsService) {
return null;
}
return (
<aiopsService.PatternAnalysisComponent
props={patternAnalysisComponentProps}
deps={services}
embeddingOrigin="discover"
/>
);
};

View file

@ -114,6 +114,18 @@ describe('useEsqlMode', () => {
viewMode: undefined,
});
});
test('should change viewMode to undefined (default) if it was PATTERN_LEVEL', async () => {
const { replaceUrlState } = renderHookWithContext(false, {
viewMode: VIEW_MODE.PATTERN_LEVEL,
});
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(1));
expect(replaceUrlState).toHaveBeenCalledWith({
viewMode: undefined,
});
});
test('changing an ES|QL query with different result columns should change state when loading and finished', async () => {
const { replaceUrlState, stateContainer } = renderHookWithContext(false);
const documents$ = stateContainer.dataState.data$.documents$;

View file

@ -98,14 +98,23 @@ describe('getStateDefaults', () => {
});
expect(actualForUndefinedViewMode.viewMode).toBeUndefined();
const actualForEsqlWithInvalidViewMode = getStateDefaults({
const actualForEsqlWithInvalidAggLevelViewMode = getStateDefaults({
services: discoverServiceMock,
savedSearch: {
...savedSearchMockWithESQL,
viewMode: VIEW_MODE.AGGREGATED_LEVEL,
},
});
expect(actualForEsqlWithInvalidViewMode.viewMode).toBe(VIEW_MODE.DOCUMENT_LEVEL);
expect(actualForEsqlWithInvalidAggLevelViewMode.viewMode).toBe(VIEW_MODE.DOCUMENT_LEVEL);
const actualForEsqlWithInvalidPatternLevelViewMode = getStateDefaults({
services: discoverServiceMock,
savedSearch: {
...savedSearchMockWithESQL,
viewMode: VIEW_MODE.PATTERN_LEVEL,
},
});
expect(actualForEsqlWithInvalidPatternLevelViewMode.viewMode).toBe(VIEW_MODE.DOCUMENT_LEVEL);
const actualForEsqlWithValidViewMode = getStateDefaults({
services: discoverServiceMock,
@ -117,15 +126,29 @@ describe('getStateDefaults', () => {
expect(actualForEsqlWithValidViewMode.viewMode).toBe(VIEW_MODE.DOCUMENT_LEVEL);
expect(actualForEsqlWithValidViewMode.dataSource).toEqual(createEsqlDataSource());
const actualForWithValidViewMode = getStateDefaults({
const actualForWithValidAggLevelViewMode = getStateDefaults({
services: discoverServiceMock,
savedSearch: {
...savedSearchMock,
viewMode: VIEW_MODE.AGGREGATED_LEVEL,
},
});
expect(actualForWithValidViewMode.viewMode).toBe(VIEW_MODE.AGGREGATED_LEVEL);
expect(actualForWithValidViewMode.dataSource).toEqual(
expect(actualForWithValidAggLevelViewMode.viewMode).toBe(VIEW_MODE.AGGREGATED_LEVEL);
expect(actualForWithValidAggLevelViewMode.dataSource).toEqual(
createDataViewDataSource({
dataViewId: savedSearchMock.searchSource.getField('index')?.id!,
})
);
const actualForWithValidPatternLevelViewMode = getStateDefaults({
services: discoverServiceMock,
savedSearch: {
...savedSearchMock,
viewMode: VIEW_MODE.PATTERN_LEVEL,
},
});
expect(actualForWithValidPatternLevelViewMode.viewMode).toBe(VIEW_MODE.PATTERN_LEVEL);
expect(actualForWithValidPatternLevelViewMode.dataSource).toEqual(
createDataViewDataSource({
dataViewId: savedSearchMock.searchSource.getField('index')?.id!,
})

View file

@ -31,6 +31,13 @@ describe('getValidViewMode', () => {
isEsqlMode: false,
})
).toBe(VIEW_MODE.AGGREGATED_LEVEL);
expect(
getValidViewMode({
viewMode: VIEW_MODE.PATTERN_LEVEL,
isEsqlMode: false,
})
).toBe(VIEW_MODE.PATTERN_LEVEL);
});
test('should work correctly for ES|QL mode', () => {
@ -54,5 +61,12 @@ describe('getValidViewMode', () => {
isEsqlMode: true,
})
).toBe(VIEW_MODE.DOCUMENT_LEVEL);
expect(
getValidViewMode({
viewMode: VIEW_MODE.PATTERN_LEVEL,
isEsqlMode: true,
})
).toBe(VIEW_MODE.DOCUMENT_LEVEL);
});
});

View file

@ -20,8 +20,11 @@ export const getValidViewMode = ({
viewMode?: VIEW_MODE;
isEsqlMode: boolean;
}): VIEW_MODE | undefined => {
if (viewMode === VIEW_MODE.AGGREGATED_LEVEL && isEsqlMode) {
// only this mode is supported for ES|QL languages
if (
(viewMode === VIEW_MODE.PATTERN_LEVEL || viewMode === VIEW_MODE.AGGREGATED_LEVEL) &&
isEsqlMode
) {
// only this mode is supported for text-based languages
return VIEW_MODE.DOCUMENT_LEVEL;
}

View file

@ -57,6 +57,7 @@ import type { ContentClient } from '@kbn/content-management-plugin/public';
import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public';
import { memoize, noop } from 'lodash';
import type { NoDataPagePluginStart } from '@kbn/no-data-page-plugin/public';
import type { AiopsPluginStart } from '@kbn/aiops-plugin/public';
import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public';
import { DiscoverStartPlugins } from './plugin';
import { DiscoverContextAppLocator } from './application/context/services/locator';
@ -77,6 +78,7 @@ export interface UrlTracker {
}
export interface DiscoverServices {
aiops?: AiopsPluginStart;
application: ApplicationStart;
addBasePath: (path: string) => string;
analytics: AnalyticsServiceStart;
@ -157,6 +159,7 @@ export const buildServices = memoize(
const storage = new Storage(localStorage);
return {
aiops: plugins.aiops,
application: core.application,
addBasePath: core.http.basePath.prepend,
analytics: core.analytics,

View file

@ -12,84 +12,153 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test';
import type { DataView } from '@kbn/data-views-plugin/common';
import { DocumentViewModeToggle } from './view_mode_toggle';
import { BehaviorSubject } from 'rxjs';
import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock';
import { DataTotalHits$ } from '../../application/main/state_management/discover_data_state_container';
import { FetchStatus } from '../../application/types';
import { ES_FIELD_TYPES } from '@kbn/field-types';
import { discoverServiceMock } from '../../__mocks__/services';
import { act } from 'react-dom/test-utils';
describe('Document view mode toggle component', () => {
const mountComponent = ({
const mountComponent = async ({
showFieldStatistics = true,
viewMode = VIEW_MODE.DOCUMENT_LEVEL,
isEsqlMode = false,
setDiscoverViewMode = jest.fn(),
useDataViewWithTextFields = true,
} = {}) => {
const services = {
...discoverServiceMock,
uiSettings: {
get: () => showFieldStatistics,
},
aiops: {
getPatternAnalysisAvailable: jest
.fn()
.mockResolvedValue(jest.fn().mockResolvedValue(useDataViewWithTextFields)),
},
};
const dataViewWithTextFields = {
fields: [
{
name: 'field1',
esTypes: [ES_FIELD_TYPES.TEXT],
},
],
} as unknown as DataView;
const dataViewWithoutTextFields = {
fields: [
{
name: 'field1',
esTypes: [ES_FIELD_TYPES.FLOAT],
},
],
} as unknown as DataView;
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: 10,
}) as DataTotalHits$;
return mountWithIntl(
const component = mountWithIntl(
<KibanaContextProvider services={services}>
<DocumentViewModeToggle
viewMode={viewMode}
isEsqlMode={isEsqlMode}
stateContainer={stateContainer}
setDiscoverViewMode={setDiscoverViewMode}
dataView={useDataViewWithTextFields ? dataViewWithTextFields : dataViewWithoutTextFields}
/>
</KibanaContextProvider>
);
await act(async () => {
component.update();
});
component!.update();
return component!;
};
it('should render if SHOW_FIELD_STATISTICS is true', () => {
const component = mountComponent();
it('should render if SHOW_FIELD_STATISTICS is true', async () => {
const component = await mountComponent();
expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(true);
expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true);
expect(findTestSubject(component, 'dscViewModeDocumentButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscViewModePatternAnalysisButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscViewModeFieldStatsButton').exists()).toBe(true);
});
it('should not render if SHOW_FIELD_STATISTICS is false', () => {
const component = mountComponent({ showFieldStatistics: false });
it('should not render if SHOW_FIELD_STATISTICS is false', async () => {
const component = await mountComponent({ showFieldStatistics: false });
expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(true);
expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true);
expect(findTestSubject(component, 'dscViewModeDocumentButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscViewModePatternAnalysisButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscViewModeFieldStatsButton').exists()).toBe(false);
});
it('should not render if ES|QL', async () => {
const component = await mountComponent({ isEsqlMode: true });
expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(false);
expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true);
expect(findTestSubject(component, 'dscViewModeDocumentButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscViewModePatternAnalysisButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscViewModeFieldStatsButton').exists()).toBe(false);
});
it('should not render if ES|QL', () => {
const component = mountComponent({ isEsqlMode: true });
expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(false);
expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true);
});
it('should set the view mode to VIEW_MODE.DOCUMENT_LEVEL when dscViewModeDocumentButton is clicked', () => {
it('should set the view mode to VIEW_MODE.DOCUMENT_LEVEL when dscViewModeDocumentButton is clicked', async () => {
const setDiscoverViewMode = jest.fn();
const component = mountComponent({ setDiscoverViewMode });
const component = await mountComponent({ setDiscoverViewMode });
component.find('[data-test-subj="dscViewModeDocumentButton"]').at(0).simulate('click');
expect(setDiscoverViewMode).toHaveBeenCalledWith(VIEW_MODE.DOCUMENT_LEVEL);
});
it('should set the view mode to VIEW_MODE.AGGREGATED_LEVEL when dscViewModeFieldStatsButton is clicked', () => {
it('should set the view mode to VIEW_MODE.PATTERN_LEVEL when dscViewModePatternAnalysisButton is clicked', async () => {
const setDiscoverViewMode = jest.fn();
const component = mountComponent({ setDiscoverViewMode });
const component = await mountComponent({ setDiscoverViewMode });
component.find('[data-test-subj="dscViewModePatternAnalysisButton"]').at(0).simulate('click');
expect(setDiscoverViewMode).toHaveBeenCalledWith(VIEW_MODE.PATTERN_LEVEL);
});
it('should set the view mode to VIEW_MODE.AGGREGATED_LEVEL when dscViewModeFieldStatsButton is clicked', async () => {
const setDiscoverViewMode = jest.fn();
const component = await mountComponent({ setDiscoverViewMode });
component.find('[data-test-subj="dscViewModeFieldStatsButton"]').at(0).simulate('click');
expect(setDiscoverViewMode).toHaveBeenCalledWith(VIEW_MODE.AGGREGATED_LEVEL);
});
it('should select the Documents tab if viewMode is VIEW_MODE.DOCUMENT_LEVEL', () => {
const component = mountComponent();
it('should select the Documents tab if viewMode is VIEW_MODE.DOCUMENT_LEVEL', async () => {
const component = await mountComponent();
expect(component.find(EuiTab).at(0).prop('isSelected')).toBe(true);
});
it('should select the Field statistics tab if viewMode is VIEW_MODE.AGGREGATED_LEVEL', () => {
const component = mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL });
it('should select the Pattern Analysis tab if viewMode is VIEW_MODE.PATTERN_LEVEL', async () => {
const component = await mountComponent({ viewMode: VIEW_MODE.PATTERN_LEVEL });
expect(component.find(EuiTab).at(1).prop('isSelected')).toBe(true);
});
it('should select the Field statistics tab if viewMode is VIEW_MODE.AGGREGATED_LEVEL', async () => {
const component = await mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL });
expect(component.find(EuiTab).at(2).prop('isSelected')).toBe(true);
});
it('should switch to document and hide pattern tab when there are no text fields', async () => {
const setDiscoverViewMode = jest.fn();
const component = await mountComponent({
viewMode: VIEW_MODE.PATTERN_LEVEL,
useDataViewWithTextFields: false,
setDiscoverViewMode,
});
expect(setDiscoverViewMode).toHaveBeenCalledWith(VIEW_MODE.DOCUMENT_LEVEL);
expect(component.find(EuiTab).length).toBe(2);
});
});

View file

@ -6,14 +6,16 @@
* Side Public License, v 1.
*/
import React, { useMemo, ReactElement } from 'react';
import React, { useMemo, useEffect, useState, type ReactElement, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { isLegacyTableEnabled, SHOW_FIELD_STATISTICS } from '@kbn/discover-utils';
import type { DataView } from '@kbn/data-views-plugin/common';
import useMountedState from 'react-use/lib/useMountedState';
import { VIEW_MODE } from '../../../common/constants';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import { DiscoverStateContainer } from '../../application/main/state_management/discover_state';
import type { DiscoverStateContainer } from '../../application/main/state_management/discover_state';
import { HitsCounter, HitsCounterMode } from '../hits_counter';
export const DocumentViewModeToggle = ({
@ -22,20 +24,70 @@ export const DocumentViewModeToggle = ({
prepend,
stateContainer,
setDiscoverViewMode,
patternCount,
dataView,
}: {
viewMode: VIEW_MODE;
isEsqlMode: boolean;
prepend?: ReactElement;
stateContainer: DiscoverStateContainer;
setDiscoverViewMode: (viewMode: VIEW_MODE) => void;
patternCount?: number;
dataView: DataView;
}) => {
const { euiTheme } = useEuiTheme();
const { uiSettings, dataVisualizer: dataVisualizerService } = useDiscoverServices();
const {
uiSettings,
dataVisualizer: dataVisualizerService,
aiops: aiopsService,
} = useDiscoverServices();
const isLegacy = useMemo(
() => isLegacyTableEnabled({ uiSettings, isEsqlMode }),
[uiSettings, isEsqlMode]
);
const includesNormalTabsStyle = viewMode === VIEW_MODE.AGGREGATED_LEVEL || isLegacy;
const [showPatternAnalysisTab, setShowPatternAnalysisTab] = useState<boolean | null>(null);
const showFieldStatisticsTab = useMemo(
() => uiSettings.get(SHOW_FIELD_STATISTICS) && dataVisualizerService !== undefined,
[dataVisualizerService, uiSettings]
);
const isMounted = useMountedState();
const setShowPatternAnalysisTabWrapper = useCallback(
(value: boolean) => {
if (isMounted()) {
setShowPatternAnalysisTab(value);
}
},
[isMounted]
);
useEffect(
function checkForPatternAnalysis() {
if (!aiopsService) {
setShowPatternAnalysisTab(false);
return;
}
aiopsService
.getPatternAnalysisAvailable()
.then((patternAnalysisAvailable) => {
patternAnalysisAvailable(dataView)
.then(setShowPatternAnalysisTabWrapper)
.catch(() => setShowPatternAnalysisTabWrapper(false));
})
.catch(() => setShowPatternAnalysisTabWrapper(false));
},
[aiopsService, dataView, setShowPatternAnalysisTabWrapper]
);
useEffect(() => {
if (showPatternAnalysisTab === false && viewMode === VIEW_MODE.PATTERN_LEVEL) {
// switch to document view if no text fields are available
setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL);
}
}, [showPatternAnalysisTab, viewMode, setDiscoverViewMode]);
const includesNormalTabsStyle =
viewMode === VIEW_MODE.AGGREGATED_LEVEL || viewMode === VIEW_MODE.PATTERN_LEVEL || isLegacy;
const containerPadding = includesNormalTabsStyle ? euiTheme.size.s : 0;
const containerCss = css`
@ -48,9 +100,6 @@ export const DocumentViewModeToggle = ({
}
`;
const showViewModeToggle =
(uiSettings.get(SHOW_FIELD_STATISTICS) && dataVisualizerService !== undefined) ?? false;
return (
<EuiFlexGroup
direction="row"
@ -72,7 +121,7 @@ export const DocumentViewModeToggle = ({
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
{isEsqlMode || !showViewModeToggle ? (
{isEsqlMode || (showFieldStatisticsTab === false && showPatternAnalysisTab === false) ? (
<HitsCounter mode={HitsCounterMode.standalone} stateContainer={stateContainer} />
) : (
<EuiTabs size="m" css={tabsCss} data-test-subj="dscViewModeToggle" bottomBorder={false}>
@ -84,16 +133,33 @@ export const DocumentViewModeToggle = ({
<FormattedMessage id="discover.viewModes.document.label" defaultMessage="Documents" />
<HitsCounter mode={HitsCounterMode.appended} stateContainer={stateContainer} />
</EuiTab>
<EuiTab
isSelected={viewMode === VIEW_MODE.AGGREGATED_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)}
data-test-subj="dscViewModeFieldStatsButton"
>
<FormattedMessage
id="discover.viewModes.fieldStatistics.label"
defaultMessage="Field statistics"
/>
</EuiTab>
{showPatternAnalysisTab ? (
<EuiTab
isSelected={viewMode === VIEW_MODE.PATTERN_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.PATTERN_LEVEL)}
data-test-subj="dscViewModePatternAnalysisButton"
>
<FormattedMessage
id="discover.viewModes.patternAnalysis.label"
defaultMessage="Patterns {patternCount}"
values={{ patternCount: patternCount !== undefined ? ` (${patternCount})` : '' }}
/>
</EuiTab>
) : null}
{showFieldStatisticsTab ? (
<EuiTab
isSelected={viewMode === VIEW_MODE.AGGREGATED_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)}
data-test-subj="dscViewModeFieldStatsButton"
>
<FormattedMessage
id="discover.viewModes.fieldStatistics.label"
defaultMessage="Field statistics"
/>
</EuiTab>
) : null}
</EuiTabs>
)}
</EuiFlexItem>

View file

@ -51,6 +51,7 @@ import type {
ObservabilityAIAssistantPublicSetup,
ObservabilityAIAssistantPublicStart,
} from '@kbn/observability-ai-assistant-plugin/public';
import type { AiopsPluginStart } from '@kbn/aiops-plugin/public';
import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public';
import { PLUGIN_ID } from '../common';
import { registerFeature } from './register_feature';
@ -179,6 +180,7 @@ export interface DiscoverSetupPlugins {
* @internal
*/
export interface DiscoverStartPlugins {
aiops?: AiopsPluginStart;
dataViews: DataViewsServicePublic;
dataViewEditor: DataViewEditorStart;
dataVisualizer?: DataVisualizerPluginStart;

View file

@ -84,12 +84,14 @@
"@kbn/shared-ux-markdown",
"@kbn/data-view-utils",
"@kbn/presentation-publishing",
"@kbn/aiops-log-pattern-analysis",
"@kbn/field-types",
"@kbn/elastic-agent-utils",
"@kbn/custom-icons",
"@kbn/observability-ai-assistant-plugin",
"@kbn/aiops-plugin",
"@kbn/data-visualizer-plugin",
"@kbn/search-types",
"@kbn/custom-icons",
"@kbn/observability-ai-assistant-plugin"
],
"exclude": ["target/**/*"]

View file

@ -42,7 +42,11 @@ const savedSearchAttributesSchema = schema.object(
searchSourceJSON: schema.string(),
}),
viewMode: schema.maybe(
schema.oneOf([schema.literal('documents'), schema.literal('aggregated')])
schema.oneOf([
schema.literal('documents'),
schema.literal('patterns'),
schema.literal('aggregated'),
])
),
hideAggregatedPreview: schema.maybe(schema.boolean()),
rowHeight: schema.maybe(schema.number()),

View file

@ -19,6 +19,7 @@ export type {
export enum VIEW_MODE {
DOCUMENT_LEVEL = 'documents',
AGGREGATED_LEVEL = 'aggregated',
PATTERN_LEVEL = 'patterns',
}
export {

View file

@ -119,3 +119,13 @@ export const SCHEMA_SEARCH_MODEL_VERSION_3 = SCHEMA_SEARCH_MODEL_VERSION_2.exten
])
),
});
export const SCHEMA_SEARCH_MODEL_VERSION_4 = SCHEMA_SEARCH_MODEL_VERSION_3.extends({
viewMode: schema.maybe(
schema.oneOf([
schema.literal(VIEW_MODE.DOCUMENT_LEVEL),
schema.literal(VIEW_MODE.PATTERN_LEVEL),
schema.literal(VIEW_MODE.AGGREGATED_LEVEL),
])
),
});

View file

@ -15,6 +15,7 @@ import {
SCHEMA_SEARCH_MODEL_VERSION_1,
SCHEMA_SEARCH_MODEL_VERSION_2,
SCHEMA_SEARCH_MODEL_VERSION_3,
SCHEMA_SEARCH_MODEL_VERSION_4,
} from './schema';
export function getSavedSearchObjectType(
@ -62,6 +63,13 @@ export function getSavedSearchObjectType(
create: SCHEMA_SEARCH_MODEL_VERSION_3,
},
},
4: {
changes: [],
schemas: {
forwardCompatibility: SCHEMA_SEARCH_MODEL_VERSION_4.extends({}, { unknowns: 'ignore' }),
create: SCHEMA_SEARCH_MODEL_VERSION_4,
},
},
},
mappings: {
dynamic: false,

View file

@ -88,6 +88,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.missingOrFail('discoverErrorCalloutTitle');
});
it('should not show Patterns tab (basic license)', async () => {
await testSubjects.missingOrFail('dscViewModePatternAnalysisButton');
await retry.try(async () => {
const documentTab = await testSubjects.find('dscViewModeDocumentButton');
expect(await documentTab.getAttribute('aria-selected')).to.be('true');
});
});
it('should show Field Statistics tab', async () => {
await testSubjects.click('dscViewModeFieldStatsButton');

View file

@ -32,7 +32,8 @@ export function createCategoryRequest(
wrap: ReturnType<typeof createRandomSamplerWrapper>['wrap'],
intervalMs?: number,
additionalFilter?: CategorizationAdditionalFilter,
useStandardTokenizer: boolean = true
useStandardTokenizer: boolean = true,
includeSparkline: boolean = true
) {
const query = createCategorizeQuery(queryIn, timeField, timeRange);
const aggs = {
@ -50,7 +51,7 @@ export function createCategoryRequest(
_source: field,
},
},
...(intervalMs
...(intervalMs && includeSparkline
? {
sparkline: {
date_histogram: {
@ -76,6 +77,16 @@ export function createCategoryRequest(
_source: field,
},
},
...(intervalMs
? {
sparkline: {
date_histogram: {
field: timeField,
fixed_interval: `${intervalMs}ms`,
},
},
}
: {}),
...(additionalFilter.field
? {
sub_field: {

View file

@ -0,0 +1,20 @@
/*
* 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 { Query, AggregateQuery, Filter } from '@kbn/es-query';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
export interface EmbeddablePatternAnalysisInput<T = Query | AggregateQuery> {
dataView: DataView;
savedSearch?: SavedSearch | null;
query?: T;
filters?: Filter[];
embeddingOrigin?: string;
switchToDocumentView?: () => void;
lastReloadRequestTime?: number;
}

View file

@ -11,7 +11,7 @@ import type { estypes } from '@elastic/elasticsearch';
import type { createRandomSamplerWrapper } from '@kbn/ml-random-sampler-utils';
import type { Category, CategoriesAgg, CatResponse } from './types';
import type { Category, CategoriesAgg, CatResponse, Sparkline } from './types';
export function processCategoryResults(
result: CatResponse,
@ -29,24 +29,17 @@ export function processCategoryResults(
) as CategoriesAgg;
const categories: Category[] = buckets.map((b) => {
const sparkline =
b.sparkline === undefined
? {}
: b.sparkline.buckets.reduce<Record<number, number>>((acc2, cur2) => {
acc2[cur2.key] = cur2.doc_count;
return acc2;
}, {});
return {
key: b.key,
count: b.doc_count,
examples: b.examples.hits.hits.map((h) => get(h._source, field)),
sparkline,
sparkline: getSparkline(b.sparkline),
subTimeRangeCount: b.sub_time_range?.buckets[0].doc_count ?? undefined,
subFieldCount: b.sub_time_range?.buckets[0].sub_field?.doc_count ?? undefined,
subFieldExamples:
b.sub_time_range?.buckets[0].examples.hits.hits.map((h) => get(h._source, field)) ??
undefined,
subFieldSparkline: getSparkline(b.sub_time_range?.buckets[0].sparkline),
regex: b.regex,
};
});
@ -59,3 +52,12 @@ export function processCategoryResults(
hasExamples,
};
}
function getSparkline(sparkline?: Sparkline) {
return sparkline === undefined
? {}
: sparkline.buckets.reduce<Record<number, number>>((acc, cur) => {
acc[cur.key] = cur.doc_count;
return acc;
}, {});
}

View file

@ -20,5 +20,8 @@
"@kbn/config-schema",
"@kbn/i18n",
"@kbn/ml-runtime-field-utils",
"@kbn/es-query",
"@kbn/saved-search-plugin",
"@kbn/data-views-plugin",
]
}

View file

@ -13,6 +13,7 @@ export interface Category {
subTimeRangeCount?: number;
subFieldCount?: number;
subFieldExamples?: string[];
subFieldSparkline?: Record<number, number>;
examples: string[];
sparkline?: Record<number, number>;
regex: string;
@ -22,6 +23,10 @@ interface CategoryExamples {
hits: { hits: Array<{ _source: { message: string } }> };
}
export interface Sparkline {
buckets: Array<{ key_as_string: string; key: number; doc_count: number }>;
}
export interface CategoriesAgg {
categories: {
buckets: Array<{
@ -29,9 +34,7 @@ export interface CategoriesAgg {
doc_count: number;
examples: CategoryExamples;
regex: string;
sparkline: {
buckets: Array<{ key_as_string: string; key: number; doc_count: number }>;
};
sparkline: Sparkline;
sub_time_range?: {
buckets: Array<{
key: number;
@ -44,6 +47,7 @@ export interface CategoriesAgg {
doc_count: number;
};
examples: CategoryExamples;
sparkline: Sparkline;
}>;
};
}>;

View file

@ -5,6 +5,7 @@
* 2.0.
*/
export { getTimeFieldRange } from './src/services/time_field_range';
export {
DatePickerContextProvider,
type DatePickerDependencies,

View file

@ -40,6 +40,8 @@ interface GetTimeFieldRangeOptions {
* API path ('/internal/file_upload/time_field_range')
*/
path: string;
signal?: AbortSignal;
}
/**
@ -48,12 +50,13 @@ interface GetTimeFieldRangeOptions {
* @returns GetTimeFieldRangeResponse
*/
export async function getTimeFieldRange(options: GetTimeFieldRangeOptions) {
const { http, path, ...body } = options;
const { http, path, signal, ...body } = options;
return await http.fetch<GetTimeFieldRangeResponse>({
path,
method: 'POST',
body: JSON.stringify(body),
version: '1',
...(signal ? { signal } : {}),
});
}

View file

@ -7,134 +7,73 @@
import type { FC } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import moment from 'moment';
import type { EuiBasicTableColumn, EuiTableSelectionType } from '@elastic/eui';
import {
useEuiBackgroundColor,
EuiInMemoryTable,
EuiHorizontalRule,
EuiSpacer,
EuiButtonIcon,
EuiToolTip,
EuiIcon,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { TimefilterContract } from '@kbn/data-plugin/public';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import type { Filter } from '@kbn/es-query';
import { useTableState } from '@kbn/ml-in-memory-table';
import type { CategorizationAdditionalFilter } from '@kbn/aiops-log-pattern-analysis/create_category_request';
import { type QueryMode, QUERY_MODE } from '@kbn/aiops-log-pattern-analysis/get_category_query';
import type { UseTableState } from '@kbn/ml-in-memory-table';
import { css } from '@emotion/react';
import { QUERY_MODE } from '@kbn/aiops-log-pattern-analysis/get_category_query';
import type { Category } from '@kbn/aiops-log-pattern-analysis/types';
import { useEuiTheme } from '../../../hooks/use_eui_theme';
import type { LogCategorizationAppState } from '../../../application/url_state/log_pattern_analysis';
import { MiniHistogram } from '../../mini_histogram';
import { useDiscoverLinks, createFilter } from '../use_discover_links';
import type { EventRate } from '../use_categorize_request';
import { getLabels } from './labels';
import { TableHeader } from './table_header';
import { ExpandedRow } from './expanded_row';
import { FormattedPatternExamples, FormattedTokens } from '../format_category';
import type { OpenInDiscover } from './use_open_in_discover';
interface Props {
categories: Category[];
eventRate: EventRate;
dataViewId: string;
selectedField: DataViewField | string | undefined;
timefilter: TimefilterContract;
aiopsListState: LogCategorizationAppState;
pinnedCategory: Category | null;
setPinnedCategory: (category: Category | null) => void;
selectedCategory: Category | null;
setSelectedCategory: (category: Category | null) => void;
onAddFilter?: (values: Filter, alias?: string) => void;
onClose?: () => void;
highlightedCategory: Category | null;
setHighlightedCategory: (category: Category | null) => void;
setSelectedCategories: (category: Category[]) => void;
openInDiscover: OpenInDiscover;
tableState: UseTableState<Category>;
enableRowActions?: boolean;
additionalFilter?: CategorizationAdditionalFilter;
navigateToDiscover?: boolean;
displayExamples?: boolean;
}
export const CategoryTable: FC<Props> = ({
categories,
eventRate,
dataViewId,
selectedField,
timefilter,
aiopsListState,
pinnedCategory,
setPinnedCategory,
selectedCategory,
setSelectedCategory,
onAddFilter,
onClose = () => {},
highlightedCategory,
setHighlightedCategory,
setSelectedCategories,
openInDiscover,
tableState,
enableRowActions = true,
additionalFilter,
navigateToDiscover = true,
displayExamples = true,
}) => {
const euiTheme = useEuiTheme();
const primaryBackgroundColor = useEuiBackgroundColor('primary');
const { openInDiscoverWithFilter } = useDiscoverLinks();
const [selectedCategories, setSelectedCategories] = useState<Category[]>([]);
const { onTableChange, pagination, sorting } = useTableState<Category>(categories ?? [], 'key');
const { onTableChange, pagination, sorting } = tableState;
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, JSX.Element>>(
{}
);
const labels = useMemo(() => {
const isFlyout = onAddFilter !== undefined && onClose !== undefined;
return getLabels(isFlyout && navigateToDiscover === false);
}, [navigateToDiscover, onAddFilter, onClose]);
const showSparkline = useMemo(() => {
return categories.some((category) => category.sparkline !== undefined);
}, [categories]);
const openInDiscover = (mode: QueryMode, category?: Category) => {
if (
onAddFilter !== undefined &&
selectedField !== undefined &&
typeof selectedField !== 'string' &&
navigateToDiscover === false
) {
onAddFilter(
createFilter('', selectedField.name, selectedCategories, mode, category),
`Patterns - ${selectedField.name}`
);
onClose();
return;
}
const timefilterActiveBounds =
additionalFilter !== undefined
? {
min: moment(additionalFilter.from),
max: moment(additionalFilter.to),
}
: timefilter.getActiveBounds();
if (timefilterActiveBounds === undefined || selectedField === undefined) {
return;
}
openInDiscoverWithFilter(
dataViewId,
typeof selectedField === 'string' ? selectedField : selectedField.name,
selectedCategories,
aiopsListState,
timefilterActiveBounds,
mode,
category,
additionalFilter?.field
);
};
const { labels: openInDiscoverLabels, openFunction: openInDiscoverFunction } = openInDiscover;
const toggleDetails = useCallback(
(category: Category) => {
@ -197,20 +136,20 @@ export const CategoryTable: FC<Props> = ({
width: '65px',
actions: [
{
name: labels.singleSelect.in,
description: labels.singleSelect.in,
name: openInDiscoverLabels.singleSelect.in,
description: openInDiscoverLabels.singleSelect.in,
icon: 'plusInCircle',
type: 'icon',
'data-test-subj': 'aiopsLogPatternsActionFilterInButton',
onClick: (category) => openInDiscover(QUERY_MODE.INCLUDE, category),
onClick: (category) => openInDiscoverFunction(QUERY_MODE.INCLUDE, category),
},
{
name: labels.singleSelect.out,
description: labels.singleSelect.out,
name: openInDiscoverLabels.singleSelect.out,
description: openInDiscoverLabels.singleSelect.out,
icon: 'minusInCircle',
type: 'icon',
'data-test-subj': 'aiopsLogPatternsActionFilterOutButton',
onClick: (category) => openInDiscover(QUERY_MODE.EXCLUDE, category),
onClick: (category) => openInDiscoverFunction(QUERY_MODE.EXCLUDE, category),
},
],
},
@ -291,7 +230,7 @@ export const CategoryTable: FC<Props> = ({
};
}
if (selectedCategory && selectedCategory.key === category.key) {
if (highlightedCategory && highlightedCategory.key === category.key) {
return {
backgroundColor: euiTheme.euiColorLightestShade,
};
@ -302,49 +241,49 @@ export const CategoryTable: FC<Props> = ({
};
};
return (
<>
<TableHeader
categoriesCount={categories.length}
selectedCategoriesCount={selectedCategories.length}
labels={labels}
openInDiscover={(queryMode: QueryMode) => openInDiscover(queryMode)}
/>
<EuiSpacer size="xs" />
<EuiHorizontalRule margin="none" />
const tableStyle = css({
thead: {
position: 'sticky',
insetBlockStart: 0,
zIndex: 1,
backgroundColor: euiTheme.euiColorEmptyShade,
boxShadow: `inset 0 0px 0, inset 0 -1px 0 ${euiTheme.euiBorderColor}`,
},
});
<EuiInMemoryTable<Category>
compressed
items={categories}
columns={columns}
selection={selectionValue}
itemId="key"
onTableChange={onTableChange}
pagination={pagination}
sorting={sorting}
data-test-subj="aiopsLogPatternsTable"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
rowProps={(category) => {
return enableRowActions
? {
onClick: () => {
if (category.key === pinnedCategory?.key) {
setPinnedCategory(null);
} else {
setPinnedCategory(category);
}
},
onMouseEnter: () => {
setSelectedCategory(category);
},
onMouseLeave: () => {
setSelectedCategory(null);
},
style: getRowStyle(category),
}
: undefined;
}}
/>
</>
return (
<EuiInMemoryTable<Category>
compressed
items={categories}
columns={columns}
selection={selectionValue}
itemId="key"
onTableChange={onTableChange}
pagination={pagination}
sorting={sorting}
data-test-subj="aiopsLogPatternsTable"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
css={tableStyle}
rowProps={(category) => {
return enableRowActions
? {
onClick: () => {
if (category.key === pinnedCategory?.key) {
setPinnedCategory(null);
} else {
setPinnedCategory(category);
}
},
onMouseEnter: () => {
setHighlightedCategory(category);
},
onMouseLeave: () => {
setHighlightedCategory(null);
},
style: getRowStyle(category),
}
: undefined;
}}
/>
);
};

View file

@ -5,76 +5,103 @@
* 2.0.
*/
import type { FC } from 'react';
import type { FC, PropsWithChildren } from 'react';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { type QueryMode, QUERY_MODE } from '@kbn/aiops-log-pattern-analysis/get_category_query';
import { QUERY_MODE } from '@kbn/aiops-log-pattern-analysis/get_category_query';
import { useEuiTheme } from '../../../hooks/use_eui_theme';
import type { getLabels } from './labels';
import type { OpenInDiscover } from './use_open_in_discover';
interface Props {
categoriesCount: number;
selectedCategoriesCount: number;
labels: ReturnType<typeof getLabels>;
openInDiscover: (mode: QueryMode) => void;
openInDiscover: OpenInDiscover;
}
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" data-test-subj="aiopsLogPatternsFoundCount">
<FormattedMessage
id="xpack.aiops.logCategorization.counts"
defaultMessage="{count} {count, plural, one {pattern} other {patterns}} found"
values={{ count: categoriesCount }}
/>
{selectedCategoriesCount > 0 ? (
<>
<FormattedMessage
id="xpack.aiops.logCategorization.selectedCounts"
defaultMessage=" | {count} selected"
values={{ count: selectedCategoriesCount }}
/>
</>
) : null}
</EuiText>
<EuiFlexGroup gutterSize="none" alignItems="center" css={{ minHeight: euiTheme.euiSizeXL }}>
<EuiFlexItem>
<EuiText size="s" data-test-subj="aiopsLogPatternsFoundCount">
<FormattedMessage
id="xpack.aiops.logCategorization.counts"
defaultMessage="{count} {count, plural, one {pattern} other {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}>
<OpenInDiscoverButtons openInDiscover={openInDiscover} />
</EuiFlexItem>
{selectedCategoriesCount > 0 ? (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="aiopsLogPatternAnalysisOpenInDiscoverIncludeButton"
size="s"
onClick={() => openInDiscover(QUERY_MODE.INCLUDE)}
iconType="plusInCircle"
iconSide="left"
>
{labels.multiSelect.in}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="aiopsLogPatternAnalysisOpenInDiscoverExcludeButton"
size="s"
onClick={() => openInDiscover(QUERY_MODE.EXCLUDE)}
iconType="minusInCircle"
iconSide="left"
>
{labels.multiSelect.out}
</EuiButtonEmpty>
</EuiFlexItem>
</>
) : null}
</EuiFlexGroup>
</>
) : null}
</EuiFlexGroup>
);
};
export const OpenInDiscoverButtons: FC<{ openInDiscover: OpenInDiscover; showText?: boolean }> = ({
openInDiscover,
showText = true,
}) => {
const { labels, openFunction } = openInDiscover;
return (
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
<TooltipWrapper text={labels.multiSelect.in} showText={showText}>
<EuiButtonEmpty
data-test-subj="aiopsLogPatternAnalysisOpenInDiscoverIncludeButton"
size="s"
onClick={() => openFunction(QUERY_MODE.INCLUDE)}
iconType="plusInCircle"
iconSide="left"
>
{labels.multiSelect.in}
</EuiButtonEmpty>
</TooltipWrapper>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TooltipWrapper text={labels.multiSelect.out} showText={showText}>
<EuiButtonEmpty
data-test-subj="aiopsLogPatternAnalysisOpenInDiscoverExcludeButton"
size="s"
onClick={() => openFunction(QUERY_MODE.EXCLUDE)}
iconType="minusInCircle"
iconSide="left"
>
{labels.multiSelect.out}
</EuiButtonEmpty>
</TooltipWrapper>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const TooltipWrapper: FC<PropsWithChildren<{ text: string; showText: boolean }>> = ({
text,
showText,
children,
}) => {
return showText ? (
<>{children}</>
) : (
<EuiToolTip content={text}>
<>{children}</>
</EuiToolTip>
);
};

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useMemo } from 'react';
import moment from 'moment';
import { type QueryMode } from '@kbn/aiops-log-pattern-analysis/get_category_query';
import type { Filter } from '@kbn/es-query';
import type { Category } from '@kbn/aiops-log-pattern-analysis/types';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import type { TimefilterContract } from '@kbn/data-plugin/public';
import type { CategorizationAdditionalFilter } from '@kbn/aiops-log-pattern-analysis/create_category_request';
import { useDiscoverLinks, createFilter } from '../use_discover_links';
import type { LogCategorizationAppState } from '../../../application/url_state/log_pattern_analysis';
import { getLabels } from './labels';
export interface OpenInDiscover {
openFunction: (mode: QueryMode, category?: Category) => void;
labels: ReturnType<typeof getLabels>;
count: number;
}
export function useOpenInDiscover(
dataViewId: string,
selectedField: DataViewField | string | undefined,
selectedCategories: Category[],
aiopsListState: LogCategorizationAppState,
timefilter: TimefilterContract,
navigateToDiscover?: boolean,
onAddFilter?: (values: Filter, alias?: string) => void,
additionalFilter?: CategorizationAdditionalFilter,
onClose: () => void = () => {}
): OpenInDiscover {
const { openInDiscoverWithFilter } = useDiscoverLinks();
const openFunction = useCallback(
(mode: QueryMode, category?: Category) => {
if (
onAddFilter !== undefined &&
selectedField !== undefined &&
typeof selectedField !== 'string' &&
navigateToDiscover === false
) {
onAddFilter(
createFilter('', selectedField.name, selectedCategories, mode, category),
`Patterns - ${selectedField.name}`
);
onClose();
return;
}
const timefilterActiveBounds =
additionalFilter !== undefined
? {
min: moment(additionalFilter.from),
max: moment(additionalFilter.to),
}
: timefilter.getActiveBounds();
if (timefilterActiveBounds === undefined || selectedField === undefined) {
return;
}
openInDiscoverWithFilter(
dataViewId,
typeof selectedField === 'string' ? selectedField : selectedField.name,
selectedCategories,
aiopsListState,
timefilterActiveBounds,
mode,
category,
additionalFilter?.field
);
},
[
onAddFilter,
selectedField,
navigateToDiscover,
additionalFilter,
timefilter,
openInDiscoverWithFilter,
dataViewId,
selectedCategories,
aiopsListState,
onClose,
]
);
const labels = useMemo(() => {
const isFlyout = onAddFilter !== undefined && onClose !== undefined;
return getLabels(isFlyout && navigateToDiscover === false);
}, [navigateToDiscover, onAddFilter, onClose]);
return { openFunction, labels, count: selectedCategories.length };
}

View file

@ -9,7 +9,7 @@ import type { FC } from 'react';
import React from 'react';
import moment from 'moment';
import { EuiButtonEmpty } from '@elastic/eui';
import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/common';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
@ -18,6 +18,7 @@ import {
} from '@kbn/ml-ui-actions';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
interface Props {
@ -26,6 +27,7 @@ interface Props {
query: QueryDslQueryContainer;
earliest: number | undefined;
latest: number | undefined;
iconOnly?: boolean;
}
export const CreateCategorizationJobButton: FC<Props> = ({
@ -34,6 +36,7 @@ export const CreateCategorizationJobButton: FC<Props> = ({
query,
earliest,
latest,
iconOnly = false,
}) => {
const {
uiActions,
@ -58,20 +61,40 @@ export const CreateCategorizationJobButton: FC<Props> = ({
return null;
}
return (
<>
<EuiButtonEmpty
data-test-subj="aiopsLogCategorizationFlyoutAdJobButton"
onClick={createADJob}
flush="left"
iconSide="left"
iconType={'machineLearningApp'}
if (iconOnly) {
return (
<EuiToolTip
content={i18n.translate('xpack.aiops.categorizeFlyout.findAnomalies.tooltip', {
defaultMessage: 'Create anomaly detection job to find anomalies in patterns',
})}
>
<FormattedMessage
id="xpack.aiops.categorizeFlyout.findAnomalies"
defaultMessage="Find anomalies in patterns"
<EuiButtonIcon
data-test-subj="aiopsEmbeddableMenuOptionsButton"
size="s"
iconType="machineLearningApp"
onClick={createADJob}
// @ts-ignore - subdued does work
color="subdued"
aria-label={i18n.translate('xpack.aiops.categorizeFlyout.findAnomalies.tooltip', {
defaultMessage: 'Create anomaly detection job to find anomalies in patterns',
})}
/>
</EuiButtonEmpty>
</>
</EuiToolTip>
);
}
return (
<EuiButtonEmpty
data-test-subj="aiopsLogCategorizationFlyoutAdJobButton"
onClick={createADJob}
flush="left"
iconSide="left"
iconType={'machineLearningApp'}
>
<FormattedMessage
id="xpack.aiops.categorizeFlyout.findAnomalies"
defaultMessage="Find anomalies in patterns"
/>
</EuiButtonEmpty>
);
};

View file

@ -95,12 +95,15 @@ export const useCreateFormattedExample = () => {
const elements: JSX.Element[] = [];
let pos = 0;
for (let i = 0; i < positions.length; i++) {
const elementKey = `${key}-token-${i}`;
elements.push(
<span css={wildcardStyle}>{tempExample.substring(pos, positions[i].start)}</span>
<span css={wildcardStyle} key={elementKey}>
{tempExample.substring(pos, positions[i].start)}
</span>
);
elements.push(
<span css={tokenStyle}>
<span css={tokenStyle} key={`${elementKey}-2`}>
{tempExample.substring(positions[i].start, positions[i].end)}
</span>
);
@ -108,7 +111,7 @@ export const useCreateFormattedExample = () => {
}
elements.push(
<span css={wildcardStyle}>
<span css={wildcardStyle} key={key}>
{tempExample.substring(positions[positions.length - 1].end)}
</span>
);
@ -131,10 +134,10 @@ export const FormattedPatternExamples: FC<Props> = ({ category, count }) => {
.fill(0)
.map((_, i) => createFormattedExample(key, examples[i]));
return formattedExamples.map((example, i) => (
<>
<React.Fragment key={`example-${i}`}>
<code>{example}</code>
{i < formattedExamples.length - 1 ? <EuiHorizontalRule margin="s" /> : null}
</>
</React.Fragment>
));
}, [category, count, createFormattedExample]);
@ -150,10 +153,19 @@ export const FormattedRegex: FC<Props> = ({ category }) => {
const elements: JSX.Element[] = [];
for (let i = 0; i < regexTokens.length; i++) {
const token = regexTokens[i];
const key = `regex-${i}`;
if (token.match(/\.\*\?|\.\+\?/)) {
elements.push(<span css={wildcardStyle}>{token}</span>);
elements.push(
<span css={wildcardStyle} key={key}>
{token}
</span>
);
} else {
elements.push(<span css={tokenStyle}>{token}</span>);
elements.push(
<span css={tokenStyle} key={key}>
{token}
</span>
);
}
}
return elements;

View file

@ -10,93 +10,124 @@ import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiEmptyPrompt } from '@elastic/eui';
import type { DataViewField } from '@kbn/data-views-plugin/public';
interface Props {
eventRateLength: number;
fieldSelected: boolean;
fields?: DataViewField[];
categoriesLength: number | null;
loading: boolean;
}
export const InformationText: FC<Props> = ({
eventRateLength,
fieldSelected,
fields,
categoriesLength,
loading,
}) => {
if (loading === true) {
return null;
}
return (
<>
{eventRateLength === 0 ? (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.logCategorization.noDocsTitle"
defaultMessage="No documents found"
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.logCategorization.noDocsBody"
defaultMessage="Ensure the selected time range contains documents."
/>
</p>
}
data-test-subj="aiopsNoWindowParametersEmptyPrompt"
/>
) : null}
{eventRateLength > 0 && categoriesLength === null ? (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.logCategorization.emptyPromptTitle"
defaultMessage="Select a text field and click run pattern analysis to start analysis"
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.logCategorization.emptyPromptBody"
defaultMessage="Log pattern analysis groups messages into common patterns."
/>
</p>
}
data-test-subj="aiopsNoWindowParametersEmptyPrompt"
/>
) : null}
if (fields?.length === 0) {
return (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.logCategorization.noTextFieldsTitle"
defaultMessage="No text fields found"
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.logCategorization.noTextFieldsBody"
defaultMessage="Pattern analysis can only be run on text fields."
/>
</p>
}
data-test-subj="aiopsNoTextFieldsEmptyPrompt"
/>
);
}
{eventRateLength > 0 && categoriesLength !== null && categoriesLength === 0 ? (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.logCategorization.noCategoriesTitle"
defaultMessage="No patterns found"
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.logCategorization.noCategoriesBody"
defaultMessage="Ensure the selected field is populated in the selected time range."
/>
</p>
}
data-test-subj="aiopsNoWindowParametersEmptyPrompt"
/>
) : null}
</>
);
if (eventRateLength === 0) {
return (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.logCategorization.noDocsTitle"
defaultMessage="No documents found"
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.logCategorization.noDocsBody"
defaultMessage="Ensure the selected time range contains documents."
/>
</p>
}
data-test-subj="aiopsNoDocsEmptyPrompt"
/>
);
}
if (eventRateLength > 0 && categoriesLength === null) {
return (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.logCategorization.emptyPromptTitle"
defaultMessage="Select a text field and click run pattern analysis to start analysis"
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.logCategorization.emptyPromptBody"
defaultMessage="Log pattern analysis groups messages into common patterns."
/>
</p>
}
data-test-subj="aiopsNoWindowParametersEmptyPrompt"
/>
);
}
if (eventRateLength > 0 && categoriesLength !== null && categoriesLength === 0) {
return (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.logCategorization.noCategoriesTitle"
defaultMessage="No patterns found"
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.logCategorization.noCategoriesBody"
defaultMessage="Ensure the selected field is populated in the selected time range."
/>
</p>
}
data-test-subj="aiopsNoCategoriesEmptyPrompt"
/>
);
}
return null;
};

View file

@ -19,26 +19,26 @@ import {
import { FormattedMessage } from '@kbn/i18n-react';
interface Props {
onClose: () => void;
onCancel: () => void;
}
export const LoadingCategorization: FC<Props> = ({ onClose }) => (
export const LoadingCategorization: FC<Props> = ({ onCancel }) => (
<>
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false} css={{ textAlign: 'center' }}>
<EuiFlexGrid columns={1}>
<EuiFlexItem>
<EuiLoadingElastic size="xxl" />
<EuiLoadingElastic size="xl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h2>
<h4>
<FormattedMessage
id="xpack.aiops.categorizeFlyout.loading.title"
defaultMessage="Loading pattern analysis"
/>
</h2>
</h4>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
@ -46,7 +46,7 @@ export const LoadingCategorization: FC<Props> = ({ onClose }) => (
<EuiFlexItem grow={false} css={{ textAlign: 'center' }}>
<EuiButton
data-test-subj="aiopsLoadingCategorizationCancelButton"
onClick={() => onClose()}
onClick={() => onCancel()}
>
Cancel
</EuiButton>

View file

@ -0,0 +1,23 @@
/*
* 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 { DataView } from '@kbn/data-views-plugin/public';
import { ES_FIELD_TYPES } from '@kbn/field-types';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { firstValueFrom } from 'rxjs';
export function getPatternAnalysisAvailable(licensing: LicensingPluginStart) {
const lic = firstValueFrom(licensing.license$);
return async (dataView: DataView) => {
const isPlatinum = (await lic).hasAtLeast('platinum');
return (
isPlatinum &&
dataView.isTimeBased() &&
dataView.fields.some((f) => f.esTypes?.includes(ES_FIELD_TYPES.TEXT))
);
};
}

View file

@ -0,0 +1,116 @@
/*
* 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 { FC } from 'react';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import type { Category } from '@kbn/aiops-log-pattern-analysis/types';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/common';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { OpenInDiscover } from '../category_table/use_open_in_discover';
import { EmbeddableMenu } from './embeddable_menu';
import type { RandomSampler } from '../sampling_menu';
import type { MinimumTimeRangeOption } from './minimum_time_range';
import { SelectedPatterns } from './selected_patterns';
import { CreateCategorizationJobButton } from '../create_categorization_job';
import { SelectedField } from './field_selector';
interface Props {
renderViewModeToggle: (patternCount?: number) => React.ReactElement;
randomSampler: RandomSampler;
openInDiscover: OpenInDiscover;
selectedCategories: Category[];
loadCategories: () => void;
fields: DataViewField[];
setSelectedField: React.Dispatch<React.SetStateAction<DataViewField | null>>;
selectedField: DataViewField | null;
minimumTimeRangeOption: MinimumTimeRangeOption;
setMinimumTimeRangeOption: (w: MinimumTimeRangeOption) => void;
dataview: DataView;
earliest: number | undefined;
latest: number | undefined;
query: QueryDslQueryContainer;
data: {
categories: Category[];
displayExamples: boolean;
totalCategories: number;
} | null;
}
export const DiscoverTabs: FC<Props> = ({
renderViewModeToggle,
randomSampler,
openInDiscover,
selectedCategories,
loadCategories,
fields,
setSelectedField,
selectedField,
minimumTimeRangeOption,
setMinimumTimeRangeOption,
data,
dataview,
earliest,
latest,
query,
}) => {
return (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>{renderViewModeToggle(data?.categories.length)}</EuiFlexItem>
<EuiFlexItem />
<EuiFlexItem grow={false}>
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s" responsive={false}>
{selectedCategories.length > 0 ? (
<EuiFlexItem>
<SelectedPatterns openInDiscover={openInDiscover} />
</EuiFlexItem>
) : null}
<EuiFlexItem>
<SelectedField
fields={fields}
setSelectedField={setSelectedField}
selectedField={selectedField}
/>
</EuiFlexItem>
<EuiFlexItem>
<div className="unifiedDataTableToolbarControlGroup" css={{ marginRight: '8px' }}>
<div className="unifiedDataTableToolbarControlIconButton">
<EmbeddableMenu
randomSampler={randomSampler}
reload={() => loadCategories()}
minimumTimeRangeOption={minimumTimeRangeOption}
setMinimumTimeRangeOption={setMinimumTimeRangeOption}
categoryCount={data?.totalCategories}
/>
</div>
{selectedField !== null && earliest !== undefined && latest !== undefined ? (
<div className="unifiedDataTableToolbarControlIconButton">
<CreateCategorizationJobButton
dataView={dataview}
field={selectedField}
query={query}
earliest={earliest}
latest={latest}
iconOnly={true}
/>
</div>
) : null}
</div>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,149 @@
/*
* 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 { EuiPopoverTitle, EuiSuperSelect, EuiToolTip } from '@elastic/eui';
import { EuiFormRow } from '@elastic/eui';
import {
EuiButtonIcon,
EuiPanel,
EuiPopover,
EuiSpacer,
EuiTitle,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
} from '@elastic/eui';
import type { FC } from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { RandomSampler } from '../sampling_menu';
import { SamplingPanel } from '../sampling_menu/sampling_panel';
import type { MinimumTimeRangeOption } from './minimum_time_range';
import { MINIMUM_TIME_RANGE } from './minimum_time_range';
interface Props {
randomSampler: RandomSampler;
minimumTimeRangeOption: MinimumTimeRangeOption;
setMinimumTimeRangeOption: (w: MinimumTimeRangeOption) => void;
categoryCount: number | undefined;
reload: () => void;
}
const minimumTimeRangeOptions = Object.keys(MINIMUM_TIME_RANGE).map((value) => ({
inputDisplay: value,
value: value as MinimumTimeRangeOption,
}));
export const EmbeddableMenu: FC<Props> = ({
randomSampler,
minimumTimeRangeOption,
setMinimumTimeRangeOption,
categoryCount,
reload,
}) => {
const [showMenu, setShowMenu] = useState(false);
const togglePopover = () => setShowMenu(!showMenu);
const button = (
<EuiToolTip
content={i18n.translate('xpack.aiops.logCategorization.embeddableMenu.tooltip', {
defaultMessage: 'Options',
})}
>
<EuiButtonIcon
data-test-subj="aiopsEmbeddableMenuOptionsButton"
size="s"
iconType="controlsHorizontal"
onClick={() => togglePopover()}
// @ts-ignore - subdued does work
color="subdued"
aria-label={i18n.translate('xpack.aiops.logCategorization.embeddableMenu.aria', {
defaultMessage: 'Pattern analysis options',
})}
/>
</EuiToolTip>
);
return (
<EuiPopover
id={'embeddableMenu'}
button={button}
isOpen={showMenu}
closePopover={() => togglePopover()}
panelPaddingSize="s"
anchorPosition="downRight"
>
<EuiPanel color="transparent" paddingSize="s" css={{ maxWidth: '400px' }}>
<EuiTitle size="xxxs">
<EuiPopoverTitle>
<FormattedMessage
id="xpack.aiops.logCategorization.embeddableMenu.patternAnalysisSettingsTitle"
defaultMessage=" Pattern analysis settings"
/>
</EuiPopoverTitle>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFormRow
data-test-subj="aiopsRandomSamplerOptionsFormRow"
label={
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem css={{ textWrap: 'nowrap' }}>
{i18n.translate(
'xpack.aiops.logCategorization.embeddableMenu.minimumTimeRangeOptionsRowLabel',
{
defaultMessage: 'Minimum time range',
}
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip
content={i18n.translate(
'xpack.aiops.logCategorization.embeddableMenu.minimumTimeRange.tooltip',
{
defaultMessage:
'Adds a wider time range to the analysis to improve pattern accuracy.',
}
)}
>
<EuiIcon type="questionInCircle" color="subdued" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
}
helpText={
<>
{categoryCount !== undefined && minimumTimeRangeOption !== 'No minimum' ? (
<>
<FormattedMessage
id="xpack.aiops.logCategorization.embeddableMenu.totalPatternsMessage"
defaultMessage="Total patterns in {minimumTimeRangeOption}: {categoryCount}"
values={{ minimumTimeRangeOption, categoryCount }}
/>
</>
) : null}
</>
}
>
<EuiSuperSelect
aria-label="Select a minimum time range"
options={minimumTimeRangeOptions}
valueOfSelected={minimumTimeRangeOption}
onChange={setMinimumTimeRangeOption}
/>
</EuiFormRow>
<EuiHorizontalRule margin="m" />
<SamplingPanel randomSampler={randomSampler} reload={reload} calloutPosition="bottom" />
</EuiPanel>
</EuiPopover>
);
};

View file

@ -0,0 +1,79 @@
/*
* 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 { FC } from 'react';
import { useMemo } from 'react';
import { useState } from 'react';
import React from 'react';
import {
EuiDataGridToolbarControl,
EuiPopover,
EuiFormRow,
EuiSuperSelect,
EuiFlexGroup,
EuiFlexItem,
EuiToken,
} from '@elastic/eui';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import { i18n } from '@kbn/i18n';
interface Props {
fields: DataViewField[];
selectedField: DataViewField | null;
setSelectedField: (field: DataViewField) => void;
}
export const SelectedField: FC<Props> = ({ fields, selectedField, setSelectedField }) => {
const [showPopover, setShowPopover] = useState(false);
const togglePopover = () => setShowPopover(!showPopover);
const fieldOptions = useMemo(
() => fields.map((field) => ({ inputDisplay: field.name, value: field })),
[fields]
);
const button = (
<EuiDataGridToolbarControl
data-test-subj="aiopsEmbeddableSelectFieldButton"
onClick={() => togglePopover()}
>
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiToken iconType="tokenString" />
</EuiFlexItem>
<EuiFlexItem>{selectedField?.name}</EuiFlexItem>
</EuiFlexGroup>
</EuiDataGridToolbarControl>
);
return (
<EuiPopover
closePopover={() => setShowPopover(false)}
isOpen={showPopover}
button={button}
className="unifiedDataTableToolbarControlButton"
>
<EuiFormRow
data-test-subj="aiopsEmbeddableMenuSelectedFieldFormRow"
label={i18n.translate(
'xpack.aiops.logCategorization.embeddableMenu.selectedFieldRowLabel',
{
defaultMessage: 'Selected field',
}
)}
>
<EuiSuperSelect
aria-label="Select a field"
options={fieldOptions}
valueOfSelected={selectedField ?? undefined}
onChange={setSelectedField}
css={{ minWidth: '300px' }}
/>
</EuiFormRow>
</EuiPopover>
);
};

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 { LogCategorizationEmbeddable } from './log_categorization_for_embeddable';
export { LogCategorizationWrapper } from './log_categorization_wrapper';

View file

@ -0,0 +1,463 @@
/*
* 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 { FC } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, useEuiPaddingSize } from '@elastic/eui';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import { i18n } from '@kbn/i18n';
import type { Filter } from '@kbn/es-query';
import { buildEmptyFilter } from '@kbn/es-query';
import { usePageUrlState } from '@kbn/ml-url-state';
import type { FieldValidationResults } from '@kbn/ml-category-validator';
import type { Category } from '@kbn/aiops-log-pattern-analysis/types';
import type { CategorizationAdditionalFilter } from '@kbn/aiops-log-pattern-analysis/create_category_request';
import { AIOPS_TELEMETRY_ID } from '@kbn/aiops-common/constants';
import type { EmbeddablePatternAnalysisInput } from '@kbn/aiops-log-pattern-analysis/embeddable';
import { css } from '@emotion/react';
import { useTableState } from '@kbn/ml-in-memory-table/hooks/use_table_state';
import {
type LogCategorizationPageUrlState,
getDefaultLogCategorizationAppState,
} from '../../../application/url_state/log_pattern_analysis';
import { createMergedEsQuery } from '../../../application/utils/search_utils';
import { useData } from '../../../hooks/use_data';
import { useSearch } from '../../../hooks/use_search';
import { useAiopsAppContext } from '../../../hooks/use_aiops_app_context';
import { useCategorizeRequest } from '../use_categorize_request';
import type { EventRate } from '../use_categorize_request';
import { CategoryTable } from '../category_table';
import { InformationText } from '../information_text';
import { LoadingCategorization } from '../loading_categorization';
import { useValidateFieldRequest } from '../use_validate_category_field';
import { FieldValidationCallout } from '../category_validation_callout';
import { useMinimumTimeRange } from './use_minimum_time_range';
import { createAdditionalConfigHash, createDocumentStatsHash, getMessageField } from '../utils';
import { useOpenInDiscover } from '../category_table/use_open_in_discover';
import { DiscoverTabs } from './discover_tabs';
export interface LogCategorizationEmbeddableProps {
input: Readonly<EmbeddablePatternAnalysisInput>;
renderViewModeToggle: (patternCount?: number) => React.ReactElement;
}
const BAR_TARGET = 20;
export const LogCategorizationEmbeddable: FC<LogCategorizationEmbeddableProps> = ({
input,
renderViewModeToggle,
}) => {
const {
notifications: { toasts },
data: {
query: { getState, filterManager },
},
uiSettings,
embeddingOrigin,
} = useAiopsAppContext();
const tablePadding = useEuiPaddingSize('xs');
const { dataView, savedSearch } = input;
const { runValidateFieldRequest, cancelRequest: cancelValidationRequest } =
useValidateFieldRequest();
const {
getMinimumTimeRange,
cancelRequest: cancelWiderTimeRangeRequest,
minimumTimeRangeOption,
setMinimumTimeRangeOption,
} = useMinimumTimeRange();
const { filters, query } = useMemo(() => getState(), [getState]);
const mounted = useRef(false);
const {
runCategorizeRequest,
cancelRequest: cancelCategorizationRequest,
randomSampler,
} = useCategorizeRequest();
const [stateFromUrl] = usePageUrlState<LogCategorizationPageUrlState>(
'logCategorization',
getDefaultLogCategorizationAppState({
searchQuery: createMergedEsQuery(query, filters, dataView, uiSettings),
})
);
const [highlightedCategory, setHighlightedCategory] = useState<Category | null>(null);
const [selectedCategories, setSelectedCategories] = useState<Category[]>([]);
const [selectedField, setSelectedField] = useState<DataViewField | null>(null);
const [fields, setFields] = useState<DataViewField[]>([]);
const [currentDocumentStatsHash, setCurrentDocumentStatsHash] = useState<number | null>(null);
const [previousDocumentStatsHash, setPreviousDocumentStatsHash] = useState<number>(0);
const [currentAdditionalConfigsHash, setCurrentAdditionalConfigsHash] = useState<number | null>(
null
);
const [previousAdditionalConfigsHash, setPreviousAdditionalConfigsHash] = useState<number | null>(
null
);
const [loading, setLoading] = useState<boolean | null>(null);
const [eventRate, setEventRate] = useState<EventRate>([]);
const [pinnedCategory, setPinnedCategory] = useState<Category | null>(null);
const [data, setData] = useState<{
categories: Category[];
displayExamples: boolean;
totalCategories: number;
} | null>(null);
const [fieldValidationResult, setFieldValidationResult] = useState<FieldValidationResults | null>(
null
);
const tableState = useTableState<Category>([], 'key');
useEffect(
function initFields() {
setCurrentDocumentStatsHash(null);
setSelectedField(null);
setLoading(null);
const { dataViewFields, messageField } = getMessageField(dataView);
setFields(dataViewFields);
setSelectedField(messageField);
},
[dataView]
);
const cancelRequest = useCallback(() => {
cancelWiderTimeRangeRequest();
cancelValidationRequest();
cancelCategorizationRequest();
}, [cancelCategorizationRequest, cancelValidationRequest, cancelWiderTimeRangeRequest]);
useEffect(
function cancelRequestOnLeave() {
mounted.current = true;
return () => {
mounted.current = false;
cancelRequest();
};
},
[cancelRequest, mounted]
);
const { searchQuery } = useSearch(
{ dataView, savedSearch: savedSearch ?? null },
stateFromUrl,
true
);
const { documentStats, timefilter, earliest, latest, intervalMs, forceRefresh } = useData(
dataView,
'log_categorization',
searchQuery,
() => {},
undefined,
undefined,
BAR_TARGET,
false
);
const onAddFilter = useCallback(
(values: Filter, alias?: string) => {
if (input.switchToDocumentView === undefined) {
return;
}
const filter = buildEmptyFilter(false, dataView.id);
if (alias) {
filter.meta.alias = alias;
}
filter.query = values.query;
input.switchToDocumentView();
filterManager.addFilters([filter]);
},
[dataView.id, filterManager, input]
);
const openInDiscover = useOpenInDiscover(
dataView.id!,
selectedField ?? undefined,
selectedCategories,
stateFromUrl,
timefilter,
false,
onAddFilter,
undefined
);
useEffect(
function createDocumentStatHash() {
if (documentStats.documentCountStats === undefined) {
return;
}
const hash = createDocumentStatsHash(documentStats);
if (hash !== previousDocumentStatsHash) {
setCurrentDocumentStatsHash(hash);
setData(null);
setFieldValidationResult(null);
}
},
[documentStats, previousDocumentStatsHash]
);
useEffect(
function createAdditionalConfigHash2() {
if (!selectedField?.name) {
return;
}
const hash = createAdditionalConfigHash([selectedField.name, minimumTimeRangeOption]);
if (hash !== previousAdditionalConfigsHash) {
setCurrentAdditionalConfigsHash(hash);
setData(null);
setFieldValidationResult(null);
}
},
[minimumTimeRangeOption, previousAdditionalConfigsHash, selectedField]
);
const loadCategories = useCallback(async () => {
const { getIndexPattern, timeFieldName: timeField } = dataView;
const index = getIndexPattern();
if (
loading === true ||
selectedField === null ||
timeField === undefined ||
earliest === undefined ||
latest === undefined ||
minimumTimeRangeOption === undefined ||
mounted.current !== true
) {
return;
}
cancelRequest();
setLoading(true);
setData(null);
setFieldValidationResult(null);
const additionalFilter: CategorizationAdditionalFilter = {
from: earliest,
to: latest,
};
try {
const timeRange = await getMinimumTimeRange(
index,
timeField,
additionalFilter,
minimumTimeRangeOption,
searchQuery
);
if (mounted.current !== true) {
return;
}
const [validationResult, categorizationResult] = await Promise.all([
runValidateFieldRequest(index, selectedField.name, timeField, timeRange, searchQuery, {
[AIOPS_TELEMETRY_ID.AIOPS_ANALYSIS_RUN_ORIGIN]: embeddingOrigin,
}),
runCategorizeRequest(
index,
selectedField.name,
timeField,
{ to: timeRange.to, from: timeRange.from },
searchQuery,
intervalMs,
timeRange.useSubAgg ? additionalFilter : undefined
),
]);
if (mounted.current !== true) {
return;
}
setFieldValidationResult(validationResult);
const { categories, hasExamples } = categorizationResult;
if (timeRange.useSubAgg) {
const categoriesInBucket = categorizationResult.categories
.map((category) => ({
...category,
count: category.subFieldCount ?? category.subTimeRangeCount!,
examples: category.subFieldExamples!,
sparkline: category.subFieldSparkline,
}))
.filter((category) => category.count > 0)
.sort((a, b) => b.count - a.count);
setData({
categories: categoriesInBucket,
displayExamples: hasExamples,
totalCategories: categories.length,
});
} else {
setData({
categories,
displayExamples: hasExamples,
totalCategories: categories.length,
});
}
} catch (error) {
if (error.name === 'AbortError') {
// ignore error
} else {
toasts.addError(error, {
title: i18n.translate('xpack.aiops.logCategorization.errorLoadingCategories', {
defaultMessage: 'Error loading categories',
}),
});
}
}
if (mounted.current === true) {
setLoading(false);
}
}, [
dataView,
loading,
selectedField,
earliest,
latest,
minimumTimeRangeOption,
cancelRequest,
getMinimumTimeRange,
searchQuery,
runValidateFieldRequest,
embeddingOrigin,
runCategorizeRequest,
intervalMs,
toasts,
]);
useEffect(
function triggerAnalysis() {
const buckets = documentStats.documentCountStats?.buckets;
if (buckets === undefined || currentDocumentStatsHash === null) {
return;
}
if (
currentDocumentStatsHash !== previousDocumentStatsHash ||
(currentAdditionalConfigsHash !== previousAdditionalConfigsHash &&
currentDocumentStatsHash !== null)
) {
randomSampler.setDocCount(documentStats.totalCount);
setEventRate(
Object.entries(buckets).map(([key, docCount]) => ({
key: +key,
docCount,
}))
);
loadCategories();
setPreviousDocumentStatsHash(currentDocumentStatsHash);
setPreviousAdditionalConfigsHash(currentAdditionalConfigsHash);
}
},
[
loadCategories,
randomSampler,
previousDocumentStatsHash,
fieldValidationResult,
currentDocumentStatsHash,
currentAdditionalConfigsHash,
documentStats.documentCountStats?.buckets,
documentStats.totalCount,
previousAdditionalConfigsHash,
]
);
useEffect(
function refreshTriggeredFromButton() {
if (input.lastReloadRequestTime !== undefined) {
setPreviousDocumentStatsHash(0);
setPreviousAdditionalConfigsHash(null);
forceRefresh();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[input.lastReloadRequestTime]
);
const style = css({
overflowY: 'auto',
'.kbnDocTableWrapper': {
overflowX: 'hidden',
},
});
return (
<>
<DiscoverTabs
data={data}
fields={fields}
loadCategories={loadCategories}
minimumTimeRangeOption={minimumTimeRangeOption}
openInDiscover={openInDiscover}
randomSampler={randomSampler}
selectedCategories={selectedCategories}
selectedField={selectedField}
setMinimumTimeRangeOption={setMinimumTimeRangeOption}
setSelectedField={setSelectedField}
renderViewModeToggle={renderViewModeToggle}
dataview={dataView}
earliest={earliest}
latest={latest}
query={searchQuery}
/>
<EuiSpacer size="s" />
<EuiFlexItem css={style}>
<EuiFlexGroup
className="eui-fullHeight"
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem css={{ position: 'relative', overflowY: 'auto', marginLeft: tablePadding }}>
<>
<FieldValidationCallout validationResults={fieldValidationResult} />
{(loading ?? true) === true ? (
<LoadingCategorization onCancel={cancelRequest} />
) : null}
<InformationText
loading={loading ?? true}
categoriesLength={data?.categories?.length ?? null}
eventRateLength={eventRate.length}
fields={fields}
/>
{loading === false &&
data !== null &&
data.categories.length > 0 &&
selectedField !== null ? (
<CategoryTable
categories={data.categories}
eventRate={eventRate}
pinnedCategory={pinnedCategory}
setPinnedCategory={setPinnedCategory}
highlightedCategory={highlightedCategory}
setHighlightedCategory={setHighlightedCategory}
enableRowActions={false}
displayExamples={data.displayExamples}
setSelectedCategories={setSelectedCategories}
openInDiscover={openInDiscover}
tableState={tableState}
/>
) : null}
</>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</>
);
};
// eslint-disable-next-line import/no-default-export
export default LogCategorizationEmbeddable;

View file

@ -0,0 +1,87 @@
/*
* 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 { FC } from 'react';
import React, { Suspense } from 'react';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { IUiSettingsClient } from '@kbn/core/public';
import type { CoreStart } from '@kbn/core/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { DatePickerContextProvider } from '@kbn/ml-date-picker';
import { StorageContextProvider } from '@kbn/ml-local-storage';
import { pick } from 'lodash';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { AIOPS_STORAGE_KEYS } from '../../../types/storage';
import type { AiopsAppDependencies } from '../../../hooks/use_aiops_app_context';
import { AiopsAppContext } from '../../../hooks/use_aiops_app_context';
import type { LogCategorizationEmbeddableProps } from './log_categorization_for_embeddable';
import { LogCategorizationEmbeddable } from './log_categorization_for_embeddable';
export interface EmbeddableLogCategorizationDeps {
theme: ThemeServiceStart;
data: DataPublicPluginStart;
uiSettings: IUiSettingsClient;
http: CoreStart['http'];
notifications: CoreStart['notifications'];
i18n: CoreStart['i18n'];
lens: LensPublicStart;
fieldFormats: FieldFormatsStart;
application: CoreStart['application'];
charts: ChartsPluginStart;
uiActions: UiActionsStart;
}
export interface LogCategorizationEmbeddableWrapperProps {
deps: EmbeddableLogCategorizationDeps;
props: LogCategorizationEmbeddableProps;
embeddingOrigin?: string;
}
const localStorage = new Storage(window.localStorage);
export const LogCategorizationWrapper: FC<LogCategorizationEmbeddableWrapperProps> = ({
deps,
props,
embeddingOrigin,
}) => {
const I18nContext = deps.i18n.Context;
const aiopsAppContextValue = {
embeddingOrigin,
...deps,
} as unknown as AiopsAppDependencies;
const datePickerDeps = {
...pick(deps, ['data', 'http', 'notifications', 'theme', 'uiSettings', 'i18n']),
uiSettingsKeys: UI_SETTINGS,
};
return (
<I18nContext>
<AiopsAppContext.Provider value={aiopsAppContextValue}>
<DatePickerContextProvider {...datePickerDeps}>
<StorageContextProvider storage={localStorage} storageKeys={AIOPS_STORAGE_KEYS}>
<Suspense fallback={null}>
<LogCategorizationEmbeddable
input={props.input}
renderViewModeToggle={props.renderViewModeToggle}
/>
</Suspense>
</StorageContextProvider>
</DatePickerContextProvider>
</AiopsAppContext.Provider>
</I18nContext>
);
};
// eslint-disable-next-line import/no-default-export
export default LogCategorizationWrapper;

View file

@ -0,0 +1,19 @@
/*
* 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 { unitOfTime } from 'moment';
export type MinimumTimeRangeOption = 'No minimum' | '1 week' | '1 month' | '3 months' | '6 months';
type MinimumTimeRange = Record<MinimumTimeRangeOption, { factor: number; unit: unitOfTime.Base }>;
export const MINIMUM_TIME_RANGE: MinimumTimeRange = {
'No minimum': { factor: 0, unit: 'w' },
'1 week': { factor: 1, unit: 'w' },
'1 month': { factor: 1, unit: 'M' },
'3 months': { factor: 3, unit: 'M' },
'6 months': { factor: 6, unit: 'M' },
};

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FC } from 'react';
import { useState } from 'react';
import React from 'react';
import {
EuiDataGridToolbarControl,
EuiPopover,
EuiContextMenuPanel,
EuiContextMenuItem,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { QUERY_MODE } from '@kbn/aiops-log-pattern-analysis/get_category_query';
import type { OpenInDiscover } from '../category_table/use_open_in_discover';
export const SelectedPatterns: FC<{ openInDiscover: OpenInDiscover }> = ({ openInDiscover }) => {
const { labels, openFunction } = openInDiscover;
const [showMenu, setShowMenu] = useState(false);
const togglePopover = () => setShowMenu(!showMenu);
const button = (
<EuiDataGridToolbarControl
data-test-subj="aiopsEmbeddableMenuOptionsButton"
iconType="documents"
onClick={() => togglePopover()}
badgeContent={openInDiscover.count}
>
<FormattedMessage
id="xpack.aiops.logCategorization.selectedResultsButtonLabel"
defaultMessage="Selected"
description="Selected results"
/>
</EuiDataGridToolbarControl>
);
return (
<EuiPopover
closePopover={() => setShowMenu(false)}
isOpen={showMenu}
panelPaddingSize="none"
button={button}
className="unifiedDataTableToolbarControlButton"
>
<EuiContextMenuPanel
items={[
<EuiContextMenuItem
key="in"
icon="plusInCircle"
onClick={() => openFunction(QUERY_MODE.INCLUDE)}
>
{labels.multiSelect.in}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="out"
icon="minusInCircle"
onClick={() => openFunction(QUERY_MODE.EXCLUDE)}
>
{labels.multiSelect.out}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
);
};

View file

@ -0,0 +1,93 @@
/*
* 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 { useRef, useCallback } from 'react';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { HttpFetchOptions } from '@kbn/core/public';
import { getTimeFieldRange } from '@kbn/ml-date-picker';
import moment from 'moment';
import { useStorage } from '@kbn/ml-local-storage';
import { useAiopsAppContext } from '../../../hooks/use_aiops_app_context';
import type { MinimumTimeRangeOption } from './minimum_time_range';
import { MINIMUM_TIME_RANGE } from './minimum_time_range';
import type { AiOpsKey, AiOpsStorageMapped } from '../../../types/storage';
import { AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE } from '../../../types/storage';
export function useMinimumTimeRange() {
const { http } = useAiopsAppContext();
const abortController = useRef(new AbortController());
const getMinimumTimeRange = useCallback(
async (
index: string,
timeField: string,
timeRange: { from: number; to: number },
minimumTimeRangeOption: MinimumTimeRangeOption,
queryIn: QueryDslQueryContainer,
headers?: HttpFetchOptions['headers']
) => {
const minimumTimeRange = MINIMUM_TIME_RANGE[minimumTimeRangeOption];
const minimumTimeRangeMs = moment
.duration(minimumTimeRange.factor, minimumTimeRange.unit)
.asMilliseconds();
const currentMinimumTimeRange = timeRange.to - timeRange.from;
// the time range is already wide enough
if (currentMinimumTimeRange > minimumTimeRangeMs) {
return { ...timeRange, useSubAgg: false };
}
const resp = await getTimeFieldRange({
http,
index,
timeFieldName: timeField,
query: queryIn,
path: '/internal/file_upload/time_field_range',
signal: abortController.current.signal,
});
// the index isn't big enough to get a wider time range
const indexTimeRangeMs = resp.end.epoch - resp.start.epoch;
if (indexTimeRangeMs < minimumTimeRangeMs) {
return {
from: resp.start.epoch,
to: resp.end.epoch,
useSubAgg: true,
};
}
const remainder = minimumTimeRangeMs - currentMinimumTimeRange;
const newFrom = Math.max(timeRange.from - remainder, resp.start.epoch);
const newTo = Math.min(newFrom + minimumTimeRangeMs, resp.end.epoch);
return {
from: newFrom,
to: newTo,
useSubAgg: true,
};
},
[http]
);
const [minimumTimeRangeOption, setMinimumTimeRangeOption] = useStorage<
AiOpsKey,
AiOpsStorageMapped<typeof AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE>
>(AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE, '1 week');
const cancelRequest = useCallback(() => {
abortController.current.abort();
abortController.current = new AbortController();
}, []);
return {
getMinimumTimeRange,
cancelRequest,
minimumTimeRangeOption,
setMinimumTimeRangeOption,
};
}

View file

@ -19,20 +19,20 @@ import {
EuiSpacer,
EuiToolTip,
EuiIcon,
EuiHorizontalRule,
} from '@elastic/eui';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Filter } from '@kbn/es-query';
import { buildEmptyFilter } from '@kbn/es-query';
import { usePageUrlState } from '@kbn/ml-url-state';
import type { FieldValidationResults } from '@kbn/ml-category-validator';
import { AIOPS_TELEMETRY_ID } from '@kbn/aiops-common/constants';
import type { CategorizationAdditionalFilter } from '@kbn/aiops-log-pattern-analysis/create_category_request';
import type { Category } from '@kbn/aiops-log-pattern-analysis/types';
import { useTableState } from '@kbn/ml-in-memory-table/hooks/use_table_state';
import {
type LogCategorizationPageUrlState,
getDefaultLogCategorizationAppState,
@ -51,6 +51,8 @@ import { LoadingCategorization } from './loading_categorization';
import { useValidateFieldRequest } from './use_validate_category_field';
import { FieldValidationCallout } from './category_validation_callout';
import { CreateCategorizationJobButton } from './create_categorization_job';
import { TableHeader } from './category_table/table_header';
import { useOpenInDiscover } from './category_table/use_open_in_discover';
enum SELECTED_TAB {
BUCKET,
@ -80,7 +82,7 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
const {
notifications: { toasts },
data: {
query: { getState, filterManager },
query: { getState },
},
uiSettings,
} = useAiopsAppContext();
@ -102,7 +104,8 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
searchQuery: createMergedEsQuery(query, filters, dataView, uiSettings),
})
);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [highlightedCategory, setHighlightedCategory] = useState<Category | null>(null);
const [selectedCategories, setSelectedCategories] = useState<Category[]>([]);
const [selectedSavedSearch /* , setSelectedSavedSearch*/] = useState(savedSearch);
const [loading, setLoading] = useState(true);
const [eventRate, setEventRate] = useState<EventRate>([]);
@ -117,6 +120,7 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
);
const [showTabs, setShowTabs] = useState<boolean>(false);
const [selectedTab, setSelectedTab] = useState<SELECTED_TAB>(SELECTED_TAB.FULL_TIME_RANGE);
const tableState = useTableState<Category>([], 'key');
const cancelRequest = useCallback(() => {
cancelValidationRequest();
@ -150,6 +154,17 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
BAR_TARGET
);
const openInDiscover = useOpenInDiscover(
dataView.id!,
selectedField,
selectedCategories,
stateFromUrl,
timefilter,
true,
undefined,
undefined
);
const loadCategories = useCallback(async () => {
const { getIndexPattern, timeFieldName: timeField } = dataView;
const index = getIndexPattern();
@ -243,18 +258,6 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
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);
@ -312,12 +315,11 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
/>
) : null}
<FieldValidationCallout validationResults={fieldValidationResult} />
{loading === true ? <LoadingCategorization onClose={onClose} /> : null}
{loading === true ? <LoadingCategorization onCancel={onClose} /> : null}
<InformationText
loading={loading}
categoriesLength={data?.categories?.length ?? null}
eventRateLength={eventRate.length}
fieldSelected={selectedField !== null}
/>
{loading === false && data !== null && data.categories.length > 0 ? (
<>
@ -388,31 +390,31 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
</>
) : null}
<TableHeader
categoriesCount={data.categories.length}
selectedCategoriesCount={selectedCategories.length}
openInDiscover={openInDiscover}
/>
<EuiSpacer size="xs" />
<EuiHorizontalRule margin="none" />
<CategoryTable
categories={
selectedTab === SELECTED_TAB.BUCKET && data.categoriesInBucket !== null
? data.categoriesInBucket
: data.categories
}
aiopsListState={stateFromUrl}
dataViewId={dataView.id!}
eventRate={eventRate}
selectedField={selectedField}
pinnedCategory={pinnedCategory}
setPinnedCategory={setPinnedCategory}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
timefilter={timefilter}
onAddFilter={onAddFilter}
onClose={onClose}
highlightedCategory={highlightedCategory}
setHighlightedCategory={setHighlightedCategory}
enableRowActions={false}
additionalFilter={
selectedTab === SELECTED_TAB.BUCKET && additionalFilter !== undefined
? additionalFilter
: undefined
}
navigateToDiscover={additionalFilter !== undefined}
displayExamples={data.displayExamples}
setSelectedCategories={setSelectedCategories}
openInDiscover={openInDiscover}
tableState={tableState}
/>
</>
) : null}

View file

@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiHorizontalRule } from '@elastic/eui';
import {
EuiButton,
EuiSpacer,
@ -27,10 +28,10 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { usePageUrlState, useUrlState } from '@kbn/ml-url-state';
import type { FieldValidationResults } from '@kbn/ml-category-validator';
import type { SearchQueryLanguage } from '@kbn/ml-query-utils';
import { stringHash } from '@kbn/ml-string-hash';
import { AIOPS_TELEMETRY_ID } from '@kbn/aiops-common/constants';
import type { Category } from '@kbn/aiops-log-pattern-analysis/types';
import { useTableState } from '@kbn/ml-in-memory-table/hooks/use_table_state';
import { useDataSource } from '../../hooks/use_data_source';
import { useData } from '../../hooks/use_data';
import { useSearch } from '../../hooks/use_search';
@ -51,7 +52,9 @@ import { InformationText } from './information_text';
import { SamplingMenu } from './sampling_menu';
import { useValidateFieldRequest } from './use_validate_category_field';
import { FieldValidationCallout } from './category_validation_callout';
import type { DocumentStats } from '../../hooks/use_document_count_stats';
import { createDocumentStatsHash } from './utils';
import { TableHeader } from './category_table/table_header';
import { useOpenInDiscover } from './category_table/use_open_in_discover';
const BAR_TARGET = 20;
const DEFAULT_SELECTED_FIELD = 'message';
@ -80,7 +83,8 @@ export const LogCategorizationPage: FC<LogCategorizationPageProps> = ({ embeddin
);
const [globalState, setGlobalState] = useUrlState('_g');
const [selectedField, setSelectedField] = useState<string | undefined>();
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [highlightedCategory, setHighlightedCategory] = useState<Category | null>(null);
const [selectedCategories, setSelectedCategories] = useState<Category[]>([]);
const [selectedSavedSearch, setSelectedSavedSearch] = useState(savedSearch);
const [previousDocumentStatsHash, setPreviousDocumentStatsHash] = useState<number>(0);
const [loading, setLoading] = useState(false);
@ -94,6 +98,7 @@ export const LogCategorizationPage: FC<LogCategorizationPageProps> = ({ embeddin
const [fieldValidationResult, setFieldValidationResult] = useState<FieldValidationResults | null>(
null
);
const tableState = useTableState<Category>([], 'key');
const cancelRequest = useCallback(() => {
cancelValidationRequest();
@ -154,6 +159,17 @@ export const LogCategorizationPage: FC<LogCategorizationPageProps> = ({ embeddin
BAR_TARGET
);
const openInDiscover = useOpenInDiscover(
dataView.id!,
selectedField,
selectedCategories,
stateFromUrl,
timefilter,
true,
undefined,
undefined
);
useEffect(() => {
if (globalState?.time !== undefined) {
timefilter.setTime({
@ -253,8 +269,6 @@ export const LogCategorizationPage: FC<LogCategorizationPageProps> = ({ embeddin
docCount,
}))
);
setData(null);
setFieldValidationResult(null);
setTotalCount(documentStats.totalCount);
if (fieldValidationResult !== null) {
loadCategories();
@ -371,7 +385,7 @@ export const LogCategorizationPage: FC<LogCategorizationPageProps> = ({ embeddin
<DocumentCountChart
eventRate={eventRate}
pinnedCategory={pinnedCategory}
selectedCategory={selectedCategory}
selectedCategory={highlightedCategory}
totalCount={totalCount}
documentCountStats={documentStats.documentCountStats}
/>
@ -387,36 +401,33 @@ export const LogCategorizationPage: FC<LogCategorizationPageProps> = ({ embeddin
loading={loading}
categoriesLength={data?.categories?.length ?? null}
eventRateLength={eventRate.length}
fieldSelected={selectedField !== null}
/>
{selectedField !== undefined && data !== null && data.categories.length > 0 ? (
<CategoryTable
categories={data.categories}
aiopsListState={stateFromUrl}
dataViewId={dataView.id!}
eventRate={eventRate}
selectedField={selectedField}
pinnedCategory={pinnedCategory}
setPinnedCategory={setPinnedCategory}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
timefilter={timefilter}
displayExamples={data.displayExamples}
/>
<>
<TableHeader
categoriesCount={data.categories.length}
selectedCategoriesCount={selectedCategories.length}
openInDiscover={openInDiscover}
/>
<EuiSpacer size="xs" />
<EuiHorizontalRule margin="none" />
<CategoryTable
categories={data.categories}
eventRate={eventRate}
pinnedCategory={pinnedCategory}
setPinnedCategory={setPinnedCategory}
highlightedCategory={highlightedCategory}
setHighlightedCategory={setHighlightedCategory}
displayExamples={data.displayExamples}
setSelectedCategories={setSelectedCategories}
openInDiscover={openInDiscover}
tableState={tableState}
/>
</>
) : null}
</EuiPageBody>
);
};
/**
* Creates a hash from the document stats to determine if the document stats have changed.
*/
function createDocumentStatsHash(documentStats: DocumentStats) {
const lastTimeStampMs = documentStats.documentCountStats?.lastDocTimeStampMs;
const totalCount = documentStats.documentCountStats?.totalCount;
const times = Object.keys(documentStats.documentCountStats?.buckets ?? {});
const firstBucketTimeStamp = times.length ? times[0] : undefined;
const lastBucketTimeStamp = times.length ? times[times.length - 1] : undefined;
return stringHash(`${lastTimeStampMs}${totalCount}${firstBucketTimeStamp}${lastBucketTimeStamp}`);
}

View file

@ -28,27 +28,33 @@ export type RandomSamplerProbability = number | null;
export const RANDOM_SAMPLER_SELECT_OPTIONS: Array<{
value: RandomSamplerOption;
text: string;
inputDisplay: 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',
}),
inputDisplay: 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',
}),
inputDisplay: 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', {
inputDisplay: i18n.translate('xpack.aiops.logCategorization.randomSamplerPreference.offLabel', {
defaultMessage: 'Off',
}),
},
@ -126,3 +132,58 @@ export class RandomSampler {
return wrapper;
}
}
export const randomSamplerText = (randomSamplerPreference: RandomSamplerOption) => {
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 analysis speed. Accuracy will slightly decrease.',
}
),
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',
}
),
};
}
};

View file

@ -6,24 +6,14 @@
*/
import type { FC } from 'react';
import React, { 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 { useMemo } from 'react';
import React, { useState } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiPanel } from '@elastic/eui';
import { RandomSamplerRangeSlider } from './random_sampler_range_slider';
import type { RandomSampler, RandomSamplerOption } from './random_sampler';
import { RANDOM_SAMPLER_OPTION, RANDOM_SAMPLER_SELECT_OPTIONS } from './random_sampler';
import useObservable from 'react-use/lib/useObservable';
import type { RandomSampler } from './random_sampler';
import { randomSamplerText } from './random_sampler';
import { SamplingPanel } from './sampling_panel';
interface Props {
randomSampler: RandomSampler;
@ -33,82 +23,12 @@ interface Props {
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 { buttonText } = useMemo(
() => randomSamplerText(randomSamplerPreference),
[randomSamplerPreference]
);
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"
@ -129,55 +49,8 @@ export const SamplingMenu: FC<Props> = ({ randomSampler, reload }) => {
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}
<SamplingPanel randomSampler={randomSampler} reload={reload} />
</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,124 @@
/*
* 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 { FC } from 'react';
import React, { useCallback, useMemo } from 'react';
import { EuiSpacer, EuiCallOut, EuiFormRow, EuiSuperSelect } 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 type { RandomSampler, RandomSamplerOption } from './random_sampler';
import { randomSamplerText } from './random_sampler';
import { RANDOM_SAMPLER_OPTION, RANDOM_SAMPLER_SELECT_OPTIONS } from './random_sampler';
interface Props {
randomSampler: RandomSampler;
calloutPosition?: 'top' | 'bottom';
reload: () => void;
}
export const SamplingPanel: FC<Props> = ({ randomSampler, reload, calloutPosition = 'top' }) => {
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 } = useMemo(
() => randomSamplerText(randomSamplerPreference),
[randomSamplerPreference]
);
return (
<>
{calloutPosition === 'top' ? (
<CalloutInfoMessage
calloutInfoMessage={calloutInfoMessage}
calloutPosition={calloutPosition}
/>
) : null}
<EuiFormRow
data-test-subj="aiopsRandomSamplerOptionsFormRow"
label={i18n.translate(
'xpack.aiops.logCategorization.randomSamplerSettingsPopUp.randomSamplerRowLabel',
{
defaultMessage: 'Random sampling',
}
)}
helpText={
randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_AUTOMATIC ? (
<ProbabilityUsedMessage samplingProbability={samplingProbability} />
) : null
}
>
<EuiSuperSelect
data-test-subj="aiopsRandomSamplerOptionsSelect"
options={RANDOM_SAMPLER_SELECT_OPTIONS}
valueOfSelected={randomSamplerPreference}
onChange={setRandomSamplerPreference}
/>
</EuiFormRow>
{randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_MANUAL ? (
<RandomSamplerRangeSlider
samplingProbability={samplingProbability}
setSamplingProbability={setSamplingProbability}
/>
) : null}
{calloutPosition === 'bottom' ? (
<CalloutInfoMessage
calloutInfoMessage={calloutInfoMessage}
calloutPosition={calloutPosition}
/>
) : null}
</>
);
};
const ProbabilityUsedMessage: FC<{ samplingProbability: number | null }> = ({
samplingProbability,
}) => {
return samplingProbability !== null ? (
<div data-test-subj="aiopsRandomSamplerProbabilityUsedMsg">
<FormattedMessage
id="xpack.aiops.logCategorization.randomSamplerSettingsPopUp.probabilityLabel"
defaultMessage="Probability used: {samplingProbability}%"
values={{ samplingProbability: Number((samplingProbability * 100).toPrecision(3)) }}
/>
</div>
) : null;
};
const CalloutInfoMessage: FC<{
calloutInfoMessage: string;
calloutPosition: 'top' | 'bottom';
}> = ({ calloutInfoMessage, calloutPosition }) => (
<>
{calloutPosition === 'bottom' ? <EuiSpacer size="s" /> : null}
<EuiCallOut size="s" color={'primary'} title={calloutInfoMessage} />
{calloutPosition === 'top' ? <EuiSpacer size="s" /> : null}
</>
);

View file

@ -85,7 +85,9 @@ export function useCategorizeRequest() {
query,
wrap,
intervalMs,
additionalFilter
additionalFilter,
true,
additionalFilter === undefined // don't include the outer sparkline if there is an additional filter
),
{ abortSignal: abortController.current.signal }
)

View file

@ -51,6 +51,7 @@ export function useValidateFieldRequest() {
}),
headers,
version: '1',
signal: abortController.current.signal,
}
);

View file

@ -0,0 +1,60 @@
/*
* 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 { stringHash } from '@kbn/ml-string-hash';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { ES_FIELD_TYPES } from '@kbn/field-types';
import type { DocumentStats } from '../../hooks/use_document_count_stats';
/**
* Creates a hash from the document stats to determine if the document stats have changed.
*/
export function createDocumentStatsHash(documentStats: DocumentStats) {
const lastTimeStampMs = documentStats.documentCountStats?.lastDocTimeStampMs;
const totalCount = documentStats.documentCountStats?.totalCount;
const times = Object.keys(documentStats.documentCountStats?.buckets ?? {});
const firstBucketTimeStamp = times.length ? times[0] : undefined;
const lastBucketTimeStamp = times.length ? times[times.length - 1] : undefined;
return stringHash(`${lastTimeStampMs}${totalCount}${firstBucketTimeStamp}${lastBucketTimeStamp}`);
}
export function createAdditionalConfigHash(additionalStrings: string[] = []) {
return stringHash(`${additionalStrings.join('')}`);
}
/**
* Retrieves the message field from a DataView object.
* If the message field is not found, it falls back to error.message or event.original or the first text field in the DataView.
*
* @param dataView - The DataView object containing the fields.
* @returns An object containing the message field and all the fields in the DataView.
*/
export function getMessageField(dataView: DataView): {
messageField: DataViewField | null;
dataViewFields: DataViewField[];
} {
const dataViewFields = dataView.fields.filter((f) => f.esTypes?.includes(ES_FIELD_TYPES.TEXT));
let messageField: DataViewField | null | undefined = dataViewFields.find(
(f) => f.name === 'message'
);
if (messageField === undefined) {
messageField = dataViewFields.find((f) => f.name === 'error.message');
}
if (messageField === undefined) {
messageField = dataViewFields.find((f) => f.name === 'event.original');
}
if (messageField === undefined) {
if (dataViewFields.length > 0) {
messageField = dataViewFields[0];
} else {
messageField = null;
}
}
return { messageField, dataViewFields };
}

View file

@ -152,6 +152,7 @@ export const LogRateAnalysisContent: FC<LogRateAnalysisContentProps> = ({
currentSelectedSignificantItem,
currentSelectedGroup,
undefined,
true,
timeRange
);

View file

@ -36,6 +36,7 @@ export const useData = (
selectedSignificantItem?: SignificantItem,
selectedGroup: GroupTableItem | null = null,
barTarget: number = DEFAULT_BAR_TARGET,
changePointsByDefault = true,
timeRange?: { min: Moment; max: Moment }
) => {
const { executionContext, uiSettings } = useAiopsAppContext();
@ -103,7 +104,8 @@ export const useData = (
const documentStats = useDocumentCountStats(
overallStatsRequest,
selectedSignificantItemStatsRequest,
lastRefresh
lastRefresh,
changePointsByDefault
);
useEffect(() => {
@ -111,12 +113,15 @@ export const useData = (
timefilter.getAutoRefreshFetch$(),
timefilter.getTimeUpdate$(),
mlTimefilterRefresh$
).subscribe(() => {
).subscribe((done) => {
if (onUpdate) {
onUpdate({
time: timefilter.getTime(),
refreshInterval: timefilter.getRefreshInterval(),
});
if (typeof done === 'function') {
done();
}
}
setLastRefresh(Date.now());
});

View file

@ -56,7 +56,8 @@ function displayError(toastNotifications: ToastsStart, index: string, err: any)
export function useDocumentCountStats<TParams extends DocumentStatsSearchStrategyParams>(
searchParams: TParams | undefined,
searchParamsCompare: TParams | undefined,
lastRefresh: number
lastRefresh: number,
changePointsByDefault = true
): DocumentStats {
const {
data,
@ -96,7 +97,7 @@ export function useDocumentCountStats<TParams extends DocumentStatsSearchStrateg
const totalHitsResp = await lastValueFrom(
data.search.search(
{
params: getDocumentCountStatsRequest(totalHitsParams, undefined, true),
params: getDocumentCountStatsRequest(totalHitsParams, undefined, changePointsByDefault),
},
{ abortSignal: abortCtrl.current.signal }
)
@ -116,7 +117,7 @@ export function useDocumentCountStats<TParams extends DocumentStatsSearchStrateg
{ ...searchParams, trackTotalHits: false },
randomSamplerWrapper,
false,
searchParamsCompare === undefined
searchParamsCompare === undefined && changePointsByDefault
),
},
{ abortSignal: abortCtrl.current.signal }

View file

@ -13,6 +13,8 @@ export function plugin() {
return new AiopsPlugin();
}
export type { AiopsPluginStart, AiopsPluginSetup } from './types';
export type { AiopsAppDependencies } from './hooks/use_aiops_app_context';
export type { LogRateAnalysisAppStateProps } from './components/log_rate_analysis';
export type { LogRateAnalysisContentWrapperProps } from './components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content_wrapper';

View file

@ -8,6 +8,8 @@
import type { CoreStart, Plugin } from '@kbn/core/public';
import { type CoreSetup } from '@kbn/core/public';
import { firstValueFrom } from 'rxjs';
import { dynamic } from '@kbn/shared-ux-utility';
import { getChangePointDetectionComponent } from './shared_components';
import type {
AiopsPluginSetup,
@ -59,6 +61,18 @@ export class AiopsPlugin
public start(core: CoreStart, plugins: AiopsPluginStartDeps): AiopsPluginStart {
return {
ChangePointDetectionComponent: getChangePointDetectionComponent(core, plugins),
getPatternAnalysisAvailable: async () => {
const { getPatternAnalysisAvailable } = await import(
'./components/log_categorization/log_categorization_enabled'
);
return getPatternAnalysisAvailable(plugins.licensing);
},
PatternAnalysisComponent: dynamic(
async () =>
import(
'./components/log_categorization/log_categorization_for_embeddable/log_categorization_wrapper'
)
),
};
}

View file

@ -18,7 +18,10 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/
import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import type { CasesPublicSetup } from '@kbn/cases-plugin/public';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { ChangePointDetectionSharedComponent } from './shared_components';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { ChangePointDetectionSharedComponent } from '../shared_components';
import type { LogCategorizationEmbeddableWrapperProps } from '../components/log_categorization/log_categorization_for_embeddable/log_categorization_wrapper';
export interface AiopsPluginSetupDeps {
embeddable: EmbeddableSetup;
@ -45,5 +48,7 @@ export interface AiopsPluginStartDeps {
export type AiopsPluginSetup = void;
export interface AiopsPluginStart {
getPatternAnalysisAvailable: () => Promise<(dataView: DataView) => Promise<boolean>>;
PatternAnalysisComponent: React.ComponentType<LogCategorizationEmbeddableWrapperProps>;
ChangePointDetectionComponent: ChangePointDetectionSharedComponent;
}

View file

@ -6,6 +6,8 @@
*/
import { type FrozenTierPreference } from '@kbn/ml-date-picker';
import type { MinimumTimeRangeOption } from '../components/log_categorization/log_categorization_for_embeddable/minimum_time_range';
import {
type RandomSamplerOption,
type RandomSamplerProbability,
@ -15,11 +17,14 @@ 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 const AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE =
'aiops.patternAnalysisMinimumTimeRangePreference';
export type AiOps = Partial<{
[AIOPS_FROZEN_TIER_PREFERENCE]: FrozenTierPreference;
[AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE]: RandomSamplerOption;
[AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE]: number;
[AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE]: MinimumTimeRangeOption;
}> | null;
export type AiOpsKey = keyof Exclude<AiOps, null>;
@ -30,10 +35,13 @@ export type AiOpsStorageMapped<T extends AiOpsKey> = T extends typeof AIOPS_FROZ
? RandomSamplerOption
: T extends typeof AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE
? RandomSamplerProbability
: T extends typeof AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE
? MinimumTimeRangeOption
: null;
export const AIOPS_STORAGE_KEYS = [
AIOPS_FROZEN_TIER_PREFERENCE,
AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE,
AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE,
AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE,
] as const;

View file

@ -11,14 +11,28 @@
"types/**/*"
],
"kbn_references": [
"@kbn/aiops-components",
"@kbn/aiops-change-point-detection",
"@kbn/aiops-common",
"@kbn/aiops-components",
"@kbn/aiops-log-pattern-analysis",
"@kbn/aiops-log-rate-analysis",
"@kbn/aiops-test-utils",
"@kbn/analytics",
"@kbn/cases-plugin",
"@kbn/charts-plugin",
"@kbn/content-management-utils",
"@kbn/core-execution-context-browser",
"@kbn/core-http-server",
"@kbn/core-lifecycle-browser",
"@kbn/core-theme-browser",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/core",
"@kbn/data-plugin",
"@kbn/data-service",
"@kbn/data-views-plugin",
"@kbn/datemath",
"@kbn/ebt-tools",
"@kbn/embeddable-plugin",
"@kbn/es-query",
"@kbn/field-formats-plugin",
"@kbn/field-types",
@ -44,38 +58,25 @@
"@kbn/ml-response-stream",
"@kbn/ml-route-utils",
"@kbn/ml-string-hash",
"@kbn/ml-time-buckets",
"@kbn/ml-ui-actions",
"@kbn/ml-url-state",
"@kbn/presentation-containers",
"@kbn/presentation-publishing",
"@kbn/presentation-util-plugin",
"@kbn/react-kibana-context-render",
"@kbn/react-kibana-context-theme",
"@kbn/react-kibana-mount",
"@kbn/rison",
"@kbn/saved-search-plugin",
"@kbn/search-types",
"@kbn/share-plugin",
"@kbn/shared-ux-utility",
"@kbn/ui-actions-plugin",
"@kbn/unified-field-list",
"@kbn/unified-search-plugin",
"@kbn/utility-types",
"@kbn/presentation-util-plugin",
"@kbn/embeddable-plugin",
"@kbn/core-lifecycle-browser",
"@kbn/cases-plugin",
"@kbn/react-kibana-mount",
"@kbn/usage-collection-plugin",
"@kbn/analytics",
"@kbn/ml-ui-actions",
"@kbn/core-http-server",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/ml-time-buckets",
"@kbn/ebt-tools",
"@kbn/aiops-test-utils",
"@kbn/aiops-log-rate-analysis",
"@kbn/aiops-log-pattern-analysis",
"@kbn/aiops-change-point-detection",
"@kbn/react-kibana-context-theme",
"@kbn/react-kibana-context-render",
"@kbn/presentation-publishing",
"@kbn/data-service",
"@kbn/shared-ux-utility",
"@kbn/presentation-containers",
"@kbn/search-types",
"@kbn/content-management-utils",
"@kbn/utility-types",
],
"exclude": [
"target/**/*",

View file

@ -6792,7 +6792,6 @@
"unifiedFieldList.fieldStats.filterOutValueButtonAriaLabel": "Exclure le {field} : \"{value}\"",
"unifiedFieldList.fieldStats.filterValueButtonAriaLabel": "Filtrer sur le {field} : \"{value}\"",
"unifiedFieldList.fieldStats.noFieldDataInSampleDescription": "Aucune donnée de champ pour {sampledDocumentsFormatted} {sampledDocuments, plural, one {exemple d'enregistrement} other {exemples d'enregistrement}}.",
"unifiedFieldList.fieldCategorizeButton.label": "Exécuter l'analyse du modèle",
"unifiedFieldList.fieldItemButton.mappingConflictDescription": "Ce champ est défini avec plusieurs types (chaîne, entier, etc.) dans les différents index qui correspondent à ce modèle. Vous pouvez toujours utiliser ce champ conflictuel, mais il sera indisponible pour les fonctions qui nécessitent que Kibana en connaisse le type. Pour corriger ce problème, vous devrez réindexer vos données.",
"unifiedFieldList.fieldItemButton.mappingConflictTitle": "Conflit de mapping",
"unifiedFieldList.fieldList.noFieldsCallout.noDataLabel": "Aucun champ.",

View file

@ -6781,7 +6781,6 @@
"unifiedFieldList.fieldStats.filterOutValueButtonAriaLabel": "{field}を除外:\"{value}\"",
"unifiedFieldList.fieldStats.filterValueButtonAriaLabel": "{field}を除外:\"{value}\"",
"unifiedFieldList.fieldStats.noFieldDataInSampleDescription": "{sampledDocumentsFormatted}サンプル{sampledDocuments, plural, other {レコード}}のフィールドデータがありません。",
"unifiedFieldList.fieldCategorizeButton.label": "パターン分析を実行",
"unifiedFieldList.fieldItemButton.mappingConflictDescription": "このフィールドは、このパターンと一致するインデックス全体に対して複数の型文字列、整数などとして定義されています。この競合フィールドを使用することはできますが、Kibana で型を認識する必要がある関数では使用できません。この問題を修正するにはデータのレンダリングが必要です。",
"unifiedFieldList.fieldItemButton.mappingConflictTitle": "マッピングの矛盾",
"unifiedFieldList.fieldList.noFieldsCallout.noDataLabel": "フィールドがありません。",

View file

@ -6795,7 +6795,6 @@
"unifiedFieldList.fieldStats.filterOutValueButtonAriaLabel": "筛除 {field}:“{value}”",
"unifiedFieldList.fieldStats.filterValueButtonAriaLabel": "筛留 {field}:“{value}”",
"unifiedFieldList.fieldStats.noFieldDataInSampleDescription": "{sampledDocumentsFormatted} 个样例{sampledDocuments, plural, other {记录}}无字段数据。",
"unifiedFieldList.fieldCategorizeButton.label": "运行模式分析",
"unifiedFieldList.fieldItemButton.mappingConflictDescription": "此字段在匹配此模式的各个索引中已定义为若干类型(字符串、整数等)。您可能仍可以使用此冲突字段,但它无法用于需要 Kibana 知道其类型的函数。要解决此问题,需要重新索引您的数据。",
"unifiedFieldList.fieldItemButton.mappingConflictTitle": "映射冲突",
"unifiedFieldList.fieldList.noFieldsCallout.noDataLabel": "无字段。",

View file

@ -14,7 +14,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const retry = getService('retry');
const ml = getService('ml');
const PageObjects = getPageObjects(['common', 'timePicker', 'discover']);
const selectedField = '@message';
const totalDocCount = 14005;
async function retrySwitchTab(tabIndex: number, seconds: number) {
@ -55,15 +54,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.discover.selectIndexPattern('logstash-*');
await aiops.logPatternAnalysisPage.assertDiscoverDocCount(totalDocCount);
await aiops.logPatternAnalysisPage.clickDiscoverField(selectedField);
await aiops.logPatternAnalysisPage.clickDiscoverMenuAnalyzeButton(selectedField);
await aiops.logPatternAnalysisPage.clickPatternsTab();
await aiops.logPatternAnalysisPage.assertLogPatternAnalysisTabContentsExists();
await aiops.logPatternAnalysisPage.assertLogPatternAnalysisFlyoutExists();
await aiops.logPatternAnalysisPage.assertLogPatternAnalysisFlyoutTitle(selectedField);
await aiops.logPatternAnalysisPage.setRandomSamplingOptionDiscover(
'aiopsRandomSamplerOptionOff'
);
await aiops.logPatternAnalysisPage.setRandomSamplingOption('aiopsRandomSamplerOptionOff');
await aiops.logPatternAnalysisPage.assertTotalCategoriesFound(3);
await aiops.logPatternAnalysisPage.assertTotalCategoriesFoundDiscover(3);
await aiops.logPatternAnalysisPage.assertCategoryTableRows(3);
// get category count from the first row
@ -87,15 +85,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
);
await aiops.logPatternAnalysisPage.assertDiscoverDocCount(totalDocCount);
await aiops.logPatternAnalysisPage.clickDiscoverField(selectedField);
await aiops.logPatternAnalysisPage.clickDiscoverMenuAnalyzeButton(selectedField);
await aiops.logPatternAnalysisPage.clickPatternsTab();
await aiops.logPatternAnalysisPage.assertLogPatternAnalysisTabContentsExists();
await aiops.logPatternAnalysisPage.assertLogPatternAnalysisFlyoutExists();
await aiops.logPatternAnalysisPage.assertLogPatternAnalysisFlyoutTitle(selectedField);
await aiops.logPatternAnalysisPage.setRandomSamplingOptionDiscover(
'aiopsRandomSamplerOptionOff'
);
await aiops.logPatternAnalysisPage.setRandomSamplingOption('aiopsRandomSamplerOptionOff');
await aiops.logPatternAnalysisPage.assertTotalCategoriesFound(3);
await aiops.logPatternAnalysisPage.assertTotalCategoriesFoundDiscover(3);
await aiops.logPatternAnalysisPage.assertCategoryTableRows(3);
// get category count from the first row

View file

@ -35,6 +35,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./index_data_visualizer_random_sampler'));
loadTestFile(require.resolve('./index_data_visualizer_filters'));
loadTestFile(require.resolve('./index_data_visualizer_grid_in_discover'));
loadTestFile(require.resolve('./index_data_visualizer_grid_in_discover_trial'));
loadTestFile(require.resolve('./index_data_visualizer_grid_in_dashboard'));
loadTestFile(require.resolve('./index_data_visualizer_actions_panel'));
loadTestFile(require.resolve('./index_data_visualizer_data_view_management'));

View file

@ -28,24 +28,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const startTime = 'Jan 1, 2016 @ 00:00:00.000';
const endTime = 'Nov 1, 2020 @ 00:00:00.000';
function runTestsWhenDisabled(testData: TestData) {
it('should not show view mode toggle or Field stats table', async function () {
await PageObjects.common.navigateToApp('discover');
if (testData.isSavedSearch) {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.discover.loadSavedSearch(testData.sourceIndexOrSavedSearch);
});
} else {
await dataViews.switchToAndValidate(testData.sourceIndexOrSavedSearch);
}
await PageObjects.timePicker.setAbsoluteRange(startTime, endTime);
await PageObjects.discover.assertViewModeToggleNotExists();
await PageObjects.discover.assertFieldStatsTableNotExists();
});
}
function runTests(testData: TestData) {
describe(`with ${testData.suiteTitle}`, function () {
it(`displays the 'Field statistics' table content correctly`, async function () {
@ -128,14 +110,5 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
runTests(farequoteLuceneFiltersSearchTestData);
runTests(sampleLogTestData);
});
describe('when disabled', function () {
before(async function () {
// Ensure that the setting is set to default state which is false
await ml.testResources.setAdvancedSettingProperty(SHOW_FIELD_STATISTICS, false);
});
runTestsWhenDisabled(farequoteDataViewTestData);
});
});
}

View file

@ -0,0 +1,70 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
import { TestData } from './types';
const SHOW_FIELD_STATISTICS = 'discover:showFieldStatistics';
import { farequoteDataViewTestData } from './index_test_data';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'settings']);
const ml = getService('ml');
const retry = getService('retry');
const dataViews = getService('dataViews');
const startTime = 'Jan 1, 2016 @ 00:00:00.000';
const endTime = 'Nov 1, 2020 @ 00:00:00.000';
function runTestsWhenDisabled(testData: TestData) {
it('should not show view mode toggle or Field stats table', async function () {
await PageObjects.common.navigateToApp('discover');
if (testData.isSavedSearch) {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.discover.loadSavedSearch(testData.sourceIndexOrSavedSearch);
});
} else {
await dataViews.switchToAndValidate(testData.sourceIndexOrSavedSearch);
}
await PageObjects.timePicker.setAbsoluteRange(startTime, endTime);
await PageObjects.discover.assertViewModeToggleNotExists();
await PageObjects.discover.assertFieldStatsTableNotExists();
});
}
describe('field statistics in Discover (basic license)', function () {
before(async function () {
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
await ml.testResources.createDataViewIfNeeded('ft_farequote', '@timestamp');
await ml.testResources.createDataViewIfNeeded('ft_module_sample_logs', '@timestamp');
await ml.testResources.createSavedSearchFarequoteKueryIfNeeded();
await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded();
await ml.testResources.createSavedSearchFarequoteFilterAndLuceneIfNeeded();
await ml.testResources.createSavedSearchFarequoteFilterAndKueryIfNeeded();
await ml.securityUI.loginAsMlPowerUser();
});
after(async function () {
await ml.testResources.clearAdvancedSettingProperty(SHOW_FIELD_STATISTICS);
await ml.testResources.deleteSavedSearches();
await ml.testResources.deleteDataViewByTitle('ft_farequote');
});
describe('when disabled', function () {
before(async function () {
// Ensure that the setting is set to default state which is false
await ml.testResources.setAdvancedSettingProperty(SHOW_FIELD_STATISTICS, false);
});
runTestsWhenDisabled(farequoteDataViewTestData);
});
});
}

View file

@ -0,0 +1,70 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
import { TestData } from './types';
const SHOW_FIELD_STATISTICS = 'discover:showFieldStatistics';
import { farequoteDataViewTestData } from './index_test_data';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'settings']);
const ml = getService('ml');
const retry = getService('retry');
const dataViews = getService('dataViews');
const startTime = 'Jan 1, 2016 @ 00:00:00.000';
const endTime = 'Nov 1, 2020 @ 00:00:00.000';
function runTestsWhenDisabled(testData: TestData) {
it('should show view mode toggle but not Field stats tab', async function () {
await PageObjects.common.navigateToApp('discover');
if (testData.isSavedSearch) {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.discover.loadSavedSearch(testData.sourceIndexOrSavedSearch);
});
} else {
await dataViews.switchToAndValidate(testData.sourceIndexOrSavedSearch);
}
await PageObjects.timePicker.setAbsoluteRange(startTime, endTime);
await PageObjects.discover.assertViewModeToggleExists();
await PageObjects.discover.assertFieldStatsTableNotExists();
});
}
describe('field statistics in Discover (trial license)', function () {
before(async function () {
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
await ml.testResources.createDataViewIfNeeded('ft_farequote', '@timestamp');
await ml.testResources.createDataViewIfNeeded('ft_module_sample_logs', '@timestamp');
await ml.testResources.createSavedSearchFarequoteKueryIfNeeded();
await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded();
await ml.testResources.createSavedSearchFarequoteFilterAndLuceneIfNeeded();
await ml.testResources.createSavedSearchFarequoteFilterAndKueryIfNeeded();
await ml.securityUI.loginAsMlPowerUser();
});
after(async function () {
await ml.testResources.clearAdvancedSettingProperty(SHOW_FIELD_STATISTICS);
await ml.testResources.deleteSavedSearches();
await ml.testResources.deleteDataViewByTitle('ft_farequote');
});
describe('when disabled', function () {
before(async function () {
// Ensure that the setting is set to default state which is false
await ml.testResources.setAdvancedSettingProperty(SHOW_FIELD_STATISTICS, false);
});
runTestsWhenDisabled(farequoteDataViewTestData);
});
});
}

View file

@ -76,6 +76,17 @@ export function LogPatternAnalysisPageProvider({ getService, getPageObject }: Ft
});
},
async assertTotalCategoriesFoundDiscover(expectedMinimumCategoryCount: number) {
await retry.tryForTime(5000, async () => {
const actualText = await testSubjects.getVisibleText('dscViewModePatternAnalysisButton');
const actualCount = Number(actualText.match(/Patterns \((.+)\)/)![1]);
expect(actualCount + 1).to.greaterThan(
expectedMinimumCategoryCount,
`Expected patterns found count to be >= '${expectedMinimumCategoryCount}' (got '${actualCount}')`
);
});
},
async assertCategoryTableRows(expectedMinimumCategoryCount: number) {
await retry.tryForTime(5000, async () => {
const tableListContainer = await testSubjects.find('aiopsLogPatternsTable');
@ -170,12 +181,22 @@ export function LogPatternAnalysisPageProvider({ getService, getPageObject }: Ft
});
},
async clickPatternsTab() {
await testSubjects.click('dscViewModePatternAnalysisButton');
},
async assertLogPatternAnalysisFlyoutExists() {
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.existOrFail('mlJobSelectorFlyoutBody');
});
},
async assertLogPatternAnalysisTabContentsExists() {
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.existOrFail('aiopsLogPatternsTable');
});
},
async assertLogPatternAnalysisFlyoutDoesNotExist() {
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.missingOrFail('mlJobSelectorFlyoutBody');
@ -210,5 +231,23 @@ export function LogPatternAnalysisPageProvider({ getService, getPageObject }: Ft
await testSubjects.missingOrFail('aiopsRandomSamplerOptionsFormRow', { timeout: 1000 });
});
},
async setRandomSamplingOptionDiscover(option: RandomSamplerOption) {
await retry.tryForTime(20000, async () => {
await testSubjects.existOrFail('aiopsEmbeddableMenuOptionsButton');
await testSubjects.clickWhenNotDisabled('aiopsEmbeddableMenuOptionsButton');
await testSubjects.clickWhenNotDisabled('aiopsRandomSamplerOptionsSelect');
await testSubjects.existOrFail('aiopsRandomSamplerOptionOff', { timeout: 1000 });
await testSubjects.existOrFail('aiopsRandomSamplerOptionOnManual', { timeout: 1000 });
await testSubjects.existOrFail('aiopsRandomSamplerOptionOnAutomatic', { timeout: 1000 });
await testSubjects.click(option);
await testSubjects.clickWhenNotDisabled('aiopsEmbeddableMenuOptionsButton');
await testSubjects.missingOrFail('aiopsRandomSamplerOptionsFormRow', { timeout: 1000 });
});
},
};
}

View file

@ -41,6 +41,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
'../../../../../functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover'
)
);
loadTestFile(
require.resolve(
'../../../../../functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover_basic'
)
);
loadTestFile(require.resolve('./index_data_visualizer_actions_panel'));
});
}