diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/search_alerts/search_alerts.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/search_alerts/search_alerts.ts index 21f33a768cba..517590f9f499 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/search_alerts/search_alerts.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/search_alerts/search_alerts.ts @@ -68,6 +68,10 @@ export interface SearchAlertsParams { * The page size to fetch */ pageSize: number; + /** + * Force using the default context, otherwise use the AlertQueryContext + */ + skipAlertsQueryContext?: boolean; /** * The minimum score to apply to the query */ diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_search_alerts_query.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_search_alerts_query.ts index c94289280840..bae828084dd3 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_search_alerts_query.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_search_alerts_query.ts @@ -27,7 +27,11 @@ export const queryKeyPrefix = ['alerts', searchAlerts.name]; * When testing components that depend on this hook, prefer mocking the {@link searchAlerts} function instead of the hook itself. * @external https://tanstack.com/query/v4/docs/framework/react/guides/testing */ -export const useSearchAlertsQuery = ({ data, ...params }: UseSearchAlertsQueryParams) => { +export const useSearchAlertsQuery = ({ + data, + skipAlertsQueryContext, + ...params +}: UseSearchAlertsQueryParams) => { const { ruleTypeIds, consumers, @@ -64,7 +68,7 @@ export const useSearchAlertsQuery = ({ data, ...params }: UseSearchAlertsQueryPa trackScores, }), refetchOnWindowFocus: false, - context: AlertsQueryContext, + context: skipAlertsQueryContext ? undefined : AlertsQueryContext, enabled: ruleTypeIds.length > 0, // To avoid flash of empty state with pagination, see https://tanstack.com/query/latest/docs/framework/react/guides/paginated-queries#better-paginated-queries-with-placeholderdata keepPreviousData: true, diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx index c28818302ac2..780e23bb0252 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.test.tsx @@ -204,6 +204,7 @@ describe('Alert details', () => { expect(alertDetails.queryByTestId('alert-summary-container')).toBeFalsy(); expect(alertDetails.queryByTestId('overviewTab')).toBeTruthy(); expect(alertDetails.queryByTestId('metadataTab')).toBeTruthy(); + expect(alertDetails.queryByTestId('relatedAlertsTab')).toBeTruthy(); }); it('should show Metadata tab', async () => { diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx index cee55c918a78..3e0929a55fbf 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx @@ -6,7 +6,7 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { @@ -60,6 +60,8 @@ import StaleAlert from './components/stale_alert'; import { RelatedDashboards } from './components/related_dashboards'; import { getAlertTitle } from '../../utils/format_alert_title'; import { AlertSubtitle } from './components/alert_subtitle'; +import { ProximalAlertsCallout } from './proximal_alerts_callout'; +import { useTabId } from './hooks/use_tab_id'; import { useRelatedDashboards } from './hooks/use_related_dashboards'; interface AlertDetailsPathParams { @@ -74,7 +76,6 @@ const defaultBreadcrumb = i18n.translate('xpack.observability.breadcrumbs.alertD export const LOG_DOCUMENT_COUNT_RULE_TYPE_ID = 'logs.alert.document.count'; export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; -const ALERT_DETAILS_TAB_URL_STORAGE_KEY = 'tabId'; const TAB_IDS = [ 'overview', @@ -91,6 +92,7 @@ const isTabId = (value: string): value is TabId => { }; export function AlertDetails() { + const { services } = useKibana(); const { cases: { helpers: { canUseCases }, @@ -101,12 +103,11 @@ export function AlertDetails() { observabilityAIAssistant, uiSettings, serverless, - } = useKibana().services; - - const { search } = useLocation(); - const history = useHistory(); + } = services; const { ObservabilityPageTemplate, config } = usePluginContext(); const { alertId } = useParams(); + const { getUrlTabId, setUrlTabId } = useTabId(); + const urlTabId = getUrlTabId(); const { isLoadingRelatedDashboards, suggestedDashboards, @@ -135,17 +136,17 @@ export function AlertDetails() { const { euiTheme } = useEuiTheme(); const [sources, setSources] = useState(); const [activeTabId, setActiveTabId] = useState(); + const handleSetTabId = async (tabId: TabId) => { setActiveTabId(tabId); - let searchParams = new URLSearchParams(search); if (tabId === 'related_alerts') { - searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId); + setUrlTabId(tabId, true, { + filterProximal: 'true', + }); } else { - searchParams = new URLSearchParams(); - searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId); + setUrlTabId(tabId, true); } - history.replace({ search: searchParams.toString() }); }; useEffect(() => { @@ -171,11 +172,9 @@ export function AlertDetails() { if (alertDetail) { setRuleTypeModel(ruleTypeRegistry.get(alertDetail?.formatted.fields[ALERT_RULE_TYPE_ID]!)); setAlertStatus(alertDetail?.formatted?.fields[ALERT_STATUS] as AlertStatus); - const searchParams = new URLSearchParams(search); - const urlTabId = searchParams.get(ALERT_DETAILS_TAB_URL_STORAGE_KEY); setActiveTabId(urlTabId && isTabId(urlTabId) ? urlTabId : 'overview'); } - }, [alertDetail, ruleTypeRegistry, search]); + }, [alertDetail, ruleTypeRegistry, urlTabId]); useBreadcrumbs( [ @@ -199,6 +198,10 @@ export function AlertDetails() { setAlertStatus(ALERT_STATUS_UNTRACKED); }, []); + const showRelatedAlertsFromCallout = () => { + handleSetTabId('related_alerts'); + }; + usePageReady({ isRefreshing: isLoading, isReady: !isLoading && !!alertDetail && activeTabId === 'overview', @@ -253,6 +256,10 @@ export function AlertDetails() { + {rule && alertDetail.formatted && ( @@ -273,6 +280,11 @@ export function AlertDetails() { ) : ( + + diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_alerts/related_alerts_table.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_alerts/related_alerts_table.tsx index dfe8894d93a0..30e2fa2f7465 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_alerts/related_alerts_table.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_alerts/related_alerts_table.tsx @@ -11,7 +11,7 @@ import { ALERT_START, ALERT_UUID } from '@kbn/rule-data-utils'; import { AlertsTable } from '@kbn/response-ops-alerts-table'; import { SortOrder } from '@elastic/elasticsearch/lib/api/types'; import { getRelatedColumns } from './get_related_columns'; -import { useBuildRelatedAlertsQuery } from '../../hooks/related_alerts/use_build_related_alerts_query'; +import { getBuildRelatedAlertsQuery } from '../../hooks/related_alerts/get_build_related_alerts_query'; import { AlertData } from '../../../../hooks/use_fetch_alert_detail'; import { GetObservabilityAlertsTableProp, @@ -25,6 +25,8 @@ import { AlertsFlyoutFooter } from '../../../../components/alerts_flyout/alerts_ import { OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES } from '../../../../../common/constants'; import { AlertsTableCellValue } from '../../../../components/alerts_table/common/cell_value'; import { casesFeatureIdV2 } from '../../../../../common'; +import { useFilterProximalParam } from '../../hooks/use_filter_proximal_param'; +import { RelatedAlertsTableFilter } from './related_alerts_table_filter'; interface Props { alertData: AlertData; @@ -52,14 +54,15 @@ const RELATED_ALERTS_TABLE_ID = 'xpack.observability.alerts.relatedAlerts'; export function RelatedAlertsTable({ alertData }: Props) { const { formatted: alert } = alertData; - const esQuery = useBuildRelatedAlertsQuery({ alert }); + const { filterProximal } = useFilterProximalParam(); + const esQuery = getBuildRelatedAlertsQuery({ alert, filterProximal }); const { observabilityRuleTypeRegistry, config } = usePluginContext(); - const services = useKibana().services; return ( + id={RELATED_ALERTS_TABLE_ID} query={esQuery} diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_alerts/related_alerts_table_filter.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_alerts/related_alerts_table_filter.tsx new file mode 100644 index 000000000000..d90f18a23d39 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/related_alerts/related_alerts_table_filter.tsx @@ -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 { EuiCheckbox, EuiFlexGroup, EuiFormRow, EuiPanel, EuiText } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useFilterProximalParam } from '../../hooks/use_filter_proximal_param'; + +export function RelatedAlertsTableFilter() { + const { filterProximal, setProximalFilterParam } = useFilterProximalParam(); + + return ( + + + + + {i18n.translate('xpack.observability.alerts.relatedAlerts.filtersLabel', { + defaultMessage: 'Filters', + })} + + + + { + setProximalFilterParam(event.target.checked); + }} + id={'proximal-alerts-checkbox'} + data-test-subj="proximal-alerts-checkbox" + /> + + + + ); +} diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/related_alerts/use_build_related_alerts_query.ts b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/related_alerts/get_build_related_alerts_query.ts similarity index 92% rename from x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/related_alerts/use_build_related_alerts_query.ts rename to x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/related_alerts/get_build_related_alerts_query.ts index fff62250ba01..a1e8514066b6 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/related_alerts/use_build_related_alerts_query.ts +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/related_alerts/get_build_related_alerts_query.ts @@ -23,9 +23,13 @@ import { TopAlert } from '../../../../typings/alerts'; interface Props { alert: TopAlert; + filterProximal: boolean; } -export function useBuildRelatedAlertsQuery({ alert }: Props): QueryDslQueryContainer { +export function getBuildRelatedAlertsQuery({ + alert, + filterProximal, +}: Props): QueryDslQueryContainer { const groups = alert.fields[ALERT_GROUP]; const shouldGroups: QueryDslQueryContainer[] = []; groups?.forEach(({ field, value }) => { @@ -58,14 +62,22 @@ export function useBuildRelatedAlertsQuery({ alert }: Props): QueryDslQueryConta const tags = alert.fields[ALERT_RULE_TAGS] ?? []; const instanceId = alert.fields[ALERT_INSTANCE_ID]?.split(',') ?? []; + const range = filterProximal ? [30, 'minutes'] : [1, 'days']; + return { bool: { filter: [ { range: { [ALERT_START]: { - gte: startDate.clone().subtract(1, 'days').toISOString(), - lte: startDate.clone().add(1, 'days').toISOString(), + gte: startDate + .clone() + .subtract(...range) + .toISOString(), + lte: startDate + .clone() + .add(...range) + .toISOString(), }, }, }, diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_filter_proximal_param.ts b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_filter_proximal_param.ts new file mode 100644 index 000000000000..00ced92fecb1 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_filter_proximal_param.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 { useHistory, useLocation } from 'react-router-dom'; + +export const useFilterProximalParam = () => { + const { search } = useLocation(); + const searchParams = new URLSearchParams(search); + const history = useHistory(); + + const setProximalFilterParam = (proximalFilter: boolean) => { + searchParams.set('filterProximal', String(proximalFilter)); + history.replace({ search: searchParams.toString() }); + }; + + const filterProximal = searchParams.get('filterProximal') === 'true'; + + return { filterProximal, setProximalFilterParam }; +}; diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_find_proximal_alerts.ts b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_find_proximal_alerts.ts new file mode 100644 index 000000000000..e94520ddb4c0 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_find_proximal_alerts.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSearchAlertsQuery } from '@kbn/alerts-ui-shared/src/common/hooks/use_search_alerts_query'; +import { + OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES, + observabilityAlertFeatureIds, +} from '../../../../common/constants'; +import { AlertData } from '../../../hooks/use_fetch_alert_detail'; +import { useKibana } from '../../../utils/kibana_react'; +import { getBuildRelatedAlertsQuery } from './related_alerts/get_build_related_alerts_query'; + +export const useFindProximalAlerts = (alertDetail: AlertData) => { + const { services } = useKibana(); + + const esQuery = getBuildRelatedAlertsQuery({ + alert: alertDetail.formatted, + filterProximal: true, + }); + + return useSearchAlertsQuery({ + data: services.data, + ruleTypeIds: OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES, + consumers: observabilityAlertFeatureIds, + query: esQuery, + skipAlertsQueryContext: true, + }); +}; diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_tab_id.ts b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_tab_id.ts new file mode 100644 index 000000000000..f0f004ddb689 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_tab_id.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 { useHistory, useLocation } from 'react-router-dom'; + +const ALERT_DETAILS_TAB_URL_STORAGE_KEY = 'tabId'; + +export const useTabId = () => { + const { search } = useLocation(); + const history = useHistory(); + + const getUrlTabId = () => { + const searchParams = new URLSearchParams(search); + return searchParams.get(ALERT_DETAILS_TAB_URL_STORAGE_KEY); + }; + + const setUrlTabId = ( + tabId: string, + overrideSearchState?: boolean, + newSearchState?: Record + ) => { + const searchParams = new URLSearchParams(overrideSearchState ? undefined : search); + searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId); + + if (newSearchState) { + Object.entries(newSearchState).forEach(([key, value]) => { + searchParams.set(key, value); + }); + } + + history.replace({ search: searchParams.toString() }); + }; + + return { + getUrlTabId, + setUrlTabId, + }; +}; diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/proximal_alerts_callout.test.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/proximal_alerts_callout.test.tsx new file mode 100644 index 000000000000..5d4c2c121138 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/proximal_alerts_callout.test.tsx @@ -0,0 +1,78 @@ +/* + * 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 { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import React from 'react'; +import { AlertData, useFetchAlertDetail } from '../../hooks/use_fetch_alert_detail'; +import { useFindProximalAlerts } from './hooks/use_find_proximal_alerts'; +import { ConfigSchema } from '../../plugin'; +import { Subset } from '../../typings'; +import { render } from '../../utils/test_helper'; +import { alertDetail } from './mock/alert'; +import { ProximalAlertsCallout } from './proximal_alerts_callout'; +import { fireEvent } from '@testing-library/dom'; + +jest.mock('../../utils/kibana_react'); + +jest.mock('../../hooks/use_fetch_alert_detail'); + +jest.mock('./hooks/use_find_proximal_alerts'); +jest.mock('@kbn/observability-shared-plugin/public'); +jest.mock('@kbn/ebt-tools'); + +const useFetchAlertDetailMock = useFetchAlertDetail as jest.Mock; +const useFindProximalAlertsMock = useFindProximalAlerts as jest.Mock; + +const config: Subset = { + unsafe: { + alertDetails: { + uptime: { enabled: true }, + }, + }, +}; + +describe('Proximal callout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const switchTabs = jest.fn(); + + const renderCallout = (alert: AlertData) => + render( + + + , + config + ); + + it('should recommend the user see more related alerts', async () => { + useFindProximalAlertsMock.mockReturnValue({ + data: { total: 5 }, + isError: false, + isLoading: false, + }); + + useFetchAlertDetailMock.mockReturnValue([false, alertDetail]); + const callout = renderCallout(alertDetail); + expect(callout.queryByTestId('see-proximal-alerts')).toBeTruthy(); + fireEvent.click(callout.getByText('See related alerts')); + expect(switchTabs).toHaveBeenCalled(); + }); + + it('should not recommend the user see more related alerts', async () => { + useFindProximalAlertsMock.mockReturnValue({ + data: { total: 0 }, + isError: false, + isLoading: false, + }); + + useFetchAlertDetailMock.mockReturnValue([false, alertDetail]); + const callout = renderCallout(alertDetail); + expect(callout.queryByTestId('see-proximal-alerts')).toBeFalsy(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/proximal_alerts_callout.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/proximal_alerts_callout.tsx new file mode 100644 index 000000000000..a433dd556dcf --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/proximal_alerts_callout.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiCallOut, EuiIcon, EuiLink, useEuiTheme } from '@elastic/eui'; +import { useFindProximalAlerts } from './hooks/use_find_proximal_alerts'; +import { AlertData } from '../../hooks/use_fetch_alert_detail'; + +interface Props { + alertDetail: AlertData; + switchTabs: () => void; +} + +export function ProximalAlertsCallout({ alertDetail, switchTabs }: Props) { + const { euiTheme } = useEuiTheme(); + + const { data, isError, isLoading } = useFindProximalAlerts(alertDetail); + + const count = data?.total; + + if (isLoading || isError || count === undefined || count < 0) { + return null; + } + + return ( + + {i18n.translate('xpack.observability.alertDetails.proximalAlert.description', { + defaultMessage: + '{count, plural, one {# alert was} other {# alerts were}} created around the same time.', + values: { + count, + }, + })} + {count > 0 && ( + switchTabs()} + > + {i18n.translate('xpack.observability.alertDetails.proximalAlert.action', { + defaultMessage: 'See related alerts', + })}{' '} + + + )} + + ); +}