diff --git a/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts b/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts index 1c5f5cbbbc0cdc..c3289d1527f2b4 100644 --- a/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts +++ b/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts @@ -10,11 +10,11 @@ import { cloneDeep } from 'lodash'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; export function createCategorizeQuery( - queryIn: QueryDslQueryContainer, + queryIn: QueryDslQueryContainer | undefined, timeField: string, timeRange: { from: number; to: number } | undefined ) { - const query = cloneDeep(queryIn); + const query = cloneDeep(queryIn ?? { match_all: {} }); if (query.bool === undefined) { query.bool = {}; diff --git a/x-pack/plugins/aiops/public/application/url_state/common.test.ts b/x-pack/plugins/aiops/public/application/url_state/common.test.ts new file mode 100644 index 00000000000000..5a972eec7715b0 --- /dev/null +++ b/x-pack/plugins/aiops/public/application/url_state/common.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { isDefaultSearchQuery } from './common'; + +describe('isDefaultSearchQuery', () => { + it('returns true for default search query', () => { + expect(isDefaultSearchQuery({ match_all: {} })).toBe(true); + }); + + it('returns false for non default search query', () => { + expect( + isDefaultSearchQuery({ + bool: { must_not: [{ term: { 'the-term': 'the-value' } }] }, + }) + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/aiops/public/application/url_state/common.ts b/x-pack/plugins/aiops/public/application/url_state/common.ts index 8ca9ec848150ce..eb037dabb9648d 100644 --- a/x-pack/plugins/aiops/public/application/url_state/common.ts +++ b/x-pack/plugins/aiops/public/application/url_state/common.ts @@ -8,11 +8,16 @@ 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, type SearchQueryLanguage } from '@kbn/ml-query-utils'; const defaultSearchQuery = { match_all: {}, +} as const; + +export const isDefaultSearchQuery = (arg: unknown): arg is typeof defaultSearchQuery => { + return isPopulatedObject(arg, ['match_all']); }; export interface AiOpsPageUrlState { diff --git a/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts b/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts index 8d83a884156891..9d80758797324f 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts +++ b/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts @@ -56,10 +56,7 @@ export function useDiscoverLinks() { }, }); - let path = basePath.get(); - path += '/app/discover#/'; - path += '?_g=' + _g; - path += '&_a=' + encodeURIComponent(_a); + const path = `${basePath.get()}/app/discover#/?_g=${_g}&_a=${encodeURIComponent(_a)}`; window.open(path, '_blank'); }; 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 51244a23006345..daac98f67f7502 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 @@ -77,7 +77,7 @@ export interface LogRateAnalysisContentProps { /** Optional callback that exposes data of the completed analysis */ onAnalysisCompleted?: (d: LogRateAnalysisResultsData) => void; /** Optional callback that exposes current window parameters */ - onWindowParametersChange?: (wp?: WindowParameters) => void; + onWindowParametersChange?: (wp?: WindowParameters, replace?: boolean) => void; /** Identifier to indicate the plugin utilizing the component */ embeddingOrigin: string; } @@ -126,7 +126,7 @@ export const LogRateAnalysisContent: FC = ({ windowParametersTouched.current = true; if (onWindowParametersChange) { - onWindowParametersChange(windowParameters); + onWindowParametersChange(windowParameters, true); } }, [onWindowParametersChange, windowParameters]); diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx index 4931503b7366e8..22b6a68006a5e6 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx @@ -113,10 +113,14 @@ export const LogRateAnalysisPage: FC = ({ stickyHistogram }) => { useEffect(() => { if (globalState?.time !== undefined) { - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); + if ( + !isEqual({ from: globalState.time.from, to: globalState.time.to }, timefilter.getTime()) + ) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(globalState?.time), timefilter]); @@ -136,11 +140,14 @@ export const LogRateAnalysisPage: FC = ({ stickyHistogram }) => { }); }, [dataService, searchQueryLanguage, searchString]); - const onWindowParametersHandler = (wp?: WindowParameters) => { - if (!isEqual(wp, stateFromUrl.wp)) { - setUrlState({ - wp: windowParametersToAppState(wp), - }); + const onWindowParametersHandler = (wp?: WindowParameters, replace = false) => { + if (!isEqual(windowParametersToAppState(wp), stateFromUrl.wp)) { + setUrlState( + { + wp: windowParametersToAppState(wp), + }, + replace + ); } }; diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 934a470588960b..4f0e2526d7a1db 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -54,9 +54,14 @@ export const useData = ( timeRangeSelector: selectedDataView?.timeFieldName !== undefined, autoRefreshSelector: true, }); + const timeRangeMemoized = useMemo( + () => timefilter.getActiveBounds(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(timefilter.getActiveBounds())] + ); const fieldStatsRequest: DocumentStatsSearchStrategyParams | undefined = useMemo(() => { - const timefilterActiveBounds = timeRange ?? timefilter.getActiveBounds(); + const timefilterActiveBounds = timeRange ?? timeRangeMemoized; if (timefilterActiveBounds !== undefined) { _timeBuckets.setInterval('auto'); _timeBuckets.setBounds(timefilterActiveBounds); @@ -72,7 +77,7 @@ export const useData = ( }; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastRefresh, searchQuery, timeRange]); + }, [lastRefresh, searchQuery, timeRange, timeRangeMemoized]); const overallStatsRequest = useMemo(() => { return fieldStatsRequest diff --git a/x-pack/plugins/aiops/public/hooks/use_search.ts b/x-pack/plugins/aiops/public/hooks/use_search.ts index 8c62db36289fdc..060e87dab59c5a 100644 --- a/x-pack/plugins/aiops/public/hooks/use_search.ts +++ b/x-pack/plugins/aiops/public/hooks/use_search.ts @@ -9,9 +9,15 @@ import { useMemo } from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { isQuery } from '@kbn/data-plugin/public'; import { getEsQueryFromSavedSearch } from '../application/utils/search_utils'; -import type { AiOpsIndexBasedAppState } from '../application/url_state/common'; +import { + isDefaultSearchQuery, + type AiOpsIndexBasedAppState, +} from '../application/url_state/common'; +import { createMergedEsQuery } from '../application/utils/search_utils'; + import { useAiopsAppContext } from './use_aiops_app_context'; export const useSearch = ( @@ -37,23 +43,44 @@ export const useSearch = ( [dataView, uiSettings, savedSearch, filterManager] ); - if (searchData === undefined || (aiopsListState && aiopsListState.searchString !== '')) { - if (aiopsListState?.filters && readOnly === false) { - const globalFilters = filterManager?.getGlobalFilters(); + return useMemo(() => { + if (searchData === undefined || (aiopsListState && aiopsListState.searchString !== '')) { + if (aiopsListState?.filters && readOnly === false) { + const globalFilters = filterManager?.getGlobalFilters(); + + if (filterManager) filterManager.setFilters(aiopsListState.filters); + if (globalFilters) filterManager?.addFilters(globalFilters); + } + + // In cases where the url state contains only a KQL query and not yet + // the transformed ES query we regenerate it. This may happen if we restore + // url state on page load coming from another page like ML's Single Metric Viewer. + let searchQuery = aiopsListState?.searchQuery; + const query = { + language: aiopsListState?.searchQueryLanguage, + query: aiopsListState?.searchString, + }; + if ( + (aiopsListState.searchString !== '' || + (Array.isArray(aiopsListState.filters) && aiopsListState.filters.length > 0)) && + (isDefaultSearchQuery(searchQuery) || searchQuery === undefined) && + isQuery(query) + ) { + searchQuery = createMergedEsQuery(query, aiopsListState.filters, dataView, uiSettings); + } - if (filterManager) filterManager.setFilters(aiopsListState.filters); - if (globalFilters) filterManager?.addFilters(globalFilters); + return { + ...(isDefaultSearchQuery(searchQuery) ? {} : { searchQuery }), + searchString: aiopsListState?.searchString, + searchQueryLanguage: aiopsListState?.searchQueryLanguage, + }; + } else { + return { + searchQuery: searchData.searchQuery, + searchString: searchData.searchString, + searchQueryLanguage: searchData.queryLanguage, + }; } - return { - searchQuery: aiopsListState?.searchQuery, - searchString: aiopsListState?.searchString, - searchQueryLanguage: aiopsListState?.searchQueryLanguage, - }; - } else { - return { - searchQuery: searchData.searchQuery, - searchString: searchData.searchString, - searchQueryLanguage: searchData.queryLanguage, - }; - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify([searchData, aiopsListState])]); }; diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx index b81f632c9a62c7..c67092978d38a9 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx @@ -9,7 +9,7 @@ import { cloneDeep } from 'lodash'; import moment from 'moment'; import rison from '@kbn/rison'; import React, { FC, useEffect, useMemo, useState } from 'react'; -import { APP_ID as MAPS_APP_ID } from '@kbn/maps-plugin/common'; + import { EuiButtonIcon, EuiContextMenuItem, @@ -18,6 +18,9 @@ import { EuiProgress, EuiToolTip, } from '@elastic/eui'; + +import type { SerializableRecord } from '@kbn/utility-types'; +import { APP_ID as MAPS_APP_ID } from '@kbn/maps-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { ES_FIELD_TYPES } from '@kbn/field-types'; @@ -36,15 +39,19 @@ import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { CATEGORIZE_FIELD_TRIGGER } from '@kbn/ml-ui-actions'; import { isDefined } from '@kbn/ml-is-defined'; import { escapeQuotes } from '@kbn/es-query'; +import { isQuery } from '@kbn/data-plugin/public'; + import { PLUGIN_ID } from '../../../../common/constants/app'; -import { mlJobService } from '../../services/job_service'; import { findMessageField, getDataViewIdFromName } from '../../util/index_utils'; import { getInitialAnomaliesLayers, getInitialSourceIndexFieldLayers } from '../../../maps/util'; import { parseInterval } from '../../../../common/util/parse_interval'; +import { ML_APP_LOCATOR, ML_PAGES } from '../../../../common/constants/locator'; +import { getFiltersForDSLQuery } from '../../../../common/util/job_utils'; + +import { mlJobService } from '../../services/job_service'; import { ml } from '../../services/ml_api_service'; import { escapeKueryForFieldValuePair, replaceStringTokens } from '../../util/string_utils'; import { getUrlForRecord, openCustomUrlWindow } from '../../util/custom_url_utils'; -import { ML_APP_LOCATOR, ML_PAGES } from '../../../../common/constants/locator'; import { escapeDoubleQuotes, getDateFormatTz, @@ -54,8 +61,12 @@ import { usePermissionCheck } from '../../capabilities/check_capabilities'; import type { TimeRangeBounds } from '../../util/time_buckets'; import { useMlKibana } from '../../contexts/kibana'; import { getFieldTypeFromMapping } from '../../services/mapping_service'; + import { getQueryStringForInfluencers } from './get_query_string_for_influencers'; -import { getFiltersForDSLQuery } from '../../../../common/util/job_utils'; + +const LOG_RATE_ANALYSIS_MARGIN_FACTOR = 20; +const LOG_RATE_ANALYSIS_BASELINE_FACTOR = 15; + interface LinksMenuProps { anomaly: MlAnomaliesTableRecord; bounds: TimeRangeBounds; @@ -71,6 +82,7 @@ interface LinksMenuProps { export const LinksMenuUI = (props: LinksMenuProps) => { const [openInDiscoverUrl, setOpenInDiscoverUrl] = useState(); const [discoverUrlError, setDiscoverUrlError] = useState(); + const [openInLogRateAnalysisUrl, setOpenInLogRateAnalysisUrl] = useState(); const [messageField, setMessageField] = useState<{ dataView: DataView; @@ -239,20 +251,39 @@ export const LinksMenuUI = (props: LinksMenuProps) => { } })(); - const generateDiscoverUrl = async () => { + // withWindowParameters is used to generate the url state + // for Log Rate Analysis to create a baseline and deviation + // selection based on the anomaly record timestamp and bucket span. + const generateRedirectUrlPageState = async ( + withWindowParameters = false, + timeAttribute = 'timeRange' + ): Promise => { const interval = props.interval; const dataViewId = await getDataViewId(); const record = props.anomaly.source; - const earliestMoment = moment(record.timestamp).startOf(interval); - if (interval === 'hour') { + // Use the exact timestamp for Log Rate Analysis, + // in all other cases snap it to the provided interval. + const earliestMoment = withWindowParameters + ? moment(record.timestamp) + : moment(record.timestamp).startOf(interval); + + // For Log Rate Analysis, look back further to + // provide enough room for the baseline time range. + // In all other cases look back 1 hour. + if (withWindowParameters) { + earliestMoment.subtract(record.bucket_span * LOG_RATE_ANALYSIS_MARGIN_FACTOR, 's'); + } else if (interval === 'hour') { // Start from the previous hour. earliestMoment.subtract(1, 'h'); } const latestMoment = moment(record.timestamp).add(record.bucket_span, 's'); - if (props.isAggregatedData === true) { + + if (withWindowParameters) { + latestMoment.add(record.bucket_span * LOG_RATE_ANALYSIS_MARGIN_FACTOR, 's'); + } else if (props.isAggregatedData === true) { if (interval === 'hour') { // Show to the end of the next hour. latestMoment.add(1, 'h'); @@ -263,9 +294,16 @@ export const LinksMenuUI = (props: LinksMenuProps) => { const from = timeFormatter(earliestMoment.unix() * 1000); // e.g. 2016-02-08T16:00:00.000Z const to = timeFormatter(latestMoment.unix() * 1000); // e.g. 2016-02-08T18:59:59.000Z + // The window parameters for Log Rate Analysis. + // The deviation time range will span the current anomaly's bucket. + const dMin = record.timestamp; + const dMax = record.timestamp + record.bucket_span * 1000; + const bMax = dMin - record.bucket_span * 1000; + const bMin = bMax - record.bucket_span * 1000 * LOG_RATE_ANALYSIS_BASELINE_FACTOR; + let kqlQuery = ''; - if (record.influencers) { + if (record.influencers && !withWindowParameters) { kqlQuery = record.influencers .filter((influencer) => isDefined(influencer)) .map((influencer) => { @@ -283,9 +321,21 @@ export const LinksMenuUI = (props: LinksMenuProps) => { .join(' AND '); } - const url = await discoverLocator.getRedirectUrl({ + // For multi-metric or population jobs, we add the selected entity for links to + // Log Rate Analysis, so they can be restored as part of the search filter. + if (withWindowParameters && props.anomaly.entityName && props.anomaly.entityValue) { + if (kqlQuery !== '') { + kqlQuery += ' AND '; + } + + kqlQuery = `"${escapeQuotes(props.anomaly.entityName)}":"${escapeQuotes( + props.anomaly.entityValue + '' + )}"`; + } + + return { indexPatternId: dataViewId, - timeRange: { + [timeAttribute]: { from, to, mode: 'absolute', @@ -298,15 +348,76 @@ export const LinksMenuUI = (props: LinksMenuProps) => { dataViewId === null ? [] : getFiltersForDSLQuery(job.datafeed_config.query, dataViewId, job.job_id), - }); + ...(withWindowParameters + ? { + wp: { bMin, bMax, dMin, dMax }, + } + : {}), + }; + }; + + const generateDiscoverUrl = async () => { + const pageState = await generateRedirectUrlPageState(); + const url = await discoverLocator.getRedirectUrl(pageState); if (!unmounted) { setOpenInDiscoverUrl(url); } }; + const generateLogRateAnalysisUrl = async () => { + if ( + props.anomaly.source.function_description !== 'count' || + // Disable link for datafeeds that use aggregations + // and define a non-standard summary count field name + (job.analysis_config.summary_count_field_name !== undefined && + job.analysis_config.summary_count_field_name !== 'doc_count') + ) { + if (!unmounted) { + setOpenInLogRateAnalysisUrl(undefined); + } + return; + } + + const mlLocator = share.url.locators.get(ML_APP_LOCATOR); + + if (!mlLocator) { + // eslint-disable-next-line no-console + console.error('Unable to detect locator for ML or bounds'); + return; + } + const pageState = await generateRedirectUrlPageState(true, 'time'); + + const { indexPatternId, wp, query, filters, ...globalState } = pageState; + + const url = await mlLocator.getRedirectUrl({ + page: ML_PAGES.AIOPS_LOG_RATE_ANALYSIS, + pageState: { + index: indexPatternId, + globalState, + appState: { + logRateAnalysis: { + wp, + ...(isQuery(query) + ? { + filters, + searchString: query.query, + searchQueryLanguage: query.language, + } + : {}), + }, + }, + }, + }); + + if (!unmounted) { + setOpenInLogRateAnalysisUrl(url); + } + }; + if (!isCategorizationAnomalyRecord) { generateDiscoverUrl(); + generateLogRateAnalysisUrl(); } else { getDataViewId(); } @@ -606,10 +717,7 @@ export const LinksMenuUI = (props: LinksMenuProps) => { // Need to encode the _a parameter as it will contain characters such as '+' if using the regex. const { basePath } = kibana.services.http; - let path = basePath.get(); - path += '/app/discover#/'; - path += '?_g=' + _g; - path += '&_a=' + encodeURIComponent(_a); + const path = `${basePath.get()}/app/discover#/?_g=${_g}&_a=${encodeURIComponent(_a)}`; window.open(path, '_blank'); }) .catch((resp) => { @@ -813,10 +921,26 @@ export const LinksMenuUI = (props: LinksMenuProps) => { ); } + if (openInLogRateAnalysisUrl) { + items.push( + + + + ); + } + if (messageField !== null) { items.push( { closePopover(); diff --git a/x-pack/test/functional/apps/aiops/index.ts b/x-pack/test/functional/apps/aiops/index.ts index 0326a2f80b082e..8706d3d242c6bc 100644 --- a/x-pack/test/functional/apps/aiops/index.ts +++ b/x-pack/test/functional/apps/aiops/index.ts @@ -30,6 +30,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { }); loadTestFile(require.resolve('./log_rate_analysis')); + loadTestFile(require.resolve('./log_rate_analysis_anomaly_table')); loadTestFile(require.resolve('./change_point_detection')); loadTestFile(require.resolve('./log_pattern_analysis')); loadTestFile(require.resolve('./log_pattern_analysis_in_discover')); diff --git a/x-pack/test/functional/apps/aiops/log_rate_analysis_anomaly_table.ts b/x-pack/test/functional/apps/aiops/log_rate_analysis_anomaly_table.ts new file mode 100644 index 00000000000000..c92ecd8b044c3a --- /dev/null +++ b/x-pack/test/functional/apps/aiops/log_rate_analysis_anomaly_table.ts @@ -0,0 +1,287 @@ +/* + * 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; + +import type { LogRateAnalysisType } from '@kbn/aiops-utils'; +import type { Datafeed, Job } from '@kbn/ml-plugin/server/shared'; + +import { isDefaultSearchQuery } from '@kbn/aiops-plugin/public/application/url_state/common'; + +import type { FtrProviderContext } from '../../ftr_provider_context'; + +import type { LogRateAnalysisDataGenerator } from '../../services/aiops/log_rate_analysis_data_generator'; + +function getJobWithDataFeed( + detectorFunction: string, + detectorField?: string, + partitionFieldName?: string, + query: QueryDslQueryContainer = { match_all: {} } +) { + const postFix = `${detectorFunction}${detectorField ? `_${detectorField}` : ''}${ + partitionFieldName ? `_${partitionFieldName}` : '' + }${!isDefaultSearchQuery(query) ? '_with_query' : ''}`; + const jobId = `fq_lra_${postFix}`; + + // @ts-expect-error not full interface + const jobConfig: Job = { + job_id: jobId, + description: `${detectorFunction}(${ + detectorField ? detectorField : '' + }) on farequote dataset with 15m bucket span`, + groups: ['farequote', 'automated', partitionFieldName ? 'multi-metric' : 'single-metric'], + analysis_config: { + bucket_span: '15m', + influencers: [], + detectors: [ + { + function: detectorFunction, + ...(detectorField ? { field_name: detectorField } : {}), + ...(partitionFieldName ? { partition_field_name: partitionFieldName } : {}), + }, + ], + }, + data_description: { time_field: '@timestamp' }, + analysis_limits: { model_memory_limit: '10mb' }, + model_plot_config: { enabled: true }, + }; + + // @ts-expect-error not full interface + const datafeedConfig: Datafeed = { + datafeed_id: `datafeed-fq_lra_${postFix}`, + indices: ['ft_farequote'], + job_id: jobId, + query, + }; + + return { jobConfig, datafeedConfig }; +} + +interface TestData { + jobConfig: Job; + datafeedConfig: Datafeed; + analysisType: LogRateAnalysisType; + dataGenerator: LogRateAnalysisDataGenerator; + entitySelectionField?: string; + entitySelectionValue?: string; + expected: { + anomalyTableLogRateAnalysisButtonAvailable: boolean; + totalDocCount?: number; + analysisResults?: Array<{ + fieldName: string; + fieldValue: string; + impact: string; + logRate: string; + pValue: string; + }>; + }; +} + +const testData: TestData[] = [ + // Single metric job, should find AAL with log rate analysis + { + ...getJobWithDataFeed('count'), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: true, + totalDocCount: 7869, + analysisResults: [ + { + fieldName: 'airline', + fieldValue: 'AAL', + impact: 'High', + logRate: 'Chart type:bar chart', + pValue: '8.96e-49', + }, + ], + }, + }, + // Multi metric job, should filter by AAL, no significant results + { + ...getJobWithDataFeed('high_count', undefined, 'airline'), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + entitySelectionField: 'airline', + entitySelectionValue: 'AAL', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: true, + totalDocCount: 910, + }, + }, + // Single metric job with datafeed query filter, no significant results + { + ...getJobWithDataFeed('count', undefined, undefined, { + bool: { + must: [ + { + term: { + airline: { + value: 'AAL', + }, + }, + }, + ], + }, + }), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: true, + totalDocCount: 910, + }, + }, + // Single metric job with non-count detector, link should not be available + { + ...getJobWithDataFeed('mean', 'responsetime'), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: false, + }, + }, +]; + +export default function ({ getService }: FtrProviderContext) { + const aiops = getService('aiops'); + const browser = getService('browser'); + const comboBox = getService('comboBox'); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const ml = getService('ml'); + + describe('anomaly table with link to log rate analysis', async function () { + this.tags(['ml']); + + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await ml.testResources.createDataViewIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + for (const page of ['anomaly explorer', 'single metric viewer']) { + for (const td of testData) { + const { + jobConfig, + datafeedConfig, + analysisType, + dataGenerator, + entitySelectionField, + entitySelectionValue, + expected, + } = td; + describe(`via ${page} for job ${jobConfig.job_id}`, async function () { + before(async () => { + await ml.api.createAndRunAnomalyDetectionLookbackJob(jobConfig, datafeedConfig); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteDataViewByTitle('ft_farequote'); + }); + + it('should navigate to ML job management', async () => { + await ml.testExecution.logTestStep('navigate to job list'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToJobManagement(); + }); + + it(`should load the ${page} for job '${jobConfig.job_id}`, async () => { + await ml.testExecution.logTestStep('open job in single metric viewer'); + await ml.jobTable.filterWithSearchString(jobConfig.job_id, 1); + + if (page === 'single metric viewer') { + await ml.jobTable.clickOpenJobInSingleMetricViewerButton(jobConfig.job_id); + } else { + await ml.jobTable.clickOpenJobInAnomalyExplorerButton(jobConfig.job_id); + } + + await ml.commonUI.waitForMlLoadingIndicatorToDisappear(); + + if (page === 'single metric viewer' && entitySelectionField && entitySelectionValue) { + await testSubjects.existOrFail( + `mlSingleMetricViewerEntitySelection ${entitySelectionField}` + ); + await comboBox.set( + `mlSingleMetricViewerEntitySelection ${entitySelectionField} > comboBoxInput`, + entitySelectionValue + ); + } + }); + + it(`should show the anomaly table`, async () => { + await ml.testExecution.logTestStep('displays the anomalies table'); + await ml.anomaliesTable.assertTableExists(); + + await ml.testExecution.logTestStep('anomalies table is not empty'); + await ml.anomaliesTable.assertTableNotEmpty(); + }); + + if (expected.anomalyTableLogRateAnalysisButtonAvailable) { + it('should click the log rate analysis action', async () => { + await ml.anomaliesTable.assertAnomalyActionsMenuButtonExists(0); + await ml.anomaliesTable.scrollRowIntoView(0); + await ml.anomaliesTable.assertAnomalyActionsMenuButtonEnabled(0, true); + await ml.anomaliesTable.assertAnomalyActionLogRateAnalysisButtonExists(0); + await ml.anomaliesTable.ensureAnomalyActionLogRateAnalysisButtonClicked(0); + + if (expected.totalDocCount !== undefined) { + await aiops.logPatternAnalysisPage.assertTotalDocumentCount(expected.totalDocCount); + } + }); + + const shouldHaveResults = expected.analysisResults !== undefined; + + it('should complete the analysis', async () => { + await aiops.logRateAnalysisPage.assertAnalysisComplete( + analysisType, + dataGenerator, + !shouldHaveResults + ); + }); + + if (shouldHaveResults) { + it('should show analysis results', async () => { + await aiops.logRateAnalysisResultsTable.assertLogRateAnalysisResultsTableExists(); + const actualAnalysisTable = + await aiops.logRateAnalysisResultsTable.parseAnalysisTable(); + + expect(actualAnalysisTable).to.be.eql( + expected.analysisResults, + `Expected analysis table to be ${JSON.stringify( + expected.analysisResults + )}, got ${JSON.stringify(actualAnalysisTable)}` + ); + }); + } + + it('should navigate back to the anomaly table', async () => { + await browser.goBack(); + + await ml.testExecution.logTestStep('displays the anomalies table'); + await ml.anomaliesTable.assertTableExists(); + + await ml.testExecution.logTestStep('anomalies table is not empty'); + await ml.anomaliesTable.assertTableNotEmpty(); + }); + } else { + it('should not show the log rate analysis action', async () => { + await ml.anomaliesTable.assertAnomalyActionsMenuButtonExists(0); + await ml.anomaliesTable.scrollRowIntoView(0); + await ml.anomaliesTable.assertAnomalyActionsMenuButtonEnabled(0, true); + await ml.anomaliesTable.assertAnomalyActionLogRateAnalysisButtonNotExists(0); + }); + } + }); + } + } + }); +} diff --git a/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts b/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts index 6b1b45f4aa9ebe..e46b751ece54dd 100644 --- a/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts +++ b/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts @@ -244,7 +244,8 @@ export function LogRateAnalysisPageProvider({ getService, getPageObject }: FtrPr async assertAnalysisComplete( analysisType: LogRateAnalysisType, - dataGenerator: LogRateAnalysisDataGenerator + dataGenerator: LogRateAnalysisDataGenerator, + noResults = false ) { const dataGeneratorParts = dataGenerator.split('_'); const zeroDocsFallback = dataGeneratorParts.includes('zerodocsfallback'); @@ -253,6 +254,11 @@ export function LogRateAnalysisPageProvider({ getService, getPageObject }: FtrPr const currentProgressTitle = await testSubjects.getVisibleText('aiopsAnalysisComplete'); expect(currentProgressTitle).to.be('Analysis complete'); + if (noResults) { + await testSubjects.existOrFail('aiopsNoResultsFoundEmptyPrompt'); + return; + } + await testSubjects.existOrFail('aiopsAnalysisTypeCalloutTitle'); const currentAnalysisTypeCalloutTitle = await testSubjects.getVisibleText( 'aiopsAnalysisTypeCalloutTitle' diff --git a/x-pack/test/functional/services/ml/anomalies_table.ts b/x-pack/test/functional/services/ml/anomalies_table.ts index 8e6c652b33d97a..52eaf5715f6739 100644 --- a/x-pack/test/functional/services/ml/anomalies_table.ts +++ b/x-pack/test/functional/services/ml/anomalies_table.ts @@ -131,6 +131,24 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide ); }, + async assertAnomalyActionLogRateAnalysisButtonExists(rowIndex: number) { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.existOrFail('mlAnomaliesListRowAction_runLogRateAnalysisButton'); + }, + + async assertAnomalyActionLogRateAnalysisButtonNotExists(rowIndex: number) { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.missingOrFail('mlAnomaliesListRowAction_runLogRateAnalysisButton'); + }, + + async ensureAnomalyActionLogRateAnalysisButtonClicked(rowIndex: number) { + await retry.tryForTime(10 * 1000, async () => { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.click('mlAnomaliesListRowAction_runLogRateAnalysisButton'); + await testSubjects.existOrFail('aiopsLogRateAnalysisPage'); + }); + }, + /** * Asserts selected number of rows per page on the pagination control. * @param rowsNumber