diff --git a/packages/kbn-unified-field-list/src/components/field_categorize_button/categorize_trigger_utils.test.ts b/packages/kbn-unified-field-list/src/components/field_categorize_button/categorize_trigger_utils.test.ts deleted file mode 100644 index a0efbaf8de869..0000000000000 --- a/packages/kbn-unified-field-list/src/components/field_categorize_button/categorize_trigger_utils.test.ts +++ /dev/null @@ -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>>, [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); - }); - }); -}); diff --git a/packages/kbn-unified-field-list/src/components/field_categorize_button/categorize_trigger_utils.ts b/packages/kbn-unified-field-list/src/components/field_categorize_button/categorize_trigger_utils.ts deleted file mode 100644 index 007a88b2c7f97..0000000000000 --- a/packages/kbn-unified-field-list/src/components/field_categorize_button/categorize_trigger_utils.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button.test.tsx b/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button.test.tsx deleted file mode 100644 index 45569b3443370..0000000000000 --- a/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button.test.tsx +++ /dev/null @@ -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]); -jest.spyOn(uiActions, 'getTrigger').mockReturnValue({ - id: ACTION_CATEGORIZE_FIELD, - exec: mockExecuteAction, -} as unknown as TriggerContract); - -describe('UnifiedFieldList ', () => { - 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); - }); -}); diff --git a/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button.tsx b/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button.tsx deleted file mode 100644 index 5914c330f6661..0000000000000 --- a/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button.tsx +++ /dev/null @@ -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; - closePopover?: () => void; -} - -export const FieldCategorizeButton: React.FC = React.memo( - ({ field, dataView, trackUiMetric, originatingApp, uiActions, buttonProps, closePopover }) => { - const handleVisualizeLinkClick = async ( - event: React.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 ( - - ); - } -); - -export async function getFieldCategorizeButton(props: FieldCategorizeButtonProps) { - const showButton = await canCategorize(props.uiActions, props.field, props.dataView); - return showButton ? : null; -} diff --git a/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button_inner.tsx b/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button_inner.tsx deleted file mode 100644 index 8571822dcd8e9..0000000000000 --- a/packages/kbn-unified-field-list/src/components/field_categorize_button/field_categorize_button_inner.tsx +++ /dev/null @@ -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) => void; - buttonProps?: Partial; -} - -export const FieldCategorizeButtonInner: React.FC = ({ - fieldName, - handleVisualizeLinkClick, - buttonProps, -}) => { - return ( - <> - - - - - ); -}; diff --git a/packages/kbn-unified-field-list/src/components/field_popover/field_popover_footer.tsx b/packages/kbn-unified-field-list/src/components/field_popover/field_popover_footer.tsx index bde3120708ef6..95378ba8b327b 100644 --- a/packages/kbn-unified-field-list/src/components/field_popover/field_popover_footer.tsx +++ b/packages/kbn-unified-field-list/src/components/field_popover/field_popover_footer.tsx @@ -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 = (props) => { const [visualizeButton, setVisualizeButton] = useState(null); - const [categorizeButton, setCategorizeButton] = useState(null); useEffect(() => { getFieldVisualizeButton(props) @@ -25,21 +23,9 @@ const FieldPopoverFooterComponent: React.FC = (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 ? ( - - {visualizeButton} - {visualizeButton && categorizeButton ? : null} - {categorizeButton} - - ) : null; + return visualizeButton ? {visualizeButton} : null; }; export const FieldPopoverFooter: React.FC = (props) => { diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx index 409184c4d8e04..d9e02d423cd9e 100644 --- a/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx +++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx @@ -309,7 +309,6 @@ function UnifiedFieldListItemComponent({ contextualFields={workspaceSelectedFieldNames} originatingApp={stateService.creationOptions.originatingApp} uiActions={services.uiActions} - closePopover={() => closePopover()} /> )} diff --git a/packages/kbn-unified-field-list/tsconfig.json b/packages/kbn-unified-field-list/tsconfig.json index 6dc268783d89a..acad1797d26ff 100644 --- a/packages/kbn-unified-field-list/tsconfig.json +++ b/packages/kbn-unified-field-list/tsconfig.json @@ -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" diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 7697bc0ec1858..08c1c039d5e79 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -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", diff --git a/src/plugins/discover/common/constants.ts b/src/plugins/discover/common/constants.ts index 8ac1280592ff6..5a203cba55878 100644 --- a/src/plugins/discover/common/constants.ts +++ b/src/plugins/discover/common/constants.ts @@ -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 => { diff --git a/src/plugins/discover/kibana.jsonc b/src/plugins/discover/kibana.jsonc index c9714054ee694..b35d9aa8dd489 100644 --- a/src/plugins/discover/kibana.jsonc +++ b/src/plugins/discover/kibana.jsonc @@ -39,7 +39,8 @@ "lens", "noDataPage", "globalSearch", - "observabilityAIAssistant" + "observabilityAIAssistant", + "aiops" ], "requiredBundles": ["kibanaUtils", "kibanaReact", "unifiedSearch", "savedObjects"], "extraPublicDirs": ["common"] diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 2a0ffa9f7cb97..e4e2b71de8e74 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -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: { diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index cc141ce3fd567..d141b89e453e3 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -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; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx index 2ea9825d3c5bd..343ceae3ed995 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx @@ -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); + }); }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx b/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx index 23e4001a39459..7c44ed1deff83 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx @@ -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 ? ( - - ) : ( - - ); - }, [ - shouldShowViewModeToggle, - viewMode, - isEsqlMode, - stateContainer, - setDiscoverViewMode, - panelsToggle, - isChartAvailable, - ]); + const renderViewModeToggle = useCallback( + (patternCount?: number) => { + return ( + + ); + }, + [ + 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 ? ( <> {viewModeToggle} - )} + ) : null} + {viewMode === VIEW_MODE.PATTERN_LEVEL ? ( + setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL)} + trackUiMetric={trackUiMetric} + renderViewModeToggle={renderViewModeToggle} + /> + ) : null} diff --git a/src/plugins/discover/public/application/main/components/pattern_analysis/constants.ts b/src/plugins/discover/public/application/main/components/pattern_analysis/constants.ts new file mode 100644 index 0000000000000..8441a308965e7 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/pattern_analysis/constants.ts @@ -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'; diff --git a/packages/kbn-unified-field-list/src/components/field_categorize_button/index.tsx b/src/plugins/discover/public/application/main/components/pattern_analysis/index.ts old mode 100755 new mode 100644 similarity index 61% rename from packages/kbn-unified-field-list/src/components/field_categorize_button/index.tsx rename to src/plugins/discover/public/application/main/components/pattern_analysis/index.ts index 8a07a8290ca84..8741f1040edb4 --- a/packages/kbn-unified-field-list/src/components/field_categorize_button/index.tsx +++ b/src/plugins/discover/public/application/main/components/pattern_analysis/index.ts @@ -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'; diff --git a/src/plugins/discover/public/application/main/components/pattern_analysis/pattern_analysis_tab.tsx b/src/plugins/discover/public/application/main/components/pattern_analysis/pattern_analysis_tab.tsx new file mode 100644 index 0000000000000..db02134e0169f --- /dev/null +++ b/src/plugins/discover/public/application/main/components/pattern_analysis/pattern_analysis_tab.tsx @@ -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> = memo( + (props) => { + const services = useDiscoverServices(); + const querySubscriberResult = useQuerySubscriber({ + data: services.data, + }); + const savedSearch = useSavedSearch(); + + return ( + + ); + } +); diff --git a/src/plugins/discover/public/application/main/components/pattern_analysis/pattern_analysis_table.tsx b/src/plugins/discover/public/application/main/components/pattern_analysis/pattern_analysis_table.tsx new file mode 100644 index 0000000000000..ccbb2a6e72b8c --- /dev/null +++ b/src/plugins/discover/public/application/main/components/pattern_analysis/pattern_analysis_table.tsx @@ -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(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 ( + + ); +}; diff --git a/src/plugins/discover/public/application/main/hooks/use_esql_mode.test.tsx b/src/plugins/discover/public/application/main/hooks/use_esql_mode.test.tsx index 5ae16f0429288..b24bcd3eb42d5 100644 --- a/src/plugins/discover/public/application/main/hooks/use_esql_mode.test.tsx +++ b/src/plugins/discover/public/application/main/hooks/use_esql_mode.test.tsx @@ -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$; diff --git a/src/plugins/discover/public/application/main/state_management/utils/get_state_defaults.test.ts b/src/plugins/discover/public/application/main/state_management/utils/get_state_defaults.test.ts index ac85a24f91f34..28c562d3e7051 100644 --- a/src/plugins/discover/public/application/main/state_management/utils/get_state_defaults.test.ts +++ b/src/plugins/discover/public/application/main/state_management/utils/get_state_defaults.test.ts @@ -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!, }) diff --git a/src/plugins/discover/public/application/main/utils/get_valid_view_mode.test.ts b/src/plugins/discover/public/application/main/utils/get_valid_view_mode.test.ts index 51530926defbf..ff2d4250b3da8 100644 --- a/src/plugins/discover/public/application/main/utils/get_valid_view_mode.test.ts +++ b/src/plugins/discover/public/application/main/utils/get_valid_view_mode.test.ts @@ -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); }); }); diff --git a/src/plugins/discover/public/application/main/utils/get_valid_view_mode.ts b/src/plugins/discover/public/application/main/utils/get_valid_view_mode.ts index eab9677f083b2..03c3500b7ab2d 100644 --- a/src/plugins/discover/public/application/main/utils/get_valid_view_mode.ts +++ b/src/plugins/discover/public/application/main/utils/get_valid_view_mode.ts @@ -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; } diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 91242f3b1cb93..e3524dcdf115c 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -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, diff --git a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx index afc59b6e7d0c6..4d266af5e7949 100644 --- a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx +++ b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx @@ -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( ); + + 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 }); - expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(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', () => { - const component = mountComponent({ isEsqlMode: true }); + 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 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); + }); }); diff --git a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx index b9b036e266b4e..11351893b6a26 100644 --- a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx +++ b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx @@ -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(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 ( )} - {isEsqlMode || !showViewModeToggle ? ( + {isEsqlMode || (showFieldStatisticsTab === false && showPatternAnalysisTab === false) ? ( ) : ( @@ -84,16 +133,33 @@ export const DocumentViewModeToggle = ({ - setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)} - data-test-subj="dscViewModeFieldStatsButton" - > - - + + {showPatternAnalysisTab ? ( + setDiscoverViewMode(VIEW_MODE.PATTERN_LEVEL)} + data-test-subj="dscViewModePatternAnalysisButton" + > + + + ) : null} + + {showFieldStatisticsTab ? ( + setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)} + data-test-subj="dscViewModeFieldStatsButton" + > + + + ) : null} )} diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index a5ab80cbf4857..2eb34b20345e4 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -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; diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index bf4c06f1c5dbe..970f8753a8cdc 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -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/**/*"] diff --git a/src/plugins/saved_search/common/content_management/v1/cm_services.ts b/src/plugins/saved_search/common/content_management/v1/cm_services.ts index bc9d18b21e5b7..ef9d24bb8722d 100644 --- a/src/plugins/saved_search/common/content_management/v1/cm_services.ts +++ b/src/plugins/saved_search/common/content_management/v1/cm_services.ts @@ -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()), diff --git a/src/plugins/saved_search/common/index.ts b/src/plugins/saved_search/common/index.ts index 0ac92232fb3b8..f0569a86ca39a 100644 --- a/src/plugins/saved_search/common/index.ts +++ b/src/plugins/saved_search/common/index.ts @@ -19,6 +19,7 @@ export type { export enum VIEW_MODE { DOCUMENT_LEVEL = 'documents', AGGREGATED_LEVEL = 'aggregated', + PATTERN_LEVEL = 'patterns', } export { diff --git a/src/plugins/saved_search/server/saved_objects/schema.ts b/src/plugins/saved_search/server/saved_objects/schema.ts index fb0308915fe72..125ddcceb320c 100644 --- a/src/plugins/saved_search/server/saved_objects/schema.ts +++ b/src/plugins/saved_search/server/saved_objects/schema.ts @@ -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), + ]) + ), +}); diff --git a/src/plugins/saved_search/server/saved_objects/search.ts b/src/plugins/saved_search/server/saved_objects/search.ts index 6c6a9bb81c1ed..925a4dd66b180 100644 --- a/src/plugins/saved_search/server/saved_objects/search.ts +++ b/src/plugins/saved_search/server/saved_objects/search.ts @@ -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, diff --git a/test/functional/apps/discover/group6/_view_mode_toggle.ts b/test/functional/apps/discover/group6/_view_mode_toggle.ts index eada1ec26f7aa..ba964c7532d70 100644 --- a/test/functional/apps/discover/group6/_view_mode_toggle.ts +++ b/test/functional/apps/discover/group6/_view_mode_toggle.ts @@ -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'); diff --git a/x-pack/packages/ml/aiops_log_pattern_analysis/create_category_request.ts b/x-pack/packages/ml/aiops_log_pattern_analysis/create_category_request.ts index 0a15fd50e1c4b..d32970cf4c519 100644 --- a/x-pack/packages/ml/aiops_log_pattern_analysis/create_category_request.ts +++ b/x-pack/packages/ml/aiops_log_pattern_analysis/create_category_request.ts @@ -32,7 +32,8 @@ export function createCategoryRequest( wrap: ReturnType['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: { diff --git a/x-pack/packages/ml/aiops_log_pattern_analysis/embeddable.ts b/x-pack/packages/ml/aiops_log_pattern_analysis/embeddable.ts new file mode 100644 index 0000000000000..57c6b144c15d0 --- /dev/null +++ b/x-pack/packages/ml/aiops_log_pattern_analysis/embeddable.ts @@ -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 { + dataView: DataView; + savedSearch?: SavedSearch | null; + query?: T; + filters?: Filter[]; + embeddingOrigin?: string; + switchToDocumentView?: () => void; + lastReloadRequestTime?: number; +} diff --git a/x-pack/packages/ml/aiops_log_pattern_analysis/process_category_results.ts b/x-pack/packages/ml/aiops_log_pattern_analysis/process_category_results.ts index af98fa0d71e02..6a98c0ac4a5ea 100644 --- a/x-pack/packages/ml/aiops_log_pattern_analysis/process_category_results.ts +++ b/x-pack/packages/ml/aiops_log_pattern_analysis/process_category_results.ts @@ -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>((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>((acc, cur) => { + acc[cur.key] = cur.doc_count; + return acc; + }, {}); +} diff --git a/x-pack/packages/ml/aiops_log_pattern_analysis/tsconfig.json b/x-pack/packages/ml/aiops_log_pattern_analysis/tsconfig.json index edf2bc92db1a3..ac3480d1d5a8b 100644 --- a/x-pack/packages/ml/aiops_log_pattern_analysis/tsconfig.json +++ b/x-pack/packages/ml/aiops_log_pattern_analysis/tsconfig.json @@ -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", ] } diff --git a/x-pack/packages/ml/aiops_log_pattern_analysis/types.ts b/x-pack/packages/ml/aiops_log_pattern_analysis/types.ts index 06179262946e2..2ea11170df226 100644 --- a/x-pack/packages/ml/aiops_log_pattern_analysis/types.ts +++ b/x-pack/packages/ml/aiops_log_pattern_analysis/types.ts @@ -13,6 +13,7 @@ export interface Category { subTimeRangeCount?: number; subFieldCount?: number; subFieldExamples?: string[]; + subFieldSparkline?: Record; examples: string[]; sparkline?: Record; 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; }>; }; }>; diff --git a/x-pack/packages/ml/date_picker/index.ts b/x-pack/packages/ml/date_picker/index.ts index 1a949a5d1e1d1..b68ce8d9314c0 100644 --- a/x-pack/packages/ml/date_picker/index.ts +++ b/x-pack/packages/ml/date_picker/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +export { getTimeFieldRange } from './src/services/time_field_range'; export { DatePickerContextProvider, type DatePickerDependencies, diff --git a/x-pack/packages/ml/date_picker/src/services/time_field_range.ts b/x-pack/packages/ml/date_picker/src/services/time_field_range.ts index 92d71f582a1ef..f935c4577b394 100644 --- a/x-pack/packages/ml/date_picker/src/services/time_field_range.ts +++ b/x-pack/packages/ml/date_picker/src/services/time_field_range.ts @@ -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({ path, method: 'POST', body: JSON.stringify(body), version: '1', + ...(signal ? { signal } : {}), }); } diff --git a/x-pack/plugins/aiops/public/components/log_categorization/category_table/category_table.tsx b/x-pack/plugins/aiops/public/components/log_categorization/category_table/category_table.tsx index cf103f4905b20..91e18ea7b4421 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/category_table/category_table.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/category_table/category_table.tsx @@ -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; enableRowActions?: boolean; - additionalFilter?: CategorizationAdditionalFilter; - navigateToDiscover?: boolean; displayExamples?: boolean; } export const CategoryTable: FC = ({ 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([]); - const { onTableChange, pagination, sorting } = useTableState(categories ?? [], 'key'); + const { onTableChange, pagination, sorting } = tableState; + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>( {} ); - 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 = ({ 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 = ({ }; } - if (selectedCategory && selectedCategory.key === category.key) { + if (highlightedCategory && highlightedCategory.key === category.key) { return { backgroundColor: euiTheme.euiColorLightestShade, }; @@ -302,49 +241,49 @@ export const CategoryTable: FC = ({ }; }; - return ( - <> - openInDiscover(queryMode)} - /> - - + const tableStyle = css({ + thead: { + position: 'sticky', + insetBlockStart: 0, + zIndex: 1, + backgroundColor: euiTheme.euiColorEmptyShade, + boxShadow: `inset 0 0px 0, inset 0 -1px 0 ${euiTheme.euiBorderColor}`, + }, + }); - - 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 ( + + 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; + }} + /> ); }; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/category_table/table_header.tsx b/x-pack/plugins/aiops/public/components/log_categorization/category_table/table_header.tsx index 08ac019b8fdc3..d733c050fc3b8 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/category_table/table_header.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/category_table/table_header.tsx @@ -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; - openInDiscover: (mode: QueryMode) => void; + openInDiscover: OpenInDiscover; } export const TableHeader: FC = ({ categoriesCount, selectedCategoriesCount, - labels, openInDiscover, }) => { const euiTheme = useEuiTheme(); return ( - <> - - - - - {selectedCategoriesCount > 0 ? ( - <> - - - ) : null} - + + + + + {selectedCategoriesCount > 0 ? ( + <> + + + ) : null} + + + {selectedCategoriesCount > 0 ? ( + + - {selectedCategoriesCount > 0 ? ( - <> - - openInDiscover(QUERY_MODE.INCLUDE)} - iconType="plusInCircle" - iconSide="left" - > - {labels.multiSelect.in} - - - - openInDiscover(QUERY_MODE.EXCLUDE)} - iconType="minusInCircle" - iconSide="left" - > - {labels.multiSelect.out} - - - - ) : null} - - + ) : null} + + ); +}; + +export const OpenInDiscoverButtons: FC<{ openInDiscover: OpenInDiscover; showText?: boolean }> = ({ + openInDiscover, + showText = true, +}) => { + const { labels, openFunction } = openInDiscover; + + return ( + + + + openFunction(QUERY_MODE.INCLUDE)} + iconType="plusInCircle" + iconSide="left" + > + {labels.multiSelect.in} + + + + + + openFunction(QUERY_MODE.EXCLUDE)} + iconType="minusInCircle" + iconSide="left" + > + {labels.multiSelect.out} + + + + + ); +}; + +const TooltipWrapper: FC> = ({ + text, + showText, + children, +}) => { + return showText ? ( + <>{children} + ) : ( + + <>{children} + ); }; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/category_table/use_open_in_discover.ts b/x-pack/plugins/aiops/public/components/log_categorization/category_table/use_open_in_discover.ts new file mode 100644 index 0000000000000..963fd6b3a13e8 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/category_table/use_open_in_discover.ts @@ -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; + 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 }; +} diff --git a/x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx b/x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx index b0696d8767dc0..91a3185b683bb 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx @@ -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 = ({ @@ -34,6 +36,7 @@ export const CreateCategorizationJobButton: FC = ({ query, earliest, latest, + iconOnly = false, }) => { const { uiActions, @@ -58,20 +61,40 @@ export const CreateCategorizationJobButton: FC = ({ return null; } - return ( - <> - - - - + + ); + } + + return ( + + + ); }; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/format_category.tsx b/x-pack/plugins/aiops/public/components/log_categorization/format_category.tsx index 7cca9f9e8cada..5af9478349642 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/format_category.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/format_category.tsx @@ -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( - {tempExample.substring(pos, positions[i].start)} + + {tempExample.substring(pos, positions[i].start)} + ); elements.push( - + {tempExample.substring(positions[i].start, positions[i].end)} ); @@ -108,7 +111,7 @@ export const useCreateFormattedExample = () => { } elements.push( - + {tempExample.substring(positions[positions.length - 1].end)} ); @@ -131,10 +134,10 @@ export const FormattedPatternExamples: FC = ({ category, count }) => { .fill(0) .map((_, i) => createFormattedExample(key, examples[i])); return formattedExamples.map((example, i) => ( - <> + {example} {i < formattedExamples.length - 1 ? : null} - + )); }, [category, count, createFormattedExample]); @@ -150,10 +153,19 @@ export const FormattedRegex: FC = ({ 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({token}); + elements.push( + + {token} + + ); } else { - elements.push({token}); + elements.push( + + {token} + + ); } } return elements; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/information_text.tsx b/x-pack/plugins/aiops/public/components/log_categorization/information_text.tsx index c4fed642b7d0d..5096f42e2082c 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/information_text.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/information_text.tsx @@ -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 = ({ eventRateLength, - fieldSelected, + fields, categoriesLength, loading, }) => { if (loading === true) { return null; } - return ( - <> - {eventRateLength === 0 ? ( - - - - } - titleSize="xs" - body={ -

- -

- } - data-test-subj="aiopsNoWindowParametersEmptyPrompt" - /> - ) : null} - {eventRateLength > 0 && categoriesLength === null ? ( - - - - } - titleSize="xs" - body={ -

- -

- } - data-test-subj="aiopsNoWindowParametersEmptyPrompt" - /> - ) : null} + if (fields?.length === 0) { + return ( + + + + } + titleSize="xs" + body={ +

+ +

+ } + data-test-subj="aiopsNoTextFieldsEmptyPrompt" + /> + ); + } + + if (eventRateLength === 0) { + return ( + + + + } + titleSize="xs" + body={ +

+ +

+ } + data-test-subj="aiopsNoDocsEmptyPrompt" + /> + ); + } + + if (eventRateLength > 0 && categoriesLength === null) { + return ( + + + + } + titleSize="xs" + body={ +

+ +

+ } + data-test-subj="aiopsNoWindowParametersEmptyPrompt" + /> + ); + } + + if (eventRateLength > 0 && categoriesLength !== null && categoriesLength === 0) { + return ( + + + + } + titleSize="xs" + body={ +

+ +

+ } + data-test-subj="aiopsNoCategoriesEmptyPrompt" + /> + ); + } - {eventRateLength > 0 && categoriesLength !== null && categoriesLength === 0 ? ( - - - - } - titleSize="xs" - body={ -

- -

- } - data-test-subj="aiopsNoWindowParametersEmptyPrompt" - /> - ) : null} - - ); + return null; }; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/loading_categorization.tsx b/x-pack/plugins/aiops/public/components/log_categorization/loading_categorization.tsx index cd0e5cd7ad297..1d98325f2d987 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/loading_categorization.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/loading_categorization.tsx @@ -19,26 +19,26 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; interface Props { - onClose: () => void; + onCancel: () => void; } -export const LoadingCategorization: FC = ({ onClose }) => ( +export const LoadingCategorization: FC = ({ onCancel }) => ( <> - + -

+

-

+
@@ -46,7 +46,7 @@ export const LoadingCategorization: FC = ({ onClose }) => ( onClose()} + onClick={() => onCancel()} > Cancel diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_enabled.ts b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_enabled.ts new file mode 100644 index 0000000000000..6df61a20f993a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_enabled.ts @@ -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)) + ); + }; +} diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/discover_tabs.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/discover_tabs.tsx new file mode 100644 index 0000000000000..96fae405eec2c --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/discover_tabs.tsx @@ -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>; + 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 = ({ + renderViewModeToggle, + randomSampler, + openInDiscover, + selectedCategories, + loadCategories, + fields, + setSelectedField, + selectedField, + minimumTimeRangeOption, + setMinimumTimeRangeOption, + data, + dataview, + earliest, + latest, + query, +}) => { + return ( + + + {renderViewModeToggle(data?.categories.length)} + + + <> + + + {selectedCategories.length > 0 ? ( + + + + ) : null} + + + + +
+
+ loadCategories()} + minimumTimeRangeOption={minimumTimeRangeOption} + setMinimumTimeRangeOption={setMinimumTimeRangeOption} + categoryCount={data?.totalCategories} + /> +
+ {selectedField !== null && earliest !== undefined && latest !== undefined ? ( +
+ +
+ ) : null} +
+
+
+ +
+
+
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/embeddable_menu.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/embeddable_menu.tsx new file mode 100644 index 0000000000000..af40e305f558b --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/embeddable_menu.tsx @@ -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 = ({ + randomSampler, + minimumTimeRangeOption, + setMinimumTimeRangeOption, + categoryCount, + reload, +}) => { + const [showMenu, setShowMenu] = useState(false); + const togglePopover = () => setShowMenu(!showMenu); + + const button = ( + + togglePopover()} + // @ts-ignore - subdued does work + color="subdued" + aria-label={i18n.translate('xpack.aiops.logCategorization.embeddableMenu.aria', { + defaultMessage: 'Pattern analysis options', + })} + /> + + ); + + return ( + togglePopover()} + panelPaddingSize="s" + anchorPosition="downRight" + > + + + + + + + + + + + {i18n.translate( + 'xpack.aiops.logCategorization.embeddableMenu.minimumTimeRangeOptionsRowLabel', + { + defaultMessage: 'Minimum time range', + } + )} + + + + + + +
+ } + helpText={ + <> + {categoryCount !== undefined && minimumTimeRangeOption !== 'No minimum' ? ( + <> + + + ) : null} + + } + > + + + + + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/field_selector.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/field_selector.tsx new file mode 100644 index 0000000000000..1941e2d8148d0 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/field_selector.tsx @@ -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 = ({ 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 = ( + togglePopover()} + > + + + + + {selectedField?.name} + + + ); + + return ( + setShowPopover(false)} + isOpen={showPopover} + button={button} + className="unifiedDataTableToolbarControlButton" + > + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/index.ts b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/index.ts new file mode 100644 index 0000000000000..8cc8fcbad313a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/index.ts @@ -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'; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/log_categorization_for_embeddable.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/log_categorization_for_embeddable.tsx new file mode 100644 index 0000000000000..1246ea0770004 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/log_categorization_for_embeddable.tsx @@ -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; + renderViewModeToggle: (patternCount?: number) => React.ReactElement; +} + +const BAR_TARGET = 20; + +export const LogCategorizationEmbeddable: FC = ({ + 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( + 'logCategorization', + getDefaultLogCategorizationAppState({ + searchQuery: createMergedEsQuery(query, filters, dataView, uiSettings), + }) + ); + const [highlightedCategory, setHighlightedCategory] = useState(null); + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedField, setSelectedField] = useState(null); + const [fields, setFields] = useState([]); + const [currentDocumentStatsHash, setCurrentDocumentStatsHash] = useState(null); + const [previousDocumentStatsHash, setPreviousDocumentStatsHash] = useState(0); + const [currentAdditionalConfigsHash, setCurrentAdditionalConfigsHash] = useState( + null + ); + const [previousAdditionalConfigsHash, setPreviousAdditionalConfigsHash] = useState( + null + ); + const [loading, setLoading] = useState(null); + const [eventRate, setEventRate] = useState([]); + const [pinnedCategory, setPinnedCategory] = useState(null); + const [data, setData] = useState<{ + categories: Category[]; + displayExamples: boolean; + totalCategories: number; + } | null>(null); + const [fieldValidationResult, setFieldValidationResult] = useState( + null + ); + const tableState = useTableState([], '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 ( + <> + + + + + + + + <> + + {(loading ?? true) === true ? ( + + ) : null} + + {loading === false && + data !== null && + data.categories.length > 0 && + selectedField !== null ? ( + + ) : null} + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default LogCategorizationEmbeddable; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/log_categorization_wrapper.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/log_categorization_wrapper.tsx new file mode 100644 index 0000000000000..49a8219bc880a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/log_categorization_wrapper.tsx @@ -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 = ({ + 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 ( + + + + + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default LogCategorizationWrapper; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/minimum_time_range.ts b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/minimum_time_range.ts new file mode 100644 index 0000000000000..07b5485be7bbd --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/minimum_time_range.ts @@ -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; + +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' }, +}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/selected_patterns.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/selected_patterns.tsx new file mode 100644 index 0000000000000..44c9014c1aee1 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/selected_patterns.tsx @@ -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 = ( + togglePopover()} + badgeContent={openInDiscover.count} + > + + + ); + + return ( + setShowMenu(false)} + isOpen={showMenu} + panelPaddingSize="none" + button={button} + className="unifiedDataTableToolbarControlButton" + > + openFunction(QUERY_MODE.INCLUDE)} + > + {labels.multiSelect.in} + , + openFunction(QUERY_MODE.EXCLUDE)} + > + {labels.multiSelect.out} + , + ]} + /> + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/use_minimum_time_range.ts b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/use_minimum_time_range.ts new file mode 100644 index 0000000000000..e8a47546608da --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_embeddable/use_minimum_time_range.ts @@ -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 + >(AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE, '1 week'); + + const cancelRequest = useCallback(() => { + abortController.current.abort(); + abortController.current = new AbortController(); + }, []); + + return { + getMinimumTimeRange, + cancelRequest, + minimumTimeRangeOption, + setMinimumTimeRangeOption, + }; +} diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx index b4bcf95129b2b..b68f4d4728daa 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx @@ -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 = ({ const { notifications: { toasts }, data: { - query: { getState, filterManager }, + query: { getState }, }, uiSettings, } = useAiopsAppContext(); @@ -102,7 +104,8 @@ export const LogCategorizationFlyout: FC = ({ searchQuery: createMergedEsQuery(query, filters, dataView, uiSettings), }) ); - const [selectedCategory, setSelectedCategory] = useState(null); + const [highlightedCategory, setHighlightedCategory] = useState(null); + const [selectedCategories, setSelectedCategories] = useState([]); const [selectedSavedSearch /* , setSelectedSavedSearch*/] = useState(savedSearch); const [loading, setLoading] = useState(true); const [eventRate, setEventRate] = useState([]); @@ -117,6 +120,7 @@ export const LogCategorizationFlyout: FC = ({ ); const [showTabs, setShowTabs] = useState(false); const [selectedTab, setSelectedTab] = useState(SELECTED_TAB.FULL_TIME_RANGE); + const tableState = useTableState([], 'key'); const cancelRequest = useCallback(() => { cancelValidationRequest(); @@ -150,6 +154,17 @@ export const LogCategorizationFlyout: FC = ({ 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 = ({ 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 = ({ /> ) : null} - {loading === true ? : null} + {loading === true ? : null} {loading === false && data !== null && data.categories.length > 0 ? ( <> @@ -388,31 +390,31 @@ export const LogCategorizationFlyout: FC = ({ ) : null} + + + + + ) : null} diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx index 2531a47419dc4..6d8be420fd342 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx @@ -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 = ({ embeddin ); const [globalState, setGlobalState] = useUrlState('_g'); const [selectedField, setSelectedField] = useState(); - const [selectedCategory, setSelectedCategory] = useState(null); + const [highlightedCategory, setHighlightedCategory] = useState(null); + const [selectedCategories, setSelectedCategories] = useState([]); const [selectedSavedSearch, setSelectedSavedSearch] = useState(savedSearch); const [previousDocumentStatsHash, setPreviousDocumentStatsHash] = useState(0); const [loading, setLoading] = useState(false); @@ -94,6 +98,7 @@ export const LogCategorizationPage: FC = ({ embeddin const [fieldValidationResult, setFieldValidationResult] = useState( null ); + const tableState = useTableState([], 'key'); const cancelRequest = useCallback(() => { cancelValidationRequest(); @@ -154,6 +159,17 @@ export const LogCategorizationPage: FC = ({ 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 = ({ embeddin docCount, })) ); - setData(null); - setFieldValidationResult(null); setTotalCount(documentStats.totalCount); if (fieldValidationResult !== null) { loadCategories(); @@ -371,7 +385,7 @@ export const LogCategorizationPage: FC = ({ embeddin @@ -387,36 +401,33 @@ export const LogCategorizationPage: FC = ({ embeddin loading={loading} categoriesLength={data?.categories?.length ?? null} eventRateLength={eventRate.length} - fieldSelected={selectedField !== null} /> {selectedField !== undefined && data !== null && data.categories.length > 0 ? ( - + <> + + + + + + + ) : null} ); }; - -/** - * 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}`); -} diff --git a/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/random_sampler.ts b/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/random_sampler.ts index dac084c605d82..20b6d25668ba1 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/random_sampler.ts +++ b/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/random_sampler.ts @@ -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', + } + ), + }; + } +}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/sampling_menu.tsx b/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/sampling_menu.tsx index 92eeeb0fb0c1e..d2dd9591e76f7 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/sampling_menu.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/sampling_menu.tsx @@ -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 = ({ 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 ( = ({ randomSampler, reload }) => { anchorPosition="downLeft" > - - - - - - - setRandomSamplerPreference(e.target.value as RandomSamplerOption)} - /> - - - {randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_MANUAL ? ( - - ) : null} - - {randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_AUTOMATIC ? ( - - ) : null} + ); }; - -const ProbabilityUsedMessage: FC<{ samplingProbability: number | null }> = ({ - samplingProbability, -}) => { - return samplingProbability !== null ? ( -
- - - -
- ) : null; -}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/sampling_panel.tsx b/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/sampling_panel.tsx new file mode 100644 index 0000000000000..1944039a85e7f --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/sampling_menu/sampling_panel.tsx @@ -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 = ({ 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' ? ( + + ) : null} + + + ) : null + } + > + + + + {randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_MANUAL ? ( + + ) : null} + + {calloutPosition === 'bottom' ? ( + + ) : null} + + ); +}; + +const ProbabilityUsedMessage: FC<{ samplingProbability: number | null }> = ({ + samplingProbability, +}) => { + return samplingProbability !== null ? ( +
+ +
+ ) : null; +}; + +const CalloutInfoMessage: FC<{ + calloutInfoMessage: string; + calloutPosition: 'top' | 'bottom'; +}> = ({ calloutInfoMessage, calloutPosition }) => ( + <> + {calloutPosition === 'bottom' ? : null} + + {calloutPosition === 'top' ? : null} + +); diff --git a/x-pack/plugins/aiops/public/components/log_categorization/use_categorize_request.ts b/x-pack/plugins/aiops/public/components/log_categorization/use_categorize_request.ts index 9fb050699e422..9008e9100d50b 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/use_categorize_request.ts +++ b/x-pack/plugins/aiops/public/components/log_categorization/use_categorize_request.ts @@ -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 } ) diff --git a/x-pack/plugins/aiops/public/components/log_categorization/use_validate_category_field.ts b/x-pack/plugins/aiops/public/components/log_categorization/use_validate_category_field.ts index b2e16363aba35..e771a981a9c49 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/use_validate_category_field.ts +++ b/x-pack/plugins/aiops/public/components/log_categorization/use_validate_category_field.ts @@ -51,6 +51,7 @@ export function useValidateFieldRequest() { }), headers, version: '1', + signal: abortController.current.signal, } ); diff --git a/x-pack/plugins/aiops/public/components/log_categorization/utils.ts b/x-pack/plugins/aiops/public/components/log_categorization/utils.ts new file mode 100644 index 0000000000000..152cd56b77593 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/utils.ts @@ -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 }; +} diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx index 16fc210d2efc2..f017011596473 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx @@ -152,6 +152,7 @@ export const LogRateAnalysisContent: FC = ({ currentSelectedSignificantItem, currentSelectedGroup, undefined, + true, timeRange ); diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 26941cf2840fa..5c7acaf2b2372 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -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()); }); diff --git a/x-pack/plugins/aiops/public/hooks/use_document_count_stats.ts b/x-pack/plugins/aiops/public/hooks/use_document_count_stats.ts index f431179cfe858..83f10a4a445d3 100644 --- a/x-pack/plugins/aiops/public/hooks/use_document_count_stats.ts +++ b/x-pack/plugins/aiops/public/hooks/use_document_count_stats.ts @@ -56,7 +56,8 @@ function displayError(toastNotifications: ToastsStart, index: string, err: any) export function useDocumentCountStats( searchParams: TParams | undefined, searchParamsCompare: TParams | undefined, - lastRefresh: number + lastRefresh: number, + changePointsByDefault = true ): DocumentStats { const { data, @@ -96,7 +97,7 @@ export function useDocumentCountStats { + 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' + ) + ), }; } diff --git a/x-pack/plugins/aiops/public/types.ts b/x-pack/plugins/aiops/public/types/index.ts old mode 100755 new mode 100644 similarity index 80% rename from x-pack/plugins/aiops/public/types.ts rename to x-pack/plugins/aiops/public/types/index.ts index e4a97bb01f18e..5c71a63bba354 --- a/x-pack/plugins/aiops/public/types.ts +++ b/x-pack/plugins/aiops/public/types/index.ts @@ -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>; + PatternAnalysisComponent: React.ComponentType; ChangePointDetectionComponent: ChangePointDetectionSharedComponent; } diff --git a/x-pack/plugins/aiops/public/types/storage.ts b/x-pack/plugins/aiops/public/types/storage.ts index e6a1f8d46730b..ea6fde6b06552 100644 --- a/x-pack/plugins/aiops/public/types/storage.ts +++ b/x-pack/plugins/aiops/public/types/storage.ts @@ -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; @@ -30,10 +35,13 @@ export type AiOpsStorageMapped = 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; diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json index 950caae7fd88c..c4e7af826c0c3 100644 --- a/x-pack/plugins/aiops/tsconfig.json +++ b/x-pack/plugins/aiops/tsconfig.json @@ -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/**/*", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 094c1f15bb567..cef2ace6f0bae 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -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.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7afc75d976687..abdce39b748e8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "フィールドがありません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 95da09f4c580b..827dee9609929 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "无字段。", diff --git a/x-pack/test/functional/apps/aiops/log_pattern_analysis_in_discover.ts b/x-pack/test/functional/apps/aiops/log_pattern_analysis_in_discover.ts index 197b5b5338a17..01d2452121cb8 100644 --- a/x-pack/test/functional/apps/aiops/log_pattern_analysis_in_discover.ts +++ b/x-pack/test/functional/apps/aiops/log_pattern_analysis_in_discover.ts @@ -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.setRandomSamplingOption('aiopsRandomSamplerOptionOff'); + await aiops.logPatternAnalysisPage.setRandomSamplingOptionDiscover( + '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.setRandomSamplingOption('aiopsRandomSamplerOptionOff'); + await aiops.logPatternAnalysisPage.setRandomSamplingOptionDiscover( + 'aiopsRandomSamplerOptionOff' + ); - await aiops.logPatternAnalysisPage.assertTotalCategoriesFound(3); + await aiops.logPatternAnalysisPage.assertTotalCategoriesFoundDiscover(3); await aiops.logPatternAnalysisPage.assertCategoryTableRows(3); // get category count from the first row diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index.ts b/x-pack/test/functional/apps/ml/data_visualizer/index.ts index e5b1cbab24809..bddbcdfb95370 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index.ts @@ -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')); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover.ts index 7c1e2a1a8f946..14e16d5929779 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover.ts @@ -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); - }); }); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover_basic.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover_basic.ts new file mode 100644 index 0000000000000..7d7e5dfe0760f --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover_basic.ts @@ -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); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover_trial.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover_trial.ts new file mode 100644 index 0000000000000..9a1429805a18f --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover_trial.ts @@ -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); + }); + }); +} diff --git a/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts b/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts index 76ca855b4c8ca..b7ab5951af64f 100644 --- a/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts +++ b/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts @@ -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 }); + }); + }, }; } diff --git a/x-pack/test/functional_basic/apps/ml/data_visualizer/group3/index.ts b/x-pack/test/functional_basic/apps/ml/data_visualizer/group3/index.ts index d2e77f9522854..3388181aea589 100644 --- a/x-pack/test/functional_basic/apps/ml/data_visualizer/group3/index.ts +++ b/x-pack/test/functional_basic/apps/ml/data_visualizer/group3/index.ts @@ -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')); }); }