diff --git a/x-pack/plugins/aiops/public/application/utils/url_state.ts b/x-pack/plugins/aiops/public/application/utils/url_state.ts new file mode 100644 index 0000000000000..9fdaa443f4c75 --- /dev/null +++ b/x-pack/plugins/aiops/public/application/utils/url_state.ts @@ -0,0 +1,45 @@ +/* + * 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +import type { Filter, Query } from '@kbn/es-query'; +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; + +import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from './search_utils'; + +const defaultSearchQuery = { + match_all: {}, +}; + +export interface AiOpsPageUrlState { + pageKey: 'AIOPS_INDEX_VIEWER'; + pageUrlState: AiOpsIndexBasedAppState; +} + +export interface AiOpsIndexBasedAppState { + searchString?: Query['query']; + searchQuery?: estypes.QueryDslQueryContainer; + searchQueryLanguage: SearchQueryLanguage; + filters?: Filter[]; +} + +export type AiOpsFullIndexBasedAppState = Required; + +export const getDefaultAiOpsListState = ( + overrides?: Partial +): AiOpsFullIndexBasedAppState => ({ + searchString: '', + searchQuery: defaultSearchQuery, + searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY, + filters: [], + ...overrides, +}); + +export const isFullAiOpsListState = (arg: unknown): arg is AiOpsFullIndexBasedAppState => { + return isPopulatedObject(arg, Object.keys(getDefaultAiOpsListState())); +}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx index ce69ea9fea3ae..b30e3a7d1e6fb 100644 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx @@ -10,7 +10,6 @@ import { pick } from 'lodash'; import { EuiCallOut } from '@elastic/eui'; -import type { Filter, Query } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import type { SavedSearch } from '@kbn/discover-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -21,7 +20,6 @@ import { DatePickerContextProvider } from '@kbn/ml-date-picker'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; -import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../../application/utils/search_utils'; import type { AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; import { AiopsAppContext } from '../../hooks/use_aiops_app_context'; import { DataSourceContext } from '../../hooks/use_data_source'; @@ -42,34 +40,6 @@ export interface ExplainLogRateSpikesAppStateProps { appDependencies: AiopsAppDependencies; } -const defaultSearchQuery = { - match_all: {}, -}; - -export interface AiOpsPageUrlState { - pageKey: 'AIOPS_INDEX_VIEWER'; - pageUrlState: AiOpsIndexBasedAppState; -} - -export interface AiOpsIndexBasedAppState { - searchString?: Query['query']; - searchQuery?: Query['query']; - searchQueryLanguage: SearchQueryLanguage; - filters?: Filter[]; -} - -export const getDefaultAiOpsListState = ( - overrides?: Partial -): Required => ({ - searchString: '', - searchQuery: defaultSearchQuery, - searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY, - filters: [], - ...overrides, -}); - -export const restorableDefaults = getDefaultAiOpsListState(); - export const ExplainLogRateSpikesAppState: FC = ({ dataView, savedSearch, diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx index 80640f59901bc..fb9ce01c63391 100644 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useState, FC } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { EuiEmptyPrompt, EuiFlexGroup, @@ -28,6 +29,10 @@ import { useDataSource } from '../../hooks/use_data_source'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { SearchQueryLanguage } from '../../application/utils/search_utils'; import { useData } from '../../hooks/use_data'; +import { + getDefaultAiOpsListState, + type AiOpsPageUrlState, +} from '../../application/utils/url_state'; import { DocumentCountContent } from '../document_count_content/document_count_content'; import { SearchPanel } from '../search_panel'; @@ -35,7 +40,6 @@ import type { GroupTableItem } from '../spike_analysis_table/types'; import { useSpikeAnalysisTableRowContext } from '../spike_analysis_table/spike_analysis_table_row_provider'; import { PageHeader } from '../page_header'; -import { restorableDefaults, type AiOpsPageUrlState } from './explain_log_rate_spikes_app_state'; import { ExplainLogRateSpikesAnalysis } from './explain_log_rate_spikes_analysis'; function getDocumentCountStatsSplitLabel( @@ -66,7 +70,7 @@ export const ExplainLogRateSpikesPage: FC = () => { const [aiopsListState, setAiopsListState] = usePageUrlState( 'AIOPS_INDEX_VIEWER', - restorableDefaults + getDefaultAiOpsListState() ); const [globalState, setGlobalState] = useUrlState('_g'); @@ -80,7 +84,7 @@ export const ExplainLogRateSpikesPage: FC = () => { const setSearchParams = useCallback( (searchParams: { - searchQuery: Query['query']; + searchQuery: estypes.QueryDslQueryContainer; searchString: Query['query']; queryLanguage: SearchQueryLanguage; filters: Filter[]; 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 c888694a7b0c3..747f90d542354 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 @@ -25,7 +25,7 @@ import { import { useDiscoverLinks } from '../use_discover_links'; import { MiniHistogram } from '../../mini_histogram'; import { useEuiTheme } from '../../../hooks/use_eui_theme'; -import type { AiOpsIndexBasedAppState } from '../../explain_log_rate_spikes/explain_log_rate_spikes_app_state'; +import type { AiOpsFullIndexBasedAppState } from '../../../application/utils/url_state'; import type { EventRate, Category, SparkLinesPerCategory } from '../use_categorize_request'; import { useTableState } from './use_table_state'; @@ -42,7 +42,7 @@ interface Props { dataViewId: string; selectedField: string | undefined; timefilter: TimefilterContract; - aiopsListState: Required; + aiopsListState: AiOpsFullIndexBasedAppState; pinnedCategory: Category | null; setPinnedCategory: (category: Category | null) => void; selectedCategory: Category | 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 b673d8a498a21..7c7d0001aea42 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 @@ -4,8 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { FC, useState, useEffect, useCallback, useMemo } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { EuiButton, EuiSpacer, @@ -21,14 +23,18 @@ import { import { Filter, Query } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useUrlState } from '@kbn/ml-url-state'; +import { usePageUrlState, useUrlState } from '@kbn/ml-url-state'; import { useDataSource } from '../../hooks/use_data_source'; import { useData } from '../../hooks/use_data'; import type { SearchQueryLanguage } from '../../application/utils/search_utils'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; +import { + getDefaultAiOpsListState, + isFullAiOpsListState, + type AiOpsPageUrlState, +} from '../../application/utils/url_state'; -import { restorableDefaults } from '../explain_log_rate_spikes/explain_log_rate_spikes_app_state'; import { SearchPanel } from '../search_panel'; import { PageHeader } from '../page_header'; @@ -47,7 +53,10 @@ export const LogCategorizationPage: FC = () => { const { dataView, savedSearch } = useDataSource(); const { runCategorizeRequest, cancelRequest } = useCategorizeRequest(); - const [aiopsListState, setAiopsListState] = useState(restorableDefaults); + const [aiopsListState, setAiopsListState] = usePageUrlState( + 'AIOPS_INDEX_VIEWER', + getDefaultAiOpsListState() + ); const [globalState, setGlobalState] = useUrlState('_g'); const [selectedField, setSelectedField] = useState(); const [selectedCategory, setSelectedCategory] = useState(null); @@ -76,7 +85,7 @@ export const LogCategorizationPage: FC = () => { const setSearchParams = useCallback( (searchParams: { - searchQuery: Query['query']; + searchQuery: estypes.QueryDslQueryContainer; searchString: Query['query']; queryLanguage: SearchQueryLanguage; filters: Filter[]; @@ -289,7 +298,10 @@ export const LogCategorizationPage: FC = () => { fieldSelected={selectedField !== null} /> - {selectedField !== undefined && categories !== null && categories.length > 0 ? ( + {selectedField !== undefined && + categories !== null && + categories.length > 0 && + isFullAiOpsListState(aiopsListState) ? ( = ({ const copyToClipBoardAction = useCopyToClipboardAction(); const viewInDiscoverAction = useViewInDiscoverAction(dataViewId); + const viewInLogPatternAnalysisAction = useViewInLogPatternAnalysisAction(dataViewId); const columns: Array> = [ { @@ -238,7 +240,7 @@ export const SpikeAnalysisTable: FC = ({ name: i18n.translate('xpack.aiops.spikeAnalysisTable.actionsColumnName', { defaultMessage: 'Actions', }), - actions: [viewInDiscoverAction, copyToClipBoardAction], + actions: [viewInDiscoverAction, viewInLogPatternAnalysisAction, copyToClipBoardAction], width: ACTIONS_COLUMN_WIDTH, valign: 'middle', }, diff --git a/x-pack/plugins/aiops/public/components/spike_analysis_table/spike_analysis_table_groups.tsx b/x-pack/plugins/aiops/public/components/spike_analysis_table/spike_analysis_table_groups.tsx index b319db0088d4d..ca55b43907bd6 100644 --- a/x-pack/plugins/aiops/public/components/spike_analysis_table/spike_analysis_table_groups.tsx +++ b/x-pack/plugins/aiops/public/components/spike_analysis_table/spike_analysis_table_groups.tsx @@ -40,6 +40,7 @@ import { useSpikeAnalysisTableRowContext } from './spike_analysis_table_row_prov import type { GroupTableItem } from './types'; import { useCopyToClipboardAction } from './use_copy_to_clipboard_action'; import { useViewInDiscoverAction } from './use_view_in_discover_action'; +import { useViewInLogPatternAnalysisAction } from './use_view_in_log_pattern_analysis_action'; const NARROW_COLUMN_WIDTH = '120px'; const EXPAND_COLUMN_WIDTH = '40px'; @@ -121,6 +122,7 @@ export const SpikeAnalysisGroupsTable: FC = ({ const copyToClipBoardAction = useCopyToClipboardAction(); const viewInDiscoverAction = useViewInDiscoverAction(dataViewId); + const viewInLogPatternAnalysisAction = useViewInLogPatternAnalysisAction(dataViewId); const columns: Array> = [ { @@ -355,7 +357,7 @@ export const SpikeAnalysisGroupsTable: FC = ({ name: i18n.translate('xpack.aiops.spikeAnalysisTable.actionsColumnName', { defaultMessage: 'Actions', }), - actions: [viewInDiscoverAction, copyToClipBoardAction], + actions: [viewInDiscoverAction, viewInLogPatternAnalysisAction, copyToClipBoardAction], width: ACTIONS_COLUMN_WIDTH, valign: 'top', }, diff --git a/x-pack/plugins/aiops/public/components/spike_analysis_table/table_action_button.tsx b/x-pack/plugins/aiops/public/components/spike_analysis_table/table_action_button.tsx new file mode 100644 index 0000000000000..16c91f8d3851f --- /dev/null +++ b/x-pack/plugins/aiops/public/components/spike_analysis_table/table_action_button.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { type FC } from 'react'; + +import { EuiLink, EuiIcon, EuiText, EuiToolTip, type IconType } from '@elastic/eui'; + +interface TableActionButtonProps { + iconType: IconType; + dataTestSubjPostfix: string; + isDisabled: boolean; + label: string; + tooltipText?: string; + onClick: () => void; +} + +export const TableActionButton: FC = ({ + iconType, + dataTestSubjPostfix, + isDisabled, + label, + tooltipText, + onClick, +}) => { + const buttonContent = ( + <> + + {label} + + ); + + const unwrappedButton = !isDisabled ? ( + + {buttonContent} + + ) : ( + + {buttonContent} + + ); + + if (tooltipText) { + return {unwrappedButton}; + } + + return unwrappedButton; +}; diff --git a/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.test.tsx b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.test.tsx index 82359b3d2b1aa..0984c76a4b170 100644 --- a/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.test.tsx +++ b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.test.tsx @@ -30,11 +30,19 @@ describe('useCopyToClipboardAction', () => { it('renders the action for a single significant term', async () => { execCommandMock.mockImplementationOnce(() => true); const { result } = renderHook(() => useCopyToClipboardAction()); - const { getByLabelText } = render((result.current as Action).render(significantTerms[0])); + const { findByText, getByTestId } = render( + (result.current as Action).render(significantTerms[0]) + ); - const button = getByLabelText('Copy field/value pair as KQL syntax to clipboard'); + const button = getByTestId('aiopsTableActionButtonCopyToClipboard enabled'); - expect(button).toBeInTheDocument(); + userEvent.hover(button); + + // The tooltip from EUI takes 250ms to appear, so we must + // use a `find*` query to asynchronously poll for it. + expect( + await findByText('Copy field/value pair as KQL syntax to clipboard') + ).toBeInTheDocument(); await act(async () => { await userEvent.click(button); @@ -50,12 +58,16 @@ describe('useCopyToClipboardAction', () => { it('renders the action for a group of items', async () => { execCommandMock.mockImplementationOnce(() => true); const groupTableItems = getGroupTableItems(finalSignificantTermGroups); - const { result } = renderHook(() => useCopyToClipboardAction()); - const { getByLabelText } = render((result.current as Action).render(groupTableItems[0])); + const { result } = renderHook(useCopyToClipboardAction); + const { findByText, getByText } = render((result.current as Action).render(groupTableItems[0])); + + const button = getByText('Copy to clipboard'); - const button = getByLabelText('Copy group items as KQL syntax to clipboard'); + userEvent.hover(button); - expect(button).toBeInTheDocument(); + // The tooltip from EUI takes 250ms to appear, so we must + // use a `find*` query to asynchronously poll for it. + expect(await findByText('Copy group items as KQL syntax to clipboard')).toBeInTheDocument(); await act(async () => { await userEvent.click(button); diff --git a/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.tsx b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.tsx index e9924307c1e27..1b906eb56e988 100644 --- a/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.tsx +++ b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_copy_to_clipboard_action.tsx @@ -7,14 +7,22 @@ import React from 'react'; -import { EuiCopy, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { EuiCopy, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isSignificantTerm, type SignificantTerm } from '@kbn/ml-agg-utils'; +import { TableActionButton } from './table_action_button'; import { getTableItemAsKQL } from './get_table_item_as_kql'; import type { GroupTableItem, TableItemAction } from './types'; +const copyToClipboardButtonLabel = i18n.translate( + 'xpack.aiops.spikeAnalysisTable.linksMenu.copyToClipboardButtonLabel', + { + defaultMessage: 'Copy to clipboard', + } +); + const copyToClipboardSignificantTermMessage = i18n.translate( 'xpack.aiops.spikeAnalysisTable.linksMenu.copyToClipboardSignificantTermMessage', { @@ -37,7 +45,15 @@ export const useCopyToClipboardAction = (): TableItemAction => ({ return ( - {(copy) => } + {(copy) => ( + + )} ); diff --git a/x-pack/plugins/aiops/public/components/spike_analysis_table/use_view_in_discover_action.tsx b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_view_in_discover_action.tsx index 5f30abb2f6cec..bd7741bb452bf 100644 --- a/x-pack/plugins/aiops/public/components/spike_analysis_table/use_view_in_discover_action.tsx +++ b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_view_in_discover_action.tsx @@ -7,14 +7,13 @@ import React, { useMemo } from 'react'; -import { EuiIcon, EuiToolTip } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; import type { SignificantTerm } from '@kbn/ml-agg-utils'; import { SEARCH_QUERY_LANGUAGE } from '../../application/utils/search_utils'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; +import { TableActionButton } from './table_action_button'; import { getTableItemAsKQL } from './get_table_item_as_kql'; import type { GroupTableItem, TableItemAction } from './types'; @@ -83,19 +82,26 @@ export const useViewInDiscoverAction = (dataViewId?: string): TableItemAction => }; return { - name: () => ( - - - - ), - description: viewInDiscoverMessage, - type: 'button', - onClick: async (tableItem) => { - const openInDiscoverUrl = await generateDiscoverUrl(tableItem); - if (typeof openInDiscoverUrl === 'string') { - await application.navigateToUrl(openInDiscoverUrl); - } + render: (tableItem: SignificantTerm | GroupTableItem) => { + const tooltipText = discoverUrlError ? discoverUrlError : viewInDiscoverMessage; + + const clickHandler = async () => { + const openInDiscoverUrl = await generateDiscoverUrl(tableItem); + if (typeof openInDiscoverUrl === 'string') { + await application.navigateToUrl(openInDiscoverUrl); + } + }; + + return ( + + ); }, - enabled: () => discoverUrlError === undefined, }; }; diff --git a/x-pack/plugins/aiops/public/components/spike_analysis_table/use_view_in_log_pattern_analysis_action.tsx b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_view_in_log_pattern_analysis_action.tsx new file mode 100644 index 0000000000000..9388cf147c8ff --- /dev/null +++ b/x-pack/plugins/aiops/public/components/spike_analysis_table/use_view_in_log_pattern_analysis_action.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; + +import { SerializableRecord } from '@kbn/utility-types'; +import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import type { SignificantTerm } from '@kbn/ml-agg-utils'; + +import { SEARCH_QUERY_LANGUAGE } from '../../application/utils/search_utils'; +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; + +import { TableActionButton } from './table_action_button'; +import { getTableItemAsKQL } from './get_table_item_as_kql'; +import type { GroupTableItem, TableItemAction } from './types'; + +const viewInLogPatternAnalysisMessage = i18n.translate( + 'xpack.aiops.spikeAnalysisTable.linksMenu.viewInLogPatternAnalysis', + { + defaultMessage: 'View in Log Pattern Analysis', + } +); + +export const useViewInLogPatternAnalysisAction = (dataViewId?: string): TableItemAction => { + const { application, share, data } = useAiopsAppContext(); + + const mlLocator = useMemo(() => share.url.locators.get('ML_APP_LOCATOR'), [share.url.locators]); + + const generateLogPatternAnalysisUrl = async ( + groupTableItem: GroupTableItem | SignificantTerm + ) => { + if (mlLocator !== undefined) { + const searchString = getTableItemAsKQL(groupTableItem); + const ast = fromKueryExpression(searchString); + const searchQuery = toElasticsearchQuery(ast); + + const appState = { + AIOPS_INDEX_VIEWER: { + filters: data.query.filterManager.getFilters(), + // QueryDslQueryContainer type triggers an error as being + // not working with SerializableRecord, however, it works as expected. + searchQuery: searchQuery as unknown, + searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY, + searchString: getTableItemAsKQL(groupTableItem), + }, + } as SerializableRecord; + + return await mlLocator.getUrl({ + page: 'aiops/log_categorization', + pageState: { + index: dataViewId, + timeRange: data.query.timefilter.timefilter.getTime(), + appState, + }, + }); + } + }; + + const logPatternAnalysisUrlError = useMemo(() => { + if (!mlLocator) { + return i18n.translate('xpack.aiops.spikeAnalysisTable.mlLocatorMissingErrorMessage', { + defaultMessage: 'No locator for Log Pattern Analysis detected', + }); + } + if (!dataViewId) { + return i18n.translate( + 'xpack.aiops.spikeAnalysisTable.autoGeneratedLogPatternAnalysisLinkErrorMessage', + { + defaultMessage: + 'Unable to link to Log Pattern Analysis; no data view exists for this index', + } + ); + } + }, [dataViewId, mlLocator]); + + return { + render: (tableItem: SignificantTerm | GroupTableItem) => { + const message = logPatternAnalysisUrlError + ? logPatternAnalysisUrlError + : viewInLogPatternAnalysisMessage; + + const clickHandler = async () => { + const openInLogPatternAnalysisUrl = await generateLogPatternAnalysisUrl(tableItem); + if (typeof openInLogPatternAnalysisUrl === 'string') { + await application.navigateToUrl(openInLogPatternAnalysisUrl); + } + }; + + const isDisabled = logPatternAnalysisUrlError !== undefined; + + return ( + + ); + }, + }; +}; diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index c390582ccae1a..62f4c596cc60e 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -18,7 +18,7 @@ import { mlTimefilterRefresh$, useTimefilter } from '@kbn/ml-date-picker'; import { PLUGIN_ID } from '../../common'; import type { DocumentStatsSearchStrategyParams } from '../get_document_stats'; -import type { AiOpsIndexBasedAppState } from '../components/explain_log_rate_spikes/explain_log_rate_spikes_app_state'; +import type { AiOpsIndexBasedAppState } from '../application/utils/url_state'; import { getEsQueryFromSavedSearch } from '../application/utils/search_utils'; import type { GroupTableItem } from '../components/spike_analysis_table/types'; diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json index 89c236ba25c99..6c9aafffa26d0 100644 --- a/x-pack/plugins/aiops/tsconfig.json +++ b/x-pack/plugins/aiops/tsconfig.json @@ -50,6 +50,7 @@ "@kbn/ml-route-utils", "@kbn/unified-field-list-plugin", "@kbn/ml-random-sampler-utils", + "@kbn/utility-types", "@kbn/ml-error-utils", ], "exclude": [ diff --git a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts index 54b1baae454bd..7731eea1d9d7a 100644 --- a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts +++ b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts @@ -13,8 +13,8 @@ import type { FtrProviderContext } from '../../ftr_provider_context'; import { isTestDataExpectedWithSampleProbability, type TestData } from './types'; import { explainLogRateSpikesTestData } from './test_data'; -export default function ({ getPageObject, getService }: FtrProviderContext) { - const headerPage = getPageObject('header'); +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects(['common', 'console', 'header', 'home', 'security']); const elasticChart = getService('elasticChart'); const aiops = getService('aiops'); @@ -58,7 +58,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await aiops.explainLogRateSpikesPage.assertSamplingProbabilityMissing(); } - await headerPage.waitUntilLoadingHasFinished(); + await PageObjects.header.waitUntilLoadingHasFinished(); await ml.testExecution.logTestStep( `${testData.suiteTitle} displays elements in the doc count panel correctly` @@ -78,77 +78,78 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await aiops.explainLogRateSpikesPage.clickDocumentCountChart(testData.chartClickCoordinates); await aiops.explainLogRateSpikesPage.assertAnalysisSectionExists(); - await ml.testExecution.logTestStep('displays the no results found prompt'); - await aiops.explainLogRateSpikesPage.assertNoResultsFoundEmptyPromptExists(); + if (testData.brushDeviationTargetTimestamp) { + await ml.testExecution.logTestStep('displays the no results found prompt'); + await aiops.explainLogRateSpikesPage.assertNoResultsFoundEmptyPromptExists(); - await ml.testExecution.logTestStep('adjusts the brushes to get analysis results'); - await aiops.explainLogRateSpikesPage.assertRerunAnalysisButtonExists(false); + await ml.testExecution.logTestStep('adjusts the brushes to get analysis results'); + await aiops.explainLogRateSpikesPage.assertRerunAnalysisButtonExists(false); - // Get the current width of the deviation brush for later comparison. - const brushSelectionWidthBefore = await aiops.explainLogRateSpikesPage.getBrushSelectionWidth( - 'aiopsBrushDeviation' - ); - - // Get the px values for the timestamp we want to move the brush to. - const { targetPx, intervalPx } = await aiops.explainLogRateSpikesPage.getPxForTimestamp( - testData.brushDeviationTargetTimestamp - ); - - // Adjust the right brush handle - await aiops.explainLogRateSpikesPage.adjustBrushHandler( - 'aiopsBrushDeviation', - 'handle--e', - targetPx + intervalPx * testData.brushIntervalFactor - ); - - // Adjust the left brush handle - await aiops.explainLogRateSpikesPage.adjustBrushHandler( - 'aiopsBrushDeviation', - 'handle--w', - targetPx - intervalPx * (testData.brushIntervalFactor - 1) - ); + // Get the current width of the deviation brush for later comparison. + const brushSelectionWidthBefore = + await aiops.explainLogRateSpikesPage.getBrushSelectionWidth('aiopsBrushDeviation'); - if (testData.brushBaselineTargetTimestamp) { // Get the px values for the timestamp we want to move the brush to. - const { targetPx: targetBaselinePx } = - await aiops.explainLogRateSpikesPage.getPxForTimestamp( - testData.brushBaselineTargetTimestamp - ); + const { targetPx, intervalPx } = await aiops.explainLogRateSpikesPage.getPxForTimestamp( + testData.brushDeviationTargetTimestamp + ); // Adjust the right brush handle await aiops.explainLogRateSpikesPage.adjustBrushHandler( - 'aiopsBrushBaseline', + 'aiopsBrushDeviation', 'handle--e', - targetBaselinePx + intervalPx * testData.brushIntervalFactor + targetPx + intervalPx * testData.brushIntervalFactor ); // Adjust the left brush handle await aiops.explainLogRateSpikesPage.adjustBrushHandler( - 'aiopsBrushBaseline', + 'aiopsBrushDeviation', 'handle--w', - targetBaselinePx - intervalPx * (testData.brushIntervalFactor - 1) + targetPx - intervalPx * (testData.brushIntervalFactor - 1) ); - } - // Get the new brush selection width for later comparison. - const brushSelectionWidthAfter = await aiops.explainLogRateSpikesPage.getBrushSelectionWidth( - 'aiopsBrushDeviation' - ); + if (testData.brushBaselineTargetTimestamp) { + // Get the px values for the timestamp we want to move the brush to. + const { targetPx: targetBaselinePx } = + await aiops.explainLogRateSpikesPage.getPxForTimestamp( + testData.brushBaselineTargetTimestamp + ); + + // Adjust the right brush handle + await aiops.explainLogRateSpikesPage.adjustBrushHandler( + 'aiopsBrushBaseline', + 'handle--e', + targetBaselinePx + intervalPx * testData.brushIntervalFactor + ); - // Assert the adjusted brush: The selection width should have changed and - // we test if the selection is smaller than two bucket intervals. - // Finally, the adjusted brush should trigger - // a warning on the "Rerun analysis" button. - expect(brushSelectionWidthBefore).not.to.be(brushSelectionWidthAfter); - expect(brushSelectionWidthAfter).not.to.be.greaterThan( - intervalPx * 2 * testData.brushIntervalFactor - ); + // Adjust the left brush handle + await aiops.explainLogRateSpikesPage.adjustBrushHandler( + 'aiopsBrushBaseline', + 'handle--w', + targetBaselinePx - intervalPx * (testData.brushIntervalFactor - 1) + ); + } + + // Get the new brush selection width for later comparison. + const brushSelectionWidthAfter = + await aiops.explainLogRateSpikesPage.getBrushSelectionWidth('aiopsBrushDeviation'); + + // Assert the adjusted brush: The selection width should have changed and + // we test if the selection is smaller than two bucket intervals. + // Finally, the adjusted brush should trigger + // a warning on the "Rerun analysis" button. + expect(brushSelectionWidthBefore).not.to.be(brushSelectionWidthAfter); + expect(brushSelectionWidthAfter).not.to.be.greaterThan( + intervalPx * 2 * testData.brushIntervalFactor + ); - await aiops.explainLogRateSpikesPage.assertRerunAnalysisButtonExists(true); + await aiops.explainLogRateSpikesPage.assertRerunAnalysisButtonExists(true); - await ml.testExecution.logTestStep('rerun the analysis with adjusted settings'); + await ml.testExecution.logTestStep('rerun the analysis with adjusted settings'); + + await aiops.explainLogRateSpikesPage.clickRerunAnalysisButton(true); + } - await aiops.explainLogRateSpikesPage.clickRerunAnalysisButton(true); await aiops.explainLogRateSpikesPage.assertProgressTitle('Progress: 100% — Done.'); // The group switch should be disabled by default @@ -178,14 +179,14 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { ); } - // Assert the field selector that allows to costumize grouping + await ml.testExecution.logTestStep('open the field filter'); await aiops.explainLogRateSpikesPage.assertFieldFilterPopoverButtonExists(false); await aiops.explainLogRateSpikesPage.clickFieldFilterPopoverButton(true); await aiops.explainLogRateSpikesPage.assertFieldSelectorFieldNameList( testData.expected.fieldSelectorPopover ); - // Filter fields + await ml.testExecution.logTestStep('filter fields'); await aiops.explainLogRateSpikesPage.setFieldSelectorSearch(testData.fieldSelectorSearch); await aiops.explainLogRateSpikesPage.assertFieldSelectorFieldNameList([ testData.fieldSelectorSearch, @@ -196,6 +197,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { ); if (testData.fieldSelectorApplyAvailable) { + await ml.testExecution.logTestStep('regroup results'); await aiops.explainLogRateSpikesPage.clickFieldFilterApplyButton(); if (!isTestDataExpectedWithSampleProbability(testData.expected)) { @@ -206,6 +208,28 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { ); } } + + if (testData.action !== undefined) { + await ml.testExecution.logTestStep('check all table row actions are present'); + await aiops.explainLogRateSpikesAnalysisGroupsTable.assertRowActions( + testData.action.tableRowId + ); + + await ml.testExecution.logTestStep('click log pattern analysis action'); + await aiops.explainLogRateSpikesAnalysisGroupsTable.clickRowAction( + testData.action.tableRowId, + testData.action.type + ); + + await ml.testExecution.logTestStep('check log pattern analysis page loaded correctly'); + await aiops.logPatternAnalysisPageProvider.assertLogCategorizationPageExists(); + await aiops.logPatternAnalysisPageProvider.assertTotalDocumentCount( + testData.action.expected.totalDocCount + ); + await aiops.logPatternAnalysisPageProvider.assertQueryInput( + testData.action.expected.queryBar + ); + } }); } @@ -223,13 +247,27 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await ml.testResources.setKibanaTimeZoneToUTC(); - await ml.securityUI.loginAsMlPowerUser(); + if (testData.dataGenerator === 'kibana_sample_data_logs') { + await PageObjects.security.login('elastic', 'changeme', { + expectSuccess: true, + }); + + await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { + useActualUrl: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.home.addSampleDataSet('logs'); + await PageObjects.header.waitUntilLoadingHasFinished(); + } else { + await ml.securityUI.loginAsMlPowerUser(); + } }); after(async () => { await elasticChart.setNewChartUiDebugFlag(false); - await ml.testResources.deleteIndexPatternByTitle(testData.sourceIndexOrSavedSearch); - + if (testData.dataGenerator !== 'kibana_sample_data_logs') { + await ml.testResources.deleteIndexPatternByTitle(testData.sourceIndexOrSavedSearch); + } await aiops.explainLogRateSpikesDataGenerator.removeGeneratedData(testData.dataGenerator); }); diff --git a/x-pack/test/functional/apps/aiops/test_data.ts b/x-pack/test/functional/apps/aiops/test_data.ts index b6d3293aeba81..95e21fbfc7800 100644 --- a/x-pack/test/functional/apps/aiops/test_data.ts +++ b/x-pack/test/functional/apps/aiops/test_data.ts @@ -7,6 +7,111 @@ import type { TestData } from './types'; +export const kibanaLogsDataViewTestData: TestData = { + suiteTitle: 'kibana sample data logs', + dataGenerator: 'kibana_sample_data_logs', + isSavedSearch: false, + sourceIndexOrSavedSearch: 'kibana_sample_data_logs', + brushIntervalFactor: 1, + chartClickCoordinates: [235, 0], + fieldSelectorSearch: 'referer', + fieldSelectorApplyAvailable: true, + action: { + type: 'LogPatternAnalysis', + tableRowId: '488337254', + expected: { + queryBar: + 'clientip:30.156.16.164 AND host.keyword:elastic-elastic-elastic.org AND ip:30.156.16.163 AND response.keyword:404 AND machine.os.keyword:win xp AND geo.dest:IN AND geo.srcdest:US\\:IN', + totalDocCount: '100', + }, + }, + expected: { + totalDocCountFormatted: '14,074', + analysisGroupsTable: [ + { + group: + '* clientip: 30.156.16.164* host.keyword: elastic-elastic-elastic.org* ip: 30.156.16.163* referer: http://www.elastic-elastic-elastic.com/success/timothy-l-kopra* response.keyword: 404Showing 5 out of 8 items. 8 items unique to this group.', + docCount: '100', + }, + ], + filteredAnalysisGroupsTable: [ + { + group: + '* clientip: 30.156.16.164* host.keyword: elastic-elastic-elastic.org* ip: 30.156.16.163* response.keyword: 404* machine.os.keyword: win xpShowing 5 out of 7 items. 7 items unique to this group.', + docCount: '100', + }, + ], + analysisTable: [ + { + fieldName: 'clientip', + fieldValue: '30.156.16.164', + logRate: 'Chart type:bar chart', + pValue: '3.10e-13', + impact: 'High', + }, + { + fieldName: 'geo.dest', + fieldValue: 'IN', + logRate: 'Chart type:bar chart', + pValue: '0.000716', + impact: 'Medium', + }, + { + fieldName: 'geo.srcdest', + fieldValue: 'US:IN', + logRate: 'Chart type:bar chart', + pValue: '0.000716', + impact: 'Medium', + }, + { + fieldName: 'host.keyword', + fieldValue: 'elastic-elastic-elastic.org', + logRate: 'Chart type:bar chart', + pValue: '7.14e-9', + impact: 'High', + }, + { + fieldName: 'ip', + fieldValue: '30.156.16.163', + logRate: 'Chart type:bar chart', + pValue: '3.28e-13', + impact: 'High', + }, + { + fieldName: 'machine.os.keyword', + fieldValue: 'win xp', + logRate: 'Chart type:bar chart', + pValue: '0.0000997', + impact: 'Medium', + }, + { + fieldName: 'referer', + fieldValue: 'http://www.elastic-elastic-elastic.com/success/timothy-l-kopra', + logRate: 'Chart type:bar chart', + pValue: '4.74e-13', + impact: 'High', + }, + { + fieldName: 'response.keyword', + fieldValue: '404', + logRate: 'Chart type:bar chart', + pValue: '0.00000604', + impact: 'Medium', + }, + ], + fieldSelectorPopover: [ + 'clientip', + 'geo.dest', + 'geo.srcdest', + 'host.keyword', + 'ip', + 'machine.os.keyword', + 'referer', + 'response.keyword', + ], + }, +}; + export const farequoteDataViewTestData: TestData = { suiteTitle: 'farequote with spike', dataGenerator: 'farequote_with_spike', @@ -122,6 +227,7 @@ export const artificialLogDataViewTestData: TestData = { }; export const explainLogRateSpikesTestData: TestData[] = [ + kibanaLogsDataViewTestData, farequoteDataViewTestData, farequoteDataViewTestDataWithQuery, artificialLogDataViewTestData, diff --git a/x-pack/test/functional/apps/aiops/types.ts b/x-pack/test/functional/apps/aiops/types.ts index 01733a8e1a2af..2093d4d961363 100644 --- a/x-pack/test/functional/apps/aiops/types.ts +++ b/x-pack/test/functional/apps/aiops/types.ts @@ -7,6 +7,15 @@ import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +interface TestDataTableActionLogPatternAnalysis { + type: 'LogPatternAnalysis'; + tableRowId: string; + expected: { + queryBar: string; + totalDocCount: string; + }; +} + interface TestDataExpectedWithSampleProbability { totalDocCountFormatted: string; sampleProbabilityFormatted: string; @@ -40,11 +49,12 @@ export interface TestData { sourceIndexOrSavedSearch: string; rowsPerPage?: 10 | 25 | 50; brushBaselineTargetTimestamp?: number; - brushDeviationTargetTimestamp: number; + brushDeviationTargetTimestamp?: number; brushIntervalFactor: number; chartClickCoordinates: [number, number]; fieldSelectorSearch: string; fieldSelectorApplyAvailable: boolean; query?: string; + action?: TestDataTableActionLogPatternAnalysis; expected: TestDataExpectedWithSampleProbability | TestDataExpectedWithoutSampleProbability; } diff --git a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_analysis_groups_table.ts b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_analysis_groups_table.ts index 18cadebbf9afd..b533c50677944 100644 --- a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_analysis_groups_table.ts +++ b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_analysis_groups_table.ts @@ -5,12 +5,17 @@ * 2.0. */ +import expect from '@kbn/expect'; + import { FtrProviderContext } from '../../ftr_provider_context'; export function ExplainLogRateSpikesAnalysisGroupsTableProvider({ getService, }: FtrProviderContext) { + const find = getService('find'); const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const browser = getService('browser'); return new (class AnalysisTable { public async assertSpikeAnalysisTableExists() { @@ -55,5 +60,50 @@ export function ExplainLogRateSpikesAnalysisGroupsTableProvider({ return rows; } + + public rowSelector(rowId: string, subSelector?: string) { + const row = `~aiopsSpikeAnalysisGroupsTable > ~row-${rowId}`; + return !subSelector ? row : `${row} > ${subSelector}`; + } + + public async ensureActionsMenuOpen(rowId: string) { + await retry.tryForTime(30 * 1000, async () => { + await this.ensureActionsMenuClosed(); + + if (!(await find.existsByCssSelector('.euiContextMenuPanel', 1000))) { + await testSubjects.click(this.rowSelector(rowId, 'euiCollapsedItemActionsButton')); + expect(await find.existsByCssSelector('.euiContextMenuPanel', 1000)).to.eql( + true, + 'Actions popover should exist' + ); + } + }); + } + + public async ensureActionsMenuClosed() { + await retry.tryForTime(30 * 1000, async () => { + await browser.pressKeys(browser.keys.ESCAPE); + expect(await find.existsByCssSelector('.euiContextMenuPanel', 1000)).to.eql( + false, + 'Actions popover should not exist' + ); + }); + } + + public async assertRowActions(rowId: string) { + await this.ensureActionsMenuOpen(rowId); + + await testSubjects.existOrFail('aiopsTableActionButtonCopyToClipboard enabled'); + await testSubjects.existOrFail('aiopsTableActionButtonDiscover enabled'); + await testSubjects.existOrFail('aiopsTableActionButtonLogPatternAnalysis enabled'); + + await this.ensureActionsMenuClosed(); + } + + public async clickRowAction(rowId: string, action: string) { + await this.ensureActionsMenuOpen(rowId); + await testSubjects.click(`aiopsTableActionButton${action} enabled`); + await testSubjects.missingOrFail(`aiopsTableActionButton${action} enabled`); + } })(); } diff --git a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts index 1a80ac679f29b..228d47bbc746f 100644 --- a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts +++ b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts @@ -122,6 +122,10 @@ export function ExplainLogRateSpikesDataGeneratorProvider({ getService }: FtrPro return new (class DataGenerator { public async generateData(dataGenerator: string) { switch (dataGenerator) { + case 'kibana_sample_data_logs': + // will be added via UI + break; + case 'farequote_with_spike': await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); @@ -191,6 +195,10 @@ export function ExplainLogRateSpikesDataGeneratorProvider({ getService }: FtrPro public async removeGeneratedData(dataGenerator: string) { switch (dataGenerator) { + case 'kibana_sample_data_logs': + // do not remove + break; + case 'farequote_with_spike': await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); break; diff --git a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts index 3da9ed7c760b7..736437a1d3976 100644 --- a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts +++ b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts @@ -229,9 +229,11 @@ export function ExplainLogRateSpikesPageProvider({ }, async assertProgressTitle(expectedProgressTitle: string) { - await testSubjects.existOrFail('aiopProgressTitle'); - const currentProgressTitle = await testSubjects.getVisibleText('aiopProgressTitle'); - expect(currentProgressTitle).to.be(expectedProgressTitle); + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.existOrFail('aiopProgressTitle'); + const currentProgressTitle = await testSubjects.getVisibleText('aiopProgressTitle'); + expect(currentProgressTitle).to.be(expectedProgressTitle); + }); }, async navigateToIndexPatternSelection() { diff --git a/x-pack/test/functional/services/aiops/index.ts b/x-pack/test/functional/services/aiops/index.ts index 4816d37bcff04..8c208f182f3bd 100644 --- a/x-pack/test/functional/services/aiops/index.ts +++ b/x-pack/test/functional/services/aiops/index.ts @@ -11,6 +11,7 @@ import { ExplainLogRateSpikesPageProvider } from './explain_log_rate_spikes_page import { ExplainLogRateSpikesAnalysisTableProvider } from './explain_log_rate_spikes_analysis_table'; import { ExplainLogRateSpikesAnalysisGroupsTableProvider } from './explain_log_rate_spikes_analysis_groups_table'; import { ExplainLogRateSpikesDataGeneratorProvider } from './explain_log_rate_spikes_data_generator'; +import { LogPatternAnalysisPageProvider } from './log_pattern_analysis_page'; export function AiopsProvider(context: FtrProviderContext) { const explainLogRateSpikesPage = ExplainLogRateSpikesPageProvider(context); @@ -18,11 +19,13 @@ export function AiopsProvider(context: FtrProviderContext) { const explainLogRateSpikesAnalysisGroupsTable = ExplainLogRateSpikesAnalysisGroupsTableProvider(context); const explainLogRateSpikesDataGenerator = ExplainLogRateSpikesDataGeneratorProvider(context); + const logPatternAnalysisPageProvider = LogPatternAnalysisPageProvider(context); return { explainLogRateSpikesPage, explainLogRateSpikesAnalysisTable, explainLogRateSpikesAnalysisGroupsTable, explainLogRateSpikesDataGenerator, + logPatternAnalysisPageProvider, }; } 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 new file mode 100644 index 0000000000000..37872b8d7c051 --- /dev/null +++ b/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export function LogPatternAnalysisPageProvider({ getService, getPageObject }: FtrProviderContext) { + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + + return { + async assertLogCategorizationPageExists() { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.existOrFail('aiopsLogCategorizationPage'); + }); + }, + + async assertQueryInput(expectedQueryString: string) { + const aiopsQueryInput = await testSubjects.find('aiopsQueryInput'); + const actualQueryString = await aiopsQueryInput.getVisibleText(); + expect(actualQueryString).to.eql( + expectedQueryString, + `Expected query bar text to be '${expectedQueryString}' (got '${actualQueryString}')` + ); + }, + + async assertTotalDocumentCount(expectedFormattedTotalDocCount: string) { + await retry.tryForTime(5000, async () => { + const docCount = await testSubjects.getVisibleText('aiopsTotalDocCount'); + expect(docCount).to.eql( + expectedFormattedTotalDocCount, + `Expected total document count to be '${expectedFormattedTotalDocCount}' (got '${docCount}')` + ); + }); + }, + }; +}