diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index b6f22e9c5fb4d..0c6acee136f5c 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -5,15 +5,7 @@ * 2.0. */ -import { - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingLogo, - EuiSpacer, - EuiTab, - EuiTabs, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Case, UpdateKey } from '../../../common/ui'; import { useCaseViewNavigation, useUrlParams } from '../../common/navigation'; @@ -28,6 +20,7 @@ import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; import { WhitePageWrapperNoBorder } from '../wrappers'; import { CaseViewActivity } from './components/case_view_activity'; +import { CaseViewAlerts } from './components/case_view_alerts'; import { CaseViewMetrics } from './metrics'; import { ACTIVITY_TAB, ALERTS_TAB } from './translations'; import { CaseViewPageProps, CASE_VIEW_PAGE_TABS } from './types'; @@ -36,7 +29,7 @@ import { useOnUpdateField } from './use_on_update_field'; // This hardcoded constant is left here intentionally // as a way to hide a wip functionality // that will be merge in the 8.3 release. -const ENABLE_ALERTS_TAB = false; +const ENABLE_ALERTS_TAB = true; export const CaseViewPage = React.memo( ({ @@ -194,12 +187,7 @@ export const CaseViewPage = React.memo( { id: CASE_VIEW_PAGE_TABS.ALERTS, name: ALERTS_TAB, - content: ( - } - title={

{'Alerts table placeholder'}

} - /> - ), + content: , }, ] : []), diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx new file mode 100644 index 0000000000000..30d4636275674 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 { waitFor } from '@testing-library/dom'; +import { alertCommentWithIndices, basicCase } from '../../../containers/mock'; +import { AppMockRenderer, createAppMockRenderer } from '../../../common/mock'; +import { Case } from '../../../../common'; +import { CaseViewAlerts } from './case_view_alerts'; +import * as api from '../../../containers/api'; + +jest.mock('../../../containers/api'); + +const caseData: Case = { + ...basicCase, + comments: [...basicCase.comments, alertCommentWithIndices], +}; + +describe('Case View Page activity tab', () => { + const getAlertsStateTableMock = jest.fn(); + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + appMockRender.coreStart.triggersActionsUi.getAlertsStateTable = + getAlertsStateTableMock.mockReturnValue(
); + jest.clearAllMocks(); + }); + + it('should render the alerts table', async () => { + const result = appMockRender.render(); + await waitFor(async () => { + expect(result.getByTestId('alerts-table')).toBeTruthy(); + }); + }); + + it('should call the alerts table with correct props', async () => { + appMockRender.render(); + await waitFor(async () => { + expect(getAlertsStateTableMock).toHaveBeenCalledWith({ + alertsTableConfigurationRegistry: expect.anything(), + configurationId: 'securitySolution', + featureIds: ['siem', 'observability'], + id: 'case-details-alerts-securitySolution', + query: { + ids: { + values: ['alert-id-1'], + }, + }, + }); + }); + }); + + it('should call the getFeatureIds with the correct registration context', async () => { + const getFeatureIdsMock = jest.spyOn(api, 'getFeatureIds'); + appMockRender.render(); + await waitFor(async () => { + expect(getFeatureIdsMock).toHaveBeenCalledWith( + { registrationContext: ['matchme'] }, + expect.anything() + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx new file mode 100644 index 0000000000000..75da3fd3fe470 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Case } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; +import { getManualAlertIds, getRegistrationContextFromAlerts } from './helpers'; +import { useGetFeatureIds } from '../../../containers/use_get_feature_ids'; + +interface CaseViewAlertsProps { + caseData: Case; +} +export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => { + const { triggersActionsUi } = useKibana().services; + + const alertIdsQuery = useMemo( + () => ({ + ids: { + values: getManualAlertIds(caseData.comments), + }, + }), + [caseData.comments] + ); + const alertRegistrationContexts = useMemo( + () => getRegistrationContextFromAlerts(caseData.comments), + [caseData.comments] + ); + + const alertFeatureIds = useGetFeatureIds(alertRegistrationContexts); + + const alertStateProps = { + alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, + configurationId: caseData.owner, + id: `case-details-alerts-${caseData.owner}`, + featureIds: alertFeatureIds, + query: alertIdsQuery, + }; + + return <>{triggersActionsUi.getAlertsStateTable(alertStateProps)}; +}; +CaseViewAlerts.displayName = 'CaseViewAlerts'; diff --git a/x-pack/plugins/cases/public/components/case_view/components/helpers.test.ts b/x-pack/plugins/cases/public/components/case_view/components/helpers.test.ts new file mode 100644 index 0000000000000..fba878ef1061a --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/helpers.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { alertComment } from '../../../containers/mock'; +import { getManualAlertIds, getRegistrationContextFromAlerts } from './helpers'; + +const comment = { + ...alertComment, + alertId: 'alert-id-1', + index: '.alerts-matchme.alerts', +}; +const comment2 = { + ...alertComment, + alertId: 'alert-id-2', + index: '.alerts-another.alerts', +}; + +const comment3 = { + ...alertComment, + alertId: ['nested1', 'nested2', 'nested3'], +}; + +const commentSiemSignal = { + ...alertComment, + alertId: 'alert-id-siem', + index: '.siem-signals-default-000008', +}; + +const commentIsBad = { + ...alertComment, + alertId: 'alert-id-bad', + index: 'bad-siem-signals-default-000008', +}; + +const multipleIndices = { + ...alertComment, + alertId: ['test-id-1', 'test-id-2', 'test-id-3', 'test-id-4', 'test-id-5', 'test-id-6'], + index: [ + '.internal.alerts-security.alerts-default-000001', + '.internal.alerts-observability.logs.alerts-default-000001', + '.internal.alerts-observability.uptime.alerts-default-000001', + '.internal.alerts-observability.metrics.alerts-default-000001', + '.internal.alerts-observability.apm.alerts-space2-000001', + '.internal.alerts-observability.logs.alerts-space1-000001', + ], +}; + +describe('Case view helpers', () => { + describe('getRegistrationContextFromAlerts', () => { + it('returns the correct registration context', () => { + const result = getRegistrationContextFromAlerts([comment, comment2, multipleIndices]); + expect(result).toEqual([ + 'matchme', + 'another', + 'security', + 'observability.logs', + 'observability.uptime', + 'observability.metrics', + 'observability.apm', + ]); + }); + + it('dedupes contexts', () => { + const result = getRegistrationContextFromAlerts([comment, comment]); + expect(result).toEqual(['matchme']); + }); + + it('returns the correct registration when find a .siem-signals* index', () => { + const result = getRegistrationContextFromAlerts([commentSiemSignal, comment2]); + expect(result).toEqual(['security', 'another']); + }); + + it('returns empty when the index is not formatted as expected', () => { + const result = getRegistrationContextFromAlerts([commentIsBad]); + expect(result).toEqual([]); + }); + }); + + describe('getManualAlertIds', () => { + it('returns the alert ids', () => { + const result = getManualAlertIds([comment, comment2]); + expect(result).toEqual(['alert-id-1', 'alert-id-2']); + }); + + it('returns the alerts id from multiple alerts in a comment', () => { + const result = getManualAlertIds([comment, comment2, comment3]); + expect(result).toEqual(['alert-id-1', 'alert-id-2', 'nested1', 'nested2', 'nested3']); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/helpers.ts b/x-pack/plugins/cases/public/components/case_view/components/helpers.ts new file mode 100644 index 0000000000000..0fb247dda5282 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/helpers.ts @@ -0,0 +1,51 @@ +/* + * 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 { CommentType } from '../../../../common/api'; +import type { Comment } from '../../../containers/types'; + +export const getManualAlertIds = (comments: Comment[]): string[] => { + const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => { + if (comment.type === CommentType.alert) { + const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + ids.forEach((id) => alertIds.add(id)); + return alertIds; + } + return alertIds; + }, new Set()); + return Array.from(dedupeAlerts); +}; + +export const getRegistrationContextFromAlerts = (comments: Comment[]): string[] => { + const dedupeRegistrationContext = comments.reduce((registrationContexts, comment: Comment) => { + if (comment.type === CommentType.alert) { + const indices = Array.isArray(comment.index) ? comment.index : [comment.index]; + indices.forEach((index) => { + // That's legacy code, we created some index alias so everything should work as expected + if (index.startsWith('.siem-signals')) { + registrationContexts.add('security'); + } else { + const registrationContext = getRegistrationContextFromIndex(index); + if (registrationContext) { + registrationContexts.add(registrationContext); + } + } + }); + return registrationContexts; + } + return registrationContexts; + }, new Set()); + return Array.from(dedupeRegistrationContext); +}; + +export const getRegistrationContextFromIndex = (indexName: string): string | null => { + const found = indexName.match(/\.alerts-(.*?).alerts/); + if (found && found.length > 1) { + return `${found[1]}`; + } + return null; +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/helpers.ts b/x-pack/plugins/cases/public/components/user_actions/helpers.ts index 673af99ed7772..140165f3d5963 100644 --- a/x-pack/plugins/cases/public/components/user_actions/helpers.ts +++ b/x-pack/plugins/cases/public/components/user_actions/helpers.ts @@ -6,6 +6,7 @@ */ import { isEmpty } from 'lodash'; + import { CommentType } from '../../../common/api'; import type { Comment } from '../../containers/types'; import { SUPPORTED_ACTION_TYPES } from './constants'; @@ -23,5 +24,5 @@ export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => } return alertIds; }, new Set()); - return [...dedupeAlerts]; + return Array.from(dedupeAlerts); }; diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index c330fb7eb9cf0..201b3878ab380 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -38,6 +38,7 @@ import { CaseStatuses, SingleCaseMetricsResponse, } from '../../../common/api'; +import type { ValidFeatureId } from '@kbn/rule-data-utils'; export const getCase = async ( caseId: string, @@ -133,3 +134,8 @@ export const pushCase = async ( export const getActionLicense = async (signal: AbortSignal): Promise => Promise.resolve(actionLicenses); + +export const getFeatureIds = async ( + _query: { registrationContext: string[] }, + _signal: AbortSignal +): Promise => Promise.resolve(['siem', 'observability']); diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index e37955b2768c0..00553a99fe365 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -6,6 +6,7 @@ */ import { httpServiceMock } from '@kbn/core/public/mocks'; +import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common'; import { KibanaServices } from '../common/lib/kibana'; import { ConnectorTypes, CommentType, CaseStatuses, CaseSeverity } from '../../common/api'; @@ -31,6 +32,7 @@ import { createAttachments, pushCase, resolveCase, + getFeatureIds, } from './api'; import { @@ -605,4 +607,25 @@ describe('Case Configuration API', () => { expect(resp).toBe(undefined); }); }); + + describe('getFeatureIds', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(['siem', 'observability']); + }); + + test('should be called with correct check url, method, signal', async () => { + const resp = await getFeatureIds( + { registrationContext: ['security', 'observability.logs'] }, + abortCtrl.signal + ); + + expect(fetchMock).toHaveBeenCalledWith(`${BASE_RAC_ALERTS_API_PATH}/_feature_ids`, { + query: { registrationContext: ['security', 'observability.logs'] }, + signal: abortCtrl.signal, + }); + + expect(resp).toEqual(['siem', 'observability']); + }); + }); }); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index b0f00ad202c5f..344f390908104 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants'; import { Cases, FetchCasesProps, @@ -333,3 +335,16 @@ export const createAttachments = async ( ); return convertToCamelCase(decodeCaseResponse(response)); }; + +export const getFeatureIds = async ( + query: { registrationContext: string[] }, + signal: AbortSignal +): Promise => { + return KibanaServices.get().http.fetch( + `${BASE_RAC_ALERTS_API_PATH}/_feature_ids`, + { + signal, + query, + } + ); +}; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 8cf413d08f2fd..1d056d166263b 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -91,6 +91,25 @@ export const alertComment: AlertComment = { version: 'WzQ3LDFc', }; +export const alertCommentWithIndices: AlertComment = { + alertId: 'alert-id-1', + index: '.alerts-matchme.alerts', + type: CommentType.alert, + id: 'alert-comment-id', + createdAt: basicCreatedAt, + createdBy: elasticUser, + owner: SECURITY_SOLUTION_OWNER, + pushedAt: null, + pushedBy: null, + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, + updatedAt: null, + updatedBy: null, + version: 'WzQ3LDFc', +}; + export const hostIsolationComment: () => Comment = () => { return { type: CommentType.actions, diff --git a/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx b/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx new file mode 100644 index 0000000000000..df39cc883d532 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_feature_ids.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 { ValidFeatureId } from '@kbn/rule-data-utils'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/dom'; +import React from 'react'; +import { TestProviders } from '../common/mock'; +import { useGetFeatureIds } from './use_get_feature_ids'; +import * as api from './api'; + +jest.mock('./api'); +jest.mock('../common/lib/kibana'); + +describe('useGetFeaturesIds', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('inits with empty data', async () => { + jest.spyOn(api, 'getFeatureIds').mockRejectedValue([]); + const { result } = renderHook(() => useGetFeatureIds(['context1']), { + wrapper: ({ children }) => {children}, + }); + act(() => { + expect(result.current).toEqual([]); + }); + }); + + it('fetches data and returns it correctly', async () => { + const spy = jest.spyOn(api, 'getFeatureIds'); + const { result } = renderHook(() => useGetFeatureIds(['context1']), { + wrapper: ({ children }) => {children}, + }); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith( + { registrationContext: ['context1'] }, + expect.any(AbortSignal) + ); + }); + + expect(result.current).toEqual(['siem', 'observability']); + }); + + it('throws an error correctly', async () => { + const spy = jest.spyOn(api, 'getFeatureIds'); + spy.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + const { result } = renderHook(() => useGetFeatureIds(['context1']), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toEqual([]); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx b/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx new file mode 100644 index 0000000000000..ca181c0596eec --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_feature_ids.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState, useRef } from 'react'; +import type { ValidFeatureId } from '@kbn/rule-data-utils'; + +import * as i18n from './translations'; +import { useToasts } from '../common/lib/kibana'; +import { getFeatureIds } from './api'; + +export const useGetFeatureIds = (alertRegistrationContexts: string[]): ValidFeatureId[] => { + const [alertFeatureIds, setAlertFeatureIds] = useState([]); + const toasts = useToasts(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const fetchFeatureIds = useCallback( + async (registrationContext: string[]) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + const query = { registrationContext }; + const response = await getFeatureIds(query, abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + setAlertFeatureIds(response); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + } + } + }, + [toasts] + ); + + useEffect(() => { + fetchFeatureIds(alertRegistrationContexts); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alertRegistrationContexts]); + + return alertFeatureIds; +}; diff --git a/x-pack/plugins/rule_registry/common/search_strategy/index.ts b/x-pack/plugins/rule_registry/common/search_strategy/index.ts index 1f3b415e53926..90ea6fbf95e70 100644 --- a/x-pack/plugins/rule_registry/common/search_strategy/index.ts +++ b/x-pack/plugins/rule_registry/common/search_strategy/index.ts @@ -7,13 +7,18 @@ import { ValidFeatureId } from '@kbn/rule-data-utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { Ecs } from '@kbn/core/server'; -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { IEsSearchRequest, IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { + QueryDslFieldAndFormat, + QueryDslQueryContainer, + SortCombinations, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; export type RuleRegistrySearchRequest = IEsSearchRequest & { featureIds: ValidFeatureId[]; - query?: { bool: estypes.QueryDslBoolQuery }; - sort?: estypes.SortCombinations[]; + fields?: QueryDslFieldAndFormat[]; + query?: Pick; + sort?: SortCombinations[]; pagination?: RuleRegistrySearchRequestPagination; }; @@ -52,15 +57,16 @@ type Join = K extends string | number ? P extends string | number ? `${K}${'' extends P ? '' : '.'}${P}` : never - : never; + : string; type DotNestedKeys = [D] extends [never] ? never : T extends object ? { [K in keyof T]-?: Join> }[keyof T] - : ''; + : never; -type EcsFieldsResponse = { - [Property in DotNestedKeys]: string[]; +export type EcsFields = DotNestedKeys>; +export type EcsFieldsResponse = { + [Property in EcsFields]: string[]; }; export type RuleRegistrySearchResponse = IEsSearchResponse; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts index d2e841a79cb31..3c3edf4e988ec 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts @@ -18,6 +18,7 @@ const createAlertsClientMock = () => { getAuthorizedAlertsIndices: jest.fn(), bulkUpdate: jest.fn(), find: jest.fn(), + getFeatureIdsByRegistrationContexts: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts index dc7be33ad3739..348dfebed1fcd 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts @@ -681,4 +681,39 @@ export class AlertsClient { throw Boom.failedDependency(errMessage); } } + + public async getFeatureIdsByRegistrationContexts( + RegistrationContexts: string[] + ): Promise { + try { + const featureIds = + this.ruleDataService.findFeatureIdsByRegistrationContexts(RegistrationContexts); + if (featureIds.length > 0) { + // ATTENTION FUTURE DEVELOPER when you are a super user the augmentedRuleTypes.authorizedRuleTypes will + // return all of the features that you can access and does not care about your featureIds + const augmentedRuleTypes = await this.authorization.getAugmentedRuleTypesWithAuthorization( + featureIds, + [ReadOperations.Find, ReadOperations.Get, WriteOperations.Update], + AlertingAuthorizationEntity.Alert + ); + // As long as the user can read a minimum of one type of rule type produced by the provided feature, + // the user should be provided that features' alerts index. + // Limiting which alerts that user can read on that index will be done via the findAuthorizationFilter + const authorizedFeatures = new Set(); + for (const ruleType of augmentedRuleTypes.authorizedRuleTypes) { + authorizedFeatures.add(ruleType.producer); + } + const validAuthorizedFeatures = Array.from(authorizedFeatures).filter( + (feature): feature is ValidFeatureId => + featureIds.includes(feature) && isValidFeatureId(feature) + ); + return validAuthorizedFeatures; + } + return featureIds; + } catch (exc) { + const errMessage = `getFeatureIdsByRegistrationContexts failed to get feature ids: ${exc}`; + this.logger.error(errMessage); + throw Boom.failedDependency(errMessage); + } + } } diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts index 6793bfceb34d2..b750b37aa51b5 100644 --- a/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts @@ -32,3 +32,10 @@ export const getUpdateRequest = () => index: '.alerts-observability.apm.alerts*', }, }); + +export const getReadFeatureIdsRequest = () => + requestMock.create({ + method: 'get', + path: `${BASE_RAC_ALERTS_API_PATH}/_feature_ids`, + query: { registrationContext: ['security'] }, + }); diff --git a/x-pack/plugins/rule_registry/server/routes/get_feature_ids_by_registration_contexts.test.ts b/x-pack/plugins/rule_registry/server/routes/get_feature_ids_by_registration_contexts.test.ts new file mode 100644 index 0000000000000..80f2b4e6026d0 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_feature_ids_by_registration_contexts.test.ts @@ -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 { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; +import { getFeatureIdsByRegistrationContexts } from './get_feature_ids_by_registration_contexts'; +import { requestContextMock } from './__mocks__/request_context'; +import { getReadFeatureIdsRequest } from './__mocks__/request_responses'; +import { requestMock, serverMock } from './__mocks__/server'; + +describe('getFeatureIdsByRegistrationContexts', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + + beforeEach(async () => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.rac.getFeatureIdsByRegistrationContexts.mockResolvedValue(['siem']); + + getFeatureIdsByRegistrationContexts(server.router); + }); + + test('returns 200 when querying for features ids', async () => { + const response = await server.inject(getReadFeatureIdsRequest(), context); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(['siem']); + }); + + describe('request validation', () => { + test('rejects invalid query params', async () => { + await expect( + server.inject( + requestMock.create({ + method: 'get', + path: `${BASE_RAC_ALERTS_API_PATH}/_feature_ids`, + query: { registrationContext: 4 }, + }), + context + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request was rejected with message: 'Invalid value \\"4\\" supplied to \\"registrationContext\\"'"` + ); + }); + + test('rejects unknown query params', async () => { + await expect( + server.inject( + requestMock.create({ + method: 'get', + path: `${BASE_RAC_ALERTS_API_PATH}/_feature_ids`, + query: { boop: 'siem' }, + }), + context + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request was rejected with message: 'invalid keys \\"boop\\"'"` + ); + }); + }); + + test('returns error status if rac client "getFeatureIdsByRegistrationContexts" fails', async () => { + clients.rac.getFeatureIdsByRegistrationContexts.mockRejectedValue( + new Error('Unable to get feature ids') + ); + const response = await server.inject(getReadFeatureIdsRequest(), context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + attributes: { success: false }, + message: 'Unable to get feature ids', + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/routes/get_feature_ids_by_registration_contexts.ts b/x-pack/plugins/rule_registry/server/routes/get_feature_ids_by_registration_contexts.ts new file mode 100644 index 0000000000000..28e0aa5940709 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_feature_ids_by_registration_contexts.ts @@ -0,0 +1,66 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import * as t from 'io-ts'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { RacRequestHandlerContext } from '../types'; +import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; +import { buildRouteValidation } from './utils/route_validation'; + +export const getFeatureIdsByRegistrationContexts = (router: IRouter) => { + router.get( + { + path: `${BASE_RAC_ALERTS_API_PATH}/_feature_ids`, + validate: { + query: buildRouteValidation( + t.exact( + t.partial({ + registrationContext: t.union([t.string, t.array(t.string)]), + }) + ) + ), + }, + options: { + tags: ['access:rac'], + }, + }, + async (context, request, response) => { + try { + const racContext = await context.rac; + const alertsClient = await racContext.getAlertsClient(); + const { registrationContext = [] } = request.query; + const featureIds = await alertsClient.getFeatureIdsByRegistrationContexts( + Array.isArray(registrationContext) ? registrationContext : [registrationContext] + ); + return response.ok({ + body: featureIds, + }); + } catch (exc) { + const err = transformError(exc); + const contentType = { + 'content-type': 'application/json', + }; + const defaultedHeaders = { + ...contentType, + }; + + return response.customError({ + headers: defaultedHeaders, + statusCode: err.statusCode, + body: { + message: err.message, + attributes: { + success: false, + }, + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/rule_registry/server/routes/index.ts b/x-pack/plugins/rule_registry/server/routes/index.ts index 625869ecbe1a8..638fb4e432412 100644 --- a/x-pack/plugins/rule_registry/server/routes/index.ts +++ b/x-pack/plugins/rule_registry/server/routes/index.ts @@ -12,6 +12,7 @@ import { updateAlertByIdRoute } from './update_alert_by_id'; import { getAlertsIndexRoute } from './get_alert_index'; import { bulkUpdateAlertsRoute } from './bulk_update_alerts'; import { findAlertsByQueryRoute } from './find'; +import { getFeatureIdsByRegistrationContexts } from './get_feature_ids_by_registration_contexts'; export function defineRoutes(router: IRouter) { getAlertByIdRoute(router); @@ -19,4 +20,5 @@ export function defineRoutes(router: IRouter) { getAlertsIndexRoute(router); bulkUpdateAlertsRoute(router); findAlertsByQueryRoute(router); + getFeatureIdsByRegistrationContexts(router); } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts index cfbfafd0092bf..9b279a541b3e9 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts @@ -17,6 +17,7 @@ export const ruleDataServiceMock = { initializeIndex: jest.fn(), findIndexByName: jest.fn(), findIndexByFeature: jest.fn(), + findFeatureIdsByRegistrationContexts: jest.fn(), }), }; diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts index a336f73c87c79..71a69dbbba427 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts @@ -72,6 +72,12 @@ export interface IRuleDataService { * Note: features are used in RBAC. */ findIndexByFeature(featureId: ValidFeatureId, dataset: Dataset): IndexInfo | null; + + /** + * Looks up Kibana "feature" associated with the given registration context. + * Note: features are used in RBAC. + */ + findFeatureIdsByRegistrationContexts(registrationContexts: string[]): string[]; } // TODO: This is a leftover. Remove its usage from the "observability" plugin and delete it. @@ -89,6 +95,7 @@ interface ConstructorOptions { export class RuleDataService implements IRuleDataService { private readonly indicesByBaseName: Map; private readonly indicesByFeatureId: Map; + private readonly registrationContextByFeatureId: Map; private readonly resourceInstaller: IResourceInstaller; private installCommonResources: Promise>; private isInitialized: boolean; @@ -96,6 +103,7 @@ export class RuleDataService implements IRuleDataService { constructor(private readonly options: ConstructorOptions) { this.indicesByBaseName = new Map(); this.indicesByFeatureId = new Map(); + this.registrationContextByFeatureId = new Map(); this.resourceInstaller = new ResourceInstaller({ getResourceName: (name) => this.getResourceName(name), getClusterClient: options.getClusterClient, @@ -162,6 +170,8 @@ export class RuleDataService implements IRuleDataService { this.indicesByFeatureId.set(indexOptions.feature, [...indicesAssociatedWithFeature, indexInfo]); this.indicesByBaseName.set(indexInfo.baseName, indexInfo); + this.registrationContextByFeatureId.set(registrationContext, indexOptions.feature); + const waitUntilClusterClientAvailable = async (): Promise => { try { const clusterClient = await this.options.getClusterClient(); @@ -214,6 +224,17 @@ export class RuleDataService implements IRuleDataService { return this.indicesByBaseName.get(baseName) ?? null; } + public findFeatureIdsByRegistrationContexts(registrationContexts: string[]): string[] { + const featureIds: string[] = []; + registrationContexts.forEach((rc) => { + const featureId = this.registrationContextByFeatureId.get(rc); + if (featureId) { + featureIds.push(featureId); + } + }); + return featureIds; + } + public findIndexByFeature(featureId: ValidFeatureId, dataset: Dataset): IndexInfo | null { const foundIndices = this.indicesByFeatureId.get(featureId) ?? []; if (dataset && foundIndices.length > 0) { diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts index 9bfc4d7a40640..1b32f688ee8c0 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts @@ -426,4 +426,104 @@ describe('ruleRegistrySearchStrategyProvider()', () => { `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.` ); }); + + it('passes the query ids if provided', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.SIEM], + query: { + ids: { values: ['test-id'] }, + }, + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + expect(searchStrategySearch).toHaveBeenCalledWith( + { + params: { + body: { + _source: false, + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], + from: 0, + query: { + ids: { + values: ['test-id'], + }, + }, + size: 1000, + sort: [], + }, + index: ['test-testSpace*'], + }, + }, + {}, + { request: {} } + ); + }); + + it('passes the fields if provided', async () => { + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.SIEM], + query: { + ids: { values: ['test-id'] }, + }, + fields: [{ field: '@timestamp', include_unmapped: true }], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + expect(searchStrategySearch).toHaveBeenCalledWith( + { + params: { + body: { + _source: false, + fields: [{ field: '@timestamp', include_unmapped: true }], + from: 0, + query: { + ids: { + values: ['test-id'], + }, + }, + size: 1000, + sort: [], + }, + index: ['test-testSpace*'], + }, + }, + {}, + { request: {} } + ); + }); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts index 255af29a9a9d3..6b9ac15cd8d6a 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts @@ -9,6 +9,7 @@ import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Logger } from '@kbn/core/server'; import { from, of } from 'rxjs'; +import { isEmpty } from 'lodash'; import { isValidFeatureId, AlertConsumers } from '@kbn/rule-data-utils'; import { ENHANCED_ES_SEARCH_STRATEGY } from '@kbn/data-plugin/common'; import { ISearchStrategy, PluginStart } from '@kbn/data-plugin/server'; @@ -30,6 +31,8 @@ export const EMPTY_RESPONSE: RuleRegistrySearchResponse = { rawResponse: {} as RuleRegistrySearchResponse['rawResponse'], }; +const EMPTY_FIELDS = [{ field: '*', include_unmapped: true }]; + export const RULE_SEARCH_STRATEGY_NAME = 'privateRuleRegistryAlertsSearchStrategy'; export const ruleRegistrySearchStrategyProvider = ( @@ -120,16 +123,21 @@ export const ruleRegistrySearchStrategyProvider = ( const sort = request.sort ?? []; const query = { - bool: { - filter, - }, + ...(request.query?.ids != null + ? { ids: request.query?.ids } + : { + bool: { + filter, + }, + }), }; const size = request.pagination ? request.pagination.pageSize : MAX_ALERT_SEARCH_SIZE; const params = { index: indices, body: { _source: false, - fields: ['*'], + // TODO the fields need to come from the request + fields: !isEmpty(request?.fields) ? request?.fields : EMPTY_FIELDS, sort, size, from: request.pagination ? request.pagination.pageIndex * size : 0, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 42daf4d4da2e6..be9bdbac67949 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -10,12 +10,12 @@ import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/ import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; import { ActionType, ActionTypeIndex, ActionTypeRegistryContract } from '../../../types'; import { loadActionTypes } from '../../lib/action_connector_api'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { useKibana } from '../../../common/lib/kibana'; -import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../..'; import { SectionLoading } from '../../components/section_loading'; interface Props { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index a1867bb6362b8..60b7b9711c557 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -26,6 +26,7 @@ import { i18n } from '@kbn/i18n'; import { omit } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; import { withTheme, EuiTheme } from '@kbn/kibana-react-plugin/common'; +import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import { hasDeleteActionsCapability, @@ -43,7 +44,6 @@ import { } from '../../../../types'; import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt'; import { useKibana } from '../../../../common/lib/kibana'; -import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../..'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import ConnectorEditFlyout from '../../action_connector_form/connector_edit_flyout'; import ConnectorAddFlyout from '../../action_connector_form/connector_add_flyout'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx index 44236a8d993f5..7c62f03f00cd0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx @@ -21,7 +21,8 @@ import { EuiProgress, EuiLoadingContent, } from '@elastic/eui'; -import { AlertsField, AlertsData } from '../../../../types'; +import type { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; +import { AlertsField } from '../../../../types'; const SAMPLE_TITLE_LABEL = i18n.translate( 'xpack.triggersActionsUI.sections.alertsTable.alertsFlyout.sampleTitle', @@ -52,7 +53,7 @@ const PAGINATION_LABEL = i18n.translate( ); interface AlertsFlyoutProps { - alert: AlertsData; + alert: EcsFieldsResponse; flyoutIndex: number; alertsCount: number; isLoading: boolean; @@ -99,7 +100,8 @@ export const AlertsFlyout: React.FunctionComponent = ({ ) : ( - {get(alert, AlertsField.name, [])[0]} + {/* any is required here to improve typescript performance */} + {get(alert as any, AlertsField.name, [])[0] as string} )} @@ -112,7 +114,8 @@ export const AlertsFlyout: React.FunctionComponent = ({ ) : ( - {get(alert, AlertsField.reason, [])[0]} + {/* any is required here to improve typescript performance */} + {get(alert as any, AlertsField.reason, [])[0] as string} )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_page/alerts_page.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_page/alerts_page.tsx index 64f89a570d8b4..41a1e1298ccde 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_page/alerts_page.tsx @@ -8,6 +8,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import { get } from 'lodash'; import { EuiDataGridControlColumn, + EuiDataGridSorting, EuiFlexItem, EuiFlexGroup, EuiSpacer, @@ -21,10 +22,10 @@ import { RuleRegistrySearchRequestPagination, } from '@kbn/rule-registry-plugin/common'; import { AbortError } from '@kbn/kibana-utils-plugin/common'; +import type { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; import { PLUGIN_ID } from '../../../../common/constants'; import { AlertsTable } from '../alerts_table'; import { useKibana } from '../../../../common/lib/kibana'; -import { AlertsData, RenderCellValueProps } from '../../../../types'; const consumers = [ AlertConsumers.APM, @@ -38,6 +39,11 @@ const defaultPagination = { pageIndex: 0, }; +const emptyConfiguration = { + id: '', + columns: [], +}; + const defaultSort: estypes.SortCombinations[] = [ { 'event.action': { @@ -52,19 +58,25 @@ const AlertsPage: React.FunctionComponent = () => { const [isLoading, setIsLoading] = useState(false); const [isInitializing, setIsInitializing] = useState(true); const [alertsCount, setAlertsCount] = useState(0); - const [alerts, setAlerts] = useState([]); + const [alerts, setAlerts] = useState([]); const [sort, setSort] = useState(defaultSort); const [pagination, setPagination] = useState(defaultPagination); + const alertsTableConfigurationRegistry = useKibana().services.alertsTableConfigurationRegistry; + const hasAlertsTableConfiguration = alertsTableConfigurationRegistry.has(PLUGIN_ID); + const alertsTableConfiguration = hasAlertsTableConfiguration + ? alertsTableConfigurationRegistry.get(PLUGIN_ID) + : emptyConfiguration; + const onPageChange = (_pagination: RuleRegistrySearchRequestPagination) => { setPagination(_pagination); }; - const onSortChange = (_sort: Array<{ id: string; direction: 'asc' | 'desc' }>) => { + const onSortChange = (_sort: EuiDataGridSorting['columns']) => { setSort( - _sort.map(({ id, direction }) => { + _sort.map((sortItem) => { return { - [id]: { - order: direction, + [sortItem.id]: { + order: sortItem.direction, }, }; }) @@ -86,9 +98,9 @@ const AlertsPage: React.FunctionComponent = () => { }) .subscribe({ next: (res) => { - const alertsResponse = res.rawResponse.hits.hits.map( - (hit) => hit.fields as unknown as AlertsData - ) as AlertsData[]; + const alertsResponse = res.rawResponse.hits.hits.map( + (hit) => hit.fields as EcsFieldsResponse + ); setAlerts(alertsResponse); const total = !isNaN(res.rawResponse.hits.total as number) ? (res.rawResponse.hits.total as number) @@ -131,11 +143,12 @@ const AlertsPage: React.FunctionComponent = () => { refresh: () => { asyncSearch(); }, + sort, }; }; const tableProps = { - configurationId: PLUGIN_ID, + columns: alertsTableConfiguration.columns, consumers, bulkActions: [], deletedEventIds: [], @@ -143,8 +156,9 @@ const AlertsPage: React.FunctionComponent = () => { pageSize: defaultPagination.pageSize, pageSizeOptions: [1, 2, 5, 10, 20, 50, 100], leadingControlColumns: [], - renderCellValue: ({ alert, field }: RenderCellValueProps) => { - const value = get(alert, field, [])[0]; + renderCellValue: ({ alert, field }: { alert: EcsFieldsResponse; field: string }) => { + // any is required here to improve typescript performance + const value = get(alert as any, field, [])[0] as string; return value ?? 'N/A'; }, showCheckboxes, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx index 6a8c6a0ff9680..fdf992a814df2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx @@ -5,50 +5,28 @@ * 2.0. */ import React from 'react'; -import { AlertConsumers } from '@kbn/rule-data-utils'; -import { AlertsTable } from './alerts_table'; -import { AlertsData, AlertsField } from '../../../types'; -import { PLUGIN_ID } from '../../../common/constants'; -import { useKibana } from '../../../common/lib/kibana'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; + +import { AlertsTable } from './alerts_table'; +import { AlertsField } from '../../../types'; + jest.mock('@kbn/data-plugin/public'); -jest.mock('../../../common/lib/kibana'); const columns = [ { - id: 'kibana.alert.rule.name', + id: AlertsField.name, displayAsText: 'Name', }, { - id: 'kibana.alert.rule.category', - displayAsText: 'Category', + id: AlertsField.reason, + displayAsText: 'Reason', }, ]; -const hookUseKibanaMock = useKibana as jest.Mock; -const alertsTableConfigurationRegistryMock = - hookUseKibanaMock().services.alertsTableConfigurationRegistry; -alertsTableConfigurationRegistryMock.has.mockImplementation((plugin: string) => { - return plugin === PLUGIN_ID; -}); -alertsTableConfigurationRegistryMock.get.mockImplementation((plugin: string) => { - if (plugin === PLUGIN_ID) { - return { columns }; - } - return {}; -}); - describe('AlertsTable', () => { - const consumers = [ - AlertConsumers.APM, - AlertConsumers.LOGS, - AlertConsumers.UPTIME, - AlertConsumers.INFRASTRUCTURE, - AlertConsumers.SIEM, - ]; - - const alerts: AlertsData[] = [ + const alerts = [ { [AlertsField.name]: ['one'], [AlertsField.reason]: ['two'], @@ -57,7 +35,7 @@ describe('AlertsTable', () => { [AlertsField.name]: ['three'], [AlertsField.reason]: ['four'], }, - ]; + ] as unknown as EcsFieldsResponse[]; const fetchAlertsData = { activePage: 0, @@ -70,6 +48,7 @@ describe('AlertsTable', () => { onPageChange: jest.fn(), onSortChange: jest.fn(), refresh: jest.fn(), + sort: [], }; const useFetchAlertsData = () => { @@ -77,8 +56,7 @@ describe('AlertsTable', () => { }; const tableProps = { - configurationId: PLUGIN_ID, - consumers, + columns, bulkActions: [], deletedEventIds: [], disabledCellActions: [], @@ -95,11 +73,6 @@ describe('AlertsTable', () => { 'data-test-subj': 'testTable', }; - beforeEach(() => { - alertsTableConfigurationRegistryMock.get.mockClear(); - alertsTableConfigurationRegistryMock.has.mockClear(); - }); - describe('Alerts table UI', () => { it('should support sorting', async () => { const renderResult = render(); @@ -184,18 +157,4 @@ describe('AlertsTable', () => { }); }); }); - - describe('Alerts table configuration registry', () => { - it('should read the configuration from the registry', async () => { - render(); - expect(alertsTableConfigurationRegistryMock.has).toHaveBeenCalledWith(PLUGIN_ID); - expect(alertsTableConfigurationRegistryMock.get).toHaveBeenCalledWith(PLUGIN_ID); - }); - - it('should render an empty error state when the plugin id owner is not registered', async () => { - const props = { ...tableProps, configurationId: 'none' }; - const result = render(); - expect(result.getByTestId('alertsTableNoConfiguration')).toBeTruthy(); - }); - }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx index dca547e65ae27..e772ceb01c8a0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx @@ -8,7 +8,6 @@ import React, { useState, Suspense, lazy, useCallback, useMemo, useEffect } from 'react'; import { EuiDataGrid, - EuiEmptyPrompt, EuiDataGridCellValueElementProps, EuiDataGridCellValueProps, EuiFlexGroup, @@ -18,11 +17,8 @@ import { EuiDataGridStyle, } from '@elastic/eui'; import { useSorting, usePagination } from './hooks'; -import { AlertsTableProps, AlertsField } from '../../../types'; -import { useKibana } from '../../../common/lib/kibana'; +import { AlertsTableProps } from '../../../types'; import { - ALERTS_TABLE_CONF_ERROR_MESSAGE, - ALERTS_TABLE_CONF_ERROR_TITLE, ALERTS_TABLE_CONTROL_COLUMNS_ACTIONS_LABEL, ALERTS_TABLE_CONTROL_COLUMNS_VIEW_DETAILS_LABEL, } from './translations'; @@ -30,18 +26,25 @@ import './alerts_table.scss'; export const ACTIVE_ROW_CLASS = 'alertsTableActiveRow'; -const AlertsFlyout = lazy(() => import('./alerts_flyout')); - -const emptyConfiguration = { - id: '', - columns: [], +const GridStyles: EuiDataGridStyle = { + border: 'horizontal', + header: 'underline', }; +const AlertsFlyout = lazy(() => import('./alerts_flyout')); + const AlertsTable: React.FunctionComponent = (props: AlertsTableProps) => { const [rowClasses, setRowClasses] = useState({}); - const { activePage, alertsCount, onPageChange, onSortChange, isLoading } = - props.useFetchAlertsData(); - const { sortingColumns, onSort } = useSorting(onSortChange); + const { + activePage, + alerts, + alertsCount, + isLoading, + onPageChange, + onSortChange, + sort: sortingFields, + } = props.useFetchAlertsData(); + const { sortingColumns, onSort } = useSorting(onSortChange, sortingFields); const { pagination, onChangePageSize, @@ -56,15 +59,7 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab alertsCount, }); - const alertsTableConfigurationRegistry = useKibana().services.alertsTableConfigurationRegistry; - const hasAlertsTableConfiguration = alertsTableConfigurationRegistry.has(props.configurationId); - const alertsTableConfiguration = hasAlertsTableConfiguration - ? alertsTableConfigurationRegistry.get(props.configurationId) - : emptyConfiguration; - - const [visibleColumns, setVisibleColumns] = useState( - alertsTableConfiguration.columns.map(({ id }) => id) - ); + const [visibleColumns, setVisibleColumns] = useState(props.columns.map(({ id }) => id)); const leadingControlColumns = useMemo(() => { return [ @@ -116,12 +111,25 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab const handleFlyoutClose = useCallback(() => setFlyoutAlertIndex(-1), [setFlyoutAlertIndex]); - return hasAlertsTableConfiguration ? ( -
+ const handleRenderCellValue = useCallback( + (improper: EuiDataGridCellValueElementProps) => { + const rcvProps = improper as EuiDataGridCellValueElementProps & EuiDataGridCellValueProps; + const alert = alerts[rcvProps.visibleRowIndex]; + return props.renderCellValue({ + ...rcvProps, + alert, + field: rcvProps.columnId, + }); + }, + [alerts, props] + ); + + return ( +
{flyoutAlertIndex > -1 && ( = (props: AlertsTab { - const rcvProps = improper as EuiDataGridCellValueElementProps & EuiDataGridCellValueProps; - const alert = props.alerts[rcvProps.visibleRowIndex]; - return props.renderCellValue({ - ...rcvProps, - alert, - field: rcvProps.columnId as AlertsField, - }); - }} - gridStyle={{ rowClasses }} + renderCellValue={handleRenderCellValue} + gridStyle={{ ...GridStyles, rowClasses }} sorting={{ columns: sortingColumns, onSort }} pagination={{ ...pagination, @@ -157,13 +157,6 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab }} />
- ) : ( - {ALERTS_TABLE_CONF_ERROR_TITLE}} - body={

{ALERTS_TABLE_CONF_ERROR_MESSAGE}

} - /> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx new file mode 100644 index 0000000000000..92aea1e5c30d6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 { render } from '@testing-library/react'; +import { AlertConsumers } from '@kbn/rule-data-utils'; +import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; + +import { AlertsField, AlertsTableConfigurationRegistry } from '../../../types'; +import { PLUGIN_ID } from '../../../common/constants'; +import { TypeRegistry } from '../../type_registry'; +import AlertsTableState from './alerts_table_state'; +import { useFetchAlerts } from './hooks/use_fetch_alerts'; +import { DefaultSort } from './hooks'; + +jest.mock('./hooks/use_fetch_alerts'); +jest.mock('@kbn/kibana-utils-plugin/public'); + +const columns = [ + { + id: AlertsField.name, + displayAsText: 'Name', + }, + { + id: AlertsField.reason, + displayAsText: 'Reason', + }, +]; + +const alerts = [ + { + [AlertsField.name]: ['one'], + [AlertsField.reason]: ['two'], + }, + { + [AlertsField.name]: ['three'], + [AlertsField.reason]: ['four'], + }, +] as unknown as EcsFieldsResponse[]; + +const hasMock = jest.fn().mockImplementation((plugin: string) => { + return plugin === PLUGIN_ID; +}); +const getMock = jest.fn().mockImplementation((plugin: string) => { + if (plugin === PLUGIN_ID) { + return { columns, sort: DefaultSort }; + } + return {}; +}); +const alertsTableConfigurationRegistryMock = { + has: hasMock, + get: getMock, +} as unknown as TypeRegistry; + +const storageMock = Storage as jest.Mock; + +storageMock.mockImplementation(() => { + return { get: jest.fn(), set: jest.fn() }; +}); + +const hookUseFetchAlerts = useFetchAlerts as jest.Mock; +hookUseFetchAlerts.mockImplementation(() => [ + false, + { + alerts, + isInitializing: false, + getInspectQuery: jest.fn(), + refetch: jest.fn(), + totalAlerts: alerts.length, + }, +]); + +describe('AlertsTableState', () => { + const tableProps = { + alertsTableConfigurationRegistry: alertsTableConfigurationRegistryMock, + configurationId: PLUGIN_ID, + id: `test-alerts`, + featureIds: [AlertConsumers.LOGS], + query: {}, + }; + + beforeEach(() => { + hasMock.mockClear(); + getMock.mockClear(); + }); + + describe('Alerts table configuration registry', () => { + it('should read the configuration from the registry', async () => { + render(); + expect(hasMock).toHaveBeenCalledWith(PLUGIN_ID); + expect(getMock).toHaveBeenCalledWith(PLUGIN_ID); + }); + + it('should render an empty error state when the plugin id owner is not registered', async () => { + const props = { ...tableProps, configurationId: 'none' }; + const result = render(); + expect(result.getByTestId('alertsTableNoConfiguration')).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx new file mode 100644 index 0000000000000..f69fe6aacb7d2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx @@ -0,0 +1,204 @@ +/* + * 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, { useState, useCallback, useRef, useMemo } from 'react'; +import { get, isEmpty } from 'lodash'; +import { + EuiDataGridColumn, + EuiDataGridControlColumn, + EuiProgress, + EuiDataGridSorting, + EuiEmptyPrompt, +} from '@elastic/eui'; +import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import type { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import type { + QueryDslQueryContainer, + SortCombinations, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { useFetchAlerts } from './hooks/use_fetch_alerts'; +import { AlertsTable } from './alerts_table'; +import { AlertsTableConfigurationRegistry, RenderCellValueProps } from '../../../types'; +import { TypeRegistry } from '../../type_registry'; +import { ALERTS_TABLE_CONF_ERROR_MESSAGE, ALERTS_TABLE_CONF_ERROR_TITLE } from './translations'; + +const DefaultPagination = { + pageSize: 10, + pageIndex: 0, +}; + +export interface AlertsTableStateProps { + alertsTableConfigurationRegistry: TypeRegistry; + configurationId: string; + id: string; + featureIds: ValidFeatureId[]; + query: Pick; +} + +interface AlertsTableStorage { + columns: EuiDataGridColumn[]; + sort: SortCombinations[]; +} + +const EmptyConfiguration = { + id: '', + columns: [], + sort: [], +}; + +const AlertsTableState = ({ + alertsTableConfigurationRegistry, + configurationId, + id, + featureIds, + query, +}: AlertsTableStateProps) => { + const hasAlertsTableConfiguration = + alertsTableConfigurationRegistry?.has(configurationId) ?? false; + const alertsTableConfiguration = hasAlertsTableConfiguration + ? alertsTableConfigurationRegistry.get(configurationId) + : EmptyConfiguration; + + const storage = useRef(new Storage(window.localStorage)); + const localAlertsTableConfig = storage.current.get(id) as Partial; + + const storageAlertsTable = useRef({ + columns: + localAlertsTableConfig && + localAlertsTableConfig.columns && + !isEmpty(localAlertsTableConfig?.columns) + ? localAlertsTableConfig?.columns ?? [] + : alertsTableConfiguration?.columns ?? [], + sort: + localAlertsTableConfig && + localAlertsTableConfig.sort && + !isEmpty(localAlertsTableConfig?.sort) + ? localAlertsTableConfig?.sort ?? [] + : alertsTableConfiguration?.sort ?? [], + }); + + const [showCheckboxes] = useState(false); + const [sort, setSort] = useState(storageAlertsTable.current.sort); + const [pagination, setPagination] = useState(DefaultPagination); + const [columns, setColumns] = useState(storageAlertsTable.current.columns); + + const [ + isLoading, + { alerts, isInitializing, getInspectQuery, refetch: refresh, totalAlerts: alertsCount }, + ] = useFetchAlerts({ + fields: columns.map((col) => ({ field: col.id, include_unmapped: true })), + featureIds, + query, + pagination, + sort, + skip: false, + }); + + const onPageChange = useCallback((_pagination: RuleRegistrySearchRequestPagination) => { + setPagination(_pagination); + }, []); + const onSortChange = useCallback( + (_sort: EuiDataGridSorting['columns']) => { + const newSort = _sort.map((sortItem) => { + return { + [sortItem.id]: { + order: sortItem.direction, + }, + }; + }); + + storageAlertsTable.current = { + ...storageAlertsTable.current, + sort: newSort, + }; + storage.current.set(id, storageAlertsTable.current); + setSort(newSort); + }, + [id] + ); + const onColumnsChange = useCallback( + (newColumns: EuiDataGridControlColumn[]) => { + setColumns(newColumns); + storageAlertsTable.current = { + ...storageAlertsTable.current, + columns: newColumns, + }; + storage.current.set(id, storageAlertsTable.current); + }, + [id, storage] + ); + + const useFetchAlertsData = useCallback(() => { + return { + activePage: pagination.pageIndex, + alerts, + alertsCount, + isInitializing, + isLoading, + getInspectQuery, + onColumnsChange, + onPageChange, + onSortChange, + refresh, + sort, + }; + }, [ + alerts, + alertsCount, + getInspectQuery, + isInitializing, + isLoading, + onColumnsChange, + onPageChange, + onSortChange, + pagination.pageIndex, + refresh, + sort, + ]); + + const tableProps = useMemo( + () => ({ + columns, + bulkActions: [], + deletedEventIds: [], + disabledCellActions: [], + pageSize: pagination.pageSize, + pageSizeOptions: [1, 2, 5, 10, 20, 50, 100], + leadingControlColumns: [], + renderCellValue: ({ alert, field }: RenderCellValueProps) => { + // any is required here to improve typescript performance + const value = get(alert as any, field, [])[0] as string; + return value ?? 'N/A'; + }, + showCheckboxes, + trailingControlColumns: [], + useFetchAlertsData, + 'data-test-subj': 'internalAlertsState', + }), + [columns, pagination.pageSize, showCheckboxes, useFetchAlertsData] + ); + + return hasAlertsTableConfiguration ? ( + <> + {isLoading && ( + + )} + + + ) : ( + {ALERTS_TABLE_CONF_ERROR_TITLE}} + body={

{ALERTS_TABLE_CONF_ERROR_MESSAGE}

} + /> + ); +}; + +export { AlertsTableState }; +// eslint-disable-next-line import/no-default-export +export { AlertsTableState as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/constants.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/constants.ts new file mode 100644 index 0000000000000..fd53f3466ba7c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/constants.ts @@ -0,0 +1,16 @@ +/* + * 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 { SortCombinations } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +export const DefaultSort: SortCombinations[] = [ + { + '@timestamp': { + order: 'asc', + }, + }, +]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/index.ts index 9da3178db8c39..7801fcf4083c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/index.ts @@ -4,5 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +export type { UsePagination } from './use_pagination'; export { usePagination } from './use_pagination'; +export type { UseSorting } from './use_sorting'; export { useSorting } from './use_sorting'; +export type { UseFetchAlerts } from './use_fetch_alerts'; +export { useFetchAlerts } from './use_fetch_alerts'; +export { DefaultSort } from './constants'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/translations.ts new file mode 100644 index 0000000000000..88c7eb6ad0a67 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/translations.ts @@ -0,0 +1,15 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ERROR_FETCH_ALERTS = i18n.translate( + 'xpack.triggersActionsUI.components.alertTable.useFetchAlerts.errorMessageText', + { + defaultMessage: `An error has occurred on alerts search`, + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.test.tsx new file mode 100644 index 0000000000000..cb19c249f7a27 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.test.tsx @@ -0,0 +1,372 @@ +/* + * 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 sinon from 'sinon'; +import { of } from 'rxjs'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useFetchAlerts, FetchAlertsArgs } from './use_fetch_alerts'; +import { useKibana } from '../../../../common/lib/kibana'; +import { IKibanaSearchResponse } from '@kbn/data-plugin/public'; + +jest.mock('../../../../common/lib/kibana'); + +const searchResponse = { + id: '0', + rawResponse: { + took: 1, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, + hits: { + total: 2, + max_score: 1, + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1', + _score: 1, + fields: { + 'kibana.alert.severity': ['low'], + 'process.name': ['iexlorer.exe'], + '@timestamp': ['2022-03-22T16:48:07.518Z'], + 'kibana.alert.risk_score': [21], + 'kibana.alert.rule.name': ['test'], + 'user.name': ['5qcxz8o4j7'], + 'kibana.alert.reason': [ + 'registry event with process iexlorer.exe, by 5qcxz8o4j7 on Host-4dbzugdlqd created low alert test.', + ], + 'host.name': ['Host-4dbzugdlqd'], + }, + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86', + _score: 1, + fields: { + 'kibana.alert.severity': ['low'], + 'process.name': ['iexlorer.exe'], + '@timestamp': ['2022-03-22T16:17:50.769Z'], + 'kibana.alert.risk_score': [21], + 'kibana.alert.rule.name': ['test'], + 'user.name': ['hdgsmwj08h'], + 'kibana.alert.reason': [ + 'network event with process iexlorer.exe, by hdgsmwj08h on Host-4dbzugdlqd created low alert test.', + ], + 'host.name': ['Host-4dbzugdlqd'], + }, + }, + ], + }, + }, + isPartial: false, + isRunning: false, + total: 2, + loaded: 2, + isRestored: false, +}; + +const searchResponse$ = of(searchResponse); + +describe('useFetchAlerts', () => { + let clock: sinon.SinonFakeTimers; + const args: FetchAlertsArgs = { + featureIds: ['siem'], + fields: [{ field: '*', include_unmapped: true }], + query: { + ids: { values: ['alert-id-1'] }, + }, + pagination: { + pageIndex: 0, + pageSize: 10, + }, + sort: [], + skip: false, + }; + + const dataSearchMock = useKibana().services.data.search.search as jest.Mock; + const showErrorMock = useKibana().services.data.search.showError as jest.Mock; + dataSearchMock.mockReturnValue(searchResponse$); + + beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); + }); + + beforeEach(() => { + jest.clearAllMocks(); + clock.reset(); + }); + + afterAll(() => clock.restore()); + + it('returns the response correctly', () => { + const { result } = renderHook(() => useFetchAlerts(args)); + expect(result.current).toEqual([ + false, + { + alerts: [ + { + '@timestamp': ['2022-03-22T16:48:07.518Z'], + 'host.name': ['Host-4dbzugdlqd'], + 'kibana.alert.reason': [ + 'registry event with process iexlorer.exe, by 5qcxz8o4j7 on Host-4dbzugdlqd created low alert test.', + ], + 'kibana.alert.risk_score': [21], + 'kibana.alert.rule.name': ['test'], + 'kibana.alert.severity': ['low'], + 'process.name': ['iexlorer.exe'], + 'user.name': ['5qcxz8o4j7'], + }, + { + '@timestamp': ['2022-03-22T16:17:50.769Z'], + 'host.name': ['Host-4dbzugdlqd'], + 'kibana.alert.reason': [ + 'network event with process iexlorer.exe, by hdgsmwj08h on Host-4dbzugdlqd created low alert test.', + ], + 'kibana.alert.risk_score': [21], + 'kibana.alert.rule.name': ['test'], + 'kibana.alert.severity': ['low'], + 'process.name': ['iexlorer.exe'], + 'user.name': ['hdgsmwj08h'], + }, + ], + totalAlerts: 2, + isInitializing: false, + updatedAt: 1609502400000, + getInspectQuery: expect.anything(), + refetch: expect.anything(), + }, + ]); + }); + + it('call search with correct arguments', () => { + renderHook(() => useFetchAlerts(args)); + expect(dataSearchMock).toHaveBeenCalledTimes(1); + expect(dataSearchMock).toHaveBeenCalledWith( + { + featureIds: args.featureIds, + fields: args.fields, + pagination: args.pagination, + query: { + ids: { + values: ['alert-id-1'], + }, + }, + sort: args.sort, + }, + { abortSignal: expect.anything(), strategy: 'privateRuleRegistryAlertsSearchStrategy' } + ); + }); + + it('skips the fetch correctly', () => { + const { result } = renderHook(() => useFetchAlerts({ ...args, skip: true })); + + expect(dataSearchMock).not.toHaveBeenCalled(); + expect(result.current).toEqual([ + false, + { + alerts: [], + getInspectQuery: expect.anything(), + refetch: expect.anything(), + isInitializing: true, + totalAlerts: -1, + updatedAt: 0, + }, + ]); + }); + + it('returns the correct response if the request is undefined', () => { + // @ts-expect-error + const obs$ = of(undefined); + dataSearchMock.mockReturnValue(obs$); + const { result } = renderHook(() => useFetchAlerts(args)); + + expect(result.current).toEqual([ + false, + { + alerts: [], + getInspectQuery: expect.anything(), + refetch: expect.anything(), + isInitializing: true, + totalAlerts: -1, + updatedAt: 0, + }, + ]); + + expect(showErrorMock).toHaveBeenCalled(); + }); + + it('returns the correct response if the request is running', () => { + const obs$ = of({ ...searchResponse, isRunning: true }); + dataSearchMock.mockReturnValue(obs$); + const { result } = renderHook(() => useFetchAlerts(args)); + + expect(result.current).toEqual([ + true, + { + alerts: [], + getInspectQuery: expect.anything(), + refetch: expect.anything(), + isInitializing: true, + totalAlerts: -1, + updatedAt: 0, + }, + ]); + }); + + it('returns the correct response if the request is partial', () => { + const obs$ = of({ ...searchResponse, isPartial: true }); + dataSearchMock.mockReturnValue(obs$); + const { result } = renderHook(() => useFetchAlerts(args)); + + expect(result.current).toEqual([ + false, + { + alerts: [], + getInspectQuery: expect.anything(), + refetch: expect.anything(), + isInitializing: true, + totalAlerts: -1, + updatedAt: 0, + }, + ]); + expect(showErrorMock).toHaveBeenCalled(); + }); + + it('returns the correct response if there is no rawResponse', () => { + // @ts-expect-error + const obs$ = of({ id: '1', isRunning: true, isPartial: false }); + dataSearchMock.mockReturnValue(obs$); + const { result } = renderHook(() => useFetchAlerts(args)); + + expect(result.current).toEqual([ + false, + { + alerts: [], + getInspectQuery: expect.anything(), + refetch: expect.anything(), + isInitializing: true, + totalAlerts: -1, + updatedAt: 0, + }, + ]); + expect(showErrorMock).toHaveBeenCalled(); + }); + + it('returns the correct total alerts if the total alerts in the response is an object', () => { + const obs$ = of({ + ...searchResponse, + rawResponse: { + ...searchResponse.rawResponse, + hits: { ...searchResponse.rawResponse.hits, total: { value: 2 } }, + }, + }); + + dataSearchMock.mockReturnValue(obs$); + const { result } = renderHook(() => useFetchAlerts(args)); + const [_, alerts] = result.current; + + expect(alerts.totalAlerts).toEqual(2); + }); + + it('does not return an alert without fields', () => { + const obs$ = of({ + ...searchResponse, + rawResponse: { + ...searchResponse.rawResponse, + hits: { + ...searchResponse.rawResponse.hits, + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1', + _score: 1, + }, + ], + }, + }, + }); + + dataSearchMock.mockReturnValue(obs$); + const { result } = renderHook(() => useFetchAlerts(args)); + const [_, alerts] = result.current; + + expect(alerts.alerts).toEqual([]); + }); + + it('resets pagination on refetch correctly', async () => { + const { result } = renderHook(() => + useFetchAlerts({ + ...args, + pagination: { + pageIndex: 5, + pageSize: 10, + }, + }) + ); + const [_, alerts] = result.current; + expect(dataSearchMock).toHaveBeenCalledWith( + { + featureIds: args.featureIds, + fields: args.fields, + pagination: { + pageIndex: 5, + pageSize: 10, + }, + query: { + ids: { + values: ['alert-id-1'], + }, + }, + sort: args.sort, + }, + { abortSignal: expect.anything(), strategy: 'privateRuleRegistryAlertsSearchStrategy' } + ); + + await act(async () => { + alerts.refetch(); + }); + + expect(dataSearchMock).toHaveBeenCalledWith( + { + featureIds: args.featureIds, + fields: args.fields, + pagination: { + pageIndex: 0, + pageSize: 10, + }, + query: { + ids: { + values: ['alert-id-1'], + }, + }, + sort: args.sort, + }, + { abortSignal: expect.anything(), strategy: 'privateRuleRegistryAlertsSearchStrategy' } + ); + }); + + it('does not fetch with no feature ids', () => { + const { result } = renderHook(() => useFetchAlerts({ ...args, featureIds: [] })); + + expect(dataSearchMock).not.toHaveBeenCalled(); + expect(result.current).toEqual([ + false, + { + alerts: [], + getInspectQuery: expect.anything(), + refetch: expect.anything(), + isInitializing: true, + totalAlerts: -1, + updatedAt: 0, + }, + ]); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx new file mode 100644 index 0000000000000..a952a1c3d2c46 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx @@ -0,0 +1,273 @@ +/* + * 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 { ValidFeatureId } from '@kbn/rule-data-utils'; +import deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash'; +import { useCallback, useEffect, useReducer, useRef, useMemo } from 'react'; +import { Subscription } from 'rxjs'; + +import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common'; +import type { + EcsFieldsResponse, + RuleRegistrySearchRequest, + RuleRegistrySearchResponse, +} from '@kbn/rule-registry-plugin/common/search_strategy'; +import type { + QueryDslFieldAndFormat, + QueryDslQueryContainer, + SortCombinations, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DefaultSort } from './constants'; +import * as i18n from './translations'; + +export interface FetchAlertsArgs { + featureIds: ValidFeatureId[]; + fields: QueryDslFieldAndFormat[]; + query: Pick; + pagination: { + pageIndex: number; + pageSize: number; + }; + sort: SortCombinations[]; + skip: boolean; +} + +type AlertRequest = Omit; + +type Refetch = () => void; + +interface InspectQuery { + request: string[]; + response: string[]; +} +type GetInspectQuery = () => InspectQuery; + +interface FetchAlertResp { + alerts: EcsFieldsResponse[]; + isInitializing: boolean; + getInspectQuery: GetInspectQuery; + refetch: Refetch; + totalAlerts: number; + updatedAt: number; +} + +type AlertResponseState = Omit; +interface AlertStateReducer { + loading: boolean; + request: Omit; + response: AlertResponseState; +} + +type AlertActions = + | { type: 'loading'; loading: boolean } + | { type: 'response'; alerts: EcsFieldsResponse[]; totalAlerts: number } + | { type: 'resetPagination' } + | { type: 'request'; request: Omit }; + +const initialAlertState: AlertStateReducer = { + loading: false, + request: { + featureIds: [], + fields: [], + query: { + bool: {}, + }, + pagination: { + pageIndex: 0, + pageSize: 50, + }, + sort: DefaultSort, + }, + response: { + alerts: [], + totalAlerts: -1, + isInitializing: true, + updatedAt: 0, + }, +}; + +function alertReducer(state: AlertStateReducer, action: AlertActions) { + switch (action.type) { + case 'loading': + return { ...state, loading: action.loading }; + case 'response': + return { + ...state, + loading: false, + response: { + isInitializing: false, + alerts: action.alerts, + totalAlerts: action.totalAlerts, + updatedAt: Date.now(), + }, + }; + case 'resetPagination': + return { + ...state, + request: { + ...state.request, + pagination: { + ...state.request.pagination, + pageIndex: 0, + }, + }, + }; + case 'request': + return { ...state, request: action.request }; + default: + throw new Error(); + } +} +export type UseFetchAlerts = ({ + featureIds, + fields, + query, + pagination, + skip, + sort, +}: FetchAlertsArgs) => [boolean, FetchAlertResp]; +const useFetchAlerts = ({ + featureIds, + fields, + query, + pagination, + skip, + sort, +}: FetchAlertsArgs): [boolean, FetchAlertResp] => { + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription$ = useRef(new Subscription()); + const [{ loading, request: alertRequest, response: alertResponse }, dispatch] = useReducer( + alertReducer, + initialAlertState + ); + const prevAlertRequest = useRef(null); + const inspectQuery = useRef({ + request: [], + response: [], + }); + const { data } = useKibana().services; + + const getInspectQuery = useCallback(() => inspectQuery.current, []); + const refetchGrid = useCallback(() => { + if ((prevAlertRequest.current?.pagination?.pageIndex ?? 0) !== 0) { + dispatch({ type: 'resetPagination' }); + } else { + refetch.current(); + } + }, []); + + const fetchAlerts = useCallback( + (request: AlertRequest | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + prevAlertRequest.current = request; + abortCtrl.current = new AbortController(); + dispatch({ type: 'loading', loading: true }); + if (data && data.search) { + searchSubscription$.current = data.search + .search( + { ...request, featureIds, fields, query }, + { + strategy: 'privateRuleRegistryAlertsSearchStrategy', + abortSignal: abortCtrl.current.signal, + } + ) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + const { rawResponse } = response; + inspectQuery.current = { + request: [], + response: [], + }; + let totalAlerts = 0; + if (rawResponse.hits.total && typeof rawResponse.hits.total === 'number') { + totalAlerts = rawResponse.hits.total; + } else if (rawResponse.hits.total && typeof rawResponse.hits.total === 'object') { + totalAlerts = rawResponse.hits.total?.value ?? 0; + } + dispatch({ + type: 'response', + alerts: rawResponse.hits.hits.reduce((acc, hit) => { + if (hit.fields) { + acc.push(hit.fields as EcsFieldsResponse); + } + return acc; + }, []), + totalAlerts, + }); + searchSubscription$.current.unsubscribe(); + } else if (isErrorResponse(response)) { + dispatch({ type: 'loading', loading: false }); + data.search.showError(new Error(i18n.ERROR_FETCH_ALERTS)); + searchSubscription$.current.unsubscribe(); + } + }, + error: (msg) => { + dispatch({ type: 'loading', loading: false }); + data.search.showError(msg); + searchSubscription$.current.unsubscribe(); + }, + }); + } + }; + + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [skip, data, featureIds, fields, query] + ); + + useEffect(() => { + if (featureIds.length === 0) { + return; + } + const newAlertRequest = { + featureIds, + fields, + pagination, + query, + sort, + }; + if ( + newAlertRequest.fields.length > 0 && + !deepEqual(newAlertRequest, prevAlertRequest.current) + ) { + dispatch({ + type: 'request', + request: newAlertRequest, + }); + } + }, [featureIds, fields, pagination, query, sort]); + + useEffect(() => { + if (alertRequest.featureIds.length > 0 && !deepEqual(alertRequest, prevAlertRequest.current)) { + fetchAlerts(alertRequest); + } + }, [alertRequest, fetchAlerts]); + + const alertResponseMemo = useMemo( + () => ({ + ...alertResponse, + getInspectQuery, + refetch: refetchGrid, + }), + [alertResponse, getInspectQuery, refetchGrid] + ); + + return [loading, alertResponseMemo]; +}; + +export { useFetchAlerts }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_pagination.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_pagination.ts index 76f4f0fa546c4..11e7bcd16441e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_pagination.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_pagination.ts @@ -12,6 +12,16 @@ type PaginationProps = RuleRegistrySearchRequestPagination & { alertsCount: number; }; +export type UsePagination = (props: PaginationProps) => { + pagination: RuleRegistrySearchRequestPagination; + onChangePageSize: (pageSize: number) => void; + onChangePageIndex: (pageIndex: number) => void; + onPaginateFlyoutNext: () => void; + onPaginateFlyoutPrevious: () => void; + flyoutAlertIndex: number; + setFlyoutAlertIndex: (alertIndex: number) => void; +}; + export function usePagination({ onPageChange, pageIndex, pageSize, alertsCount }: PaginationProps) { const [pagination, setPagination] = useState({ pageIndex, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.test.ts index 487f6908a334e..e788c9aeca9d4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.test.ts @@ -16,7 +16,12 @@ describe('useSorting', () => { it('should return the sorted columns and the callback function to call when sort changes', () => { const { result } = renderHook(() => useSorting(onSortChange)); - expect(result.current.sortingColumns).toStrictEqual([]); + expect(result.current.sortingColumns).toStrictEqual([ + { + direction: 'asc', + id: '@timestamp', + }, + ]); expect(result.current.onSort).toBeDefined(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.ts index cbb84c95806a0..781b76245808b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_sorting.ts @@ -4,12 +4,38 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +import type { SortCombinations } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { EuiDataGridSorting } from '@elastic/eui'; import { useCallback, useState } from 'react'; +import { DefaultSort } from './constants'; + +const formatGridColumns = (cols: SortCombinations[]): EuiDataGridSorting['columns'] => { + const colsSorting: EuiDataGridSorting['columns'] = []; + cols.forEach((col) => { + Object.entries(col).forEach(([field, oSort]) => { + colsSorting.push({ id: field, direction: oSort.order }); + }); + }); + return colsSorting; +}; + +export type UseSorting = ( + onSortChange: (sort: EuiDataGridSorting['columns']) => void, + defaultSort: SortCombinations[] +) => { + sortingColumns: EuiDataGridSorting['columns']; + onSort: (newSort: EuiDataGridSorting['columns']) => void; +}; + export function useSorting( - onSortChange: (sort: Array<{ id: string; direction: 'asc' | 'desc' }>) => void + onSortChange: (sort: EuiDataGridSorting['columns']) => void, + defaultSort: SortCombinations[] = DefaultSort ) { - const [sortingColumns, setSortingColumns] = useState([]); + const [sortingColumns, setSortingColumns] = useState( + formatGridColumns(defaultSort) + ); const onSort = useCallback( (_state) => { onSortChange(_state); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx index 6aa8aa8c69213..636bcaf1acb22 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx @@ -56,11 +56,11 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => { return [...new Set([...tags, ...selectedTags])].sort(); }, [tags, selectedTags]); - const options: EuiSelectableOption[] = useMemo( + const options = useMemo( () => allTags.map((tag) => ({ label: tag, - checked: selectedTags.includes(tag) ? 'on' : undefined, + checked: selectedTags.includes(tag) ? ('on' as const) : undefined, 'data-test-subj': optionDataTestSubj(tag), })), [allTags, selectedTags, optionDataTestSubj] diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_alerts_table_state.tsx new file mode 100644 index 0000000000000..d378f26b4c88e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_alerts_table_state.tsx @@ -0,0 +1,21 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React, { lazy, Suspense } from 'react'; + +import type { AlertsTableStateProps } from '../application/sections/alerts_table/alerts_table_state'; + +const AlertsTableStateLazy: React.FC = lazy( + () => import('../application/sections/alerts_table/alerts_table_state') +); + +export const getAlertsTableStateLazy = (props: AlertsTableStateProps) => ( + }> + + +); diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index d328cbf303d61..f14b5482fd6fd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -32,7 +32,6 @@ export type { AsApiContract, RuleTableItem, AlertsTableProps, - AlertsData, BulkActionsObjectProp, RuleSummary, AlertStatus, @@ -49,12 +48,29 @@ export type { ActionGroupWithCondition } from './application/sections'; export { AlertConditions, AlertConditionsGroup } from './application/sections'; -export * from './common'; - export function plugin(context: PluginInitializerContext) { return new Plugin(context); } +export type { AggregationType, Comparator } from './common'; + +export { + WhenExpression, + OfExpression, + ForLastExpression, + ThresholdExpression, + ValueExpression, + builtInComparators, + builtInGroupByTypes, + builtInAggregationTypes, + getFields, + firstFieldOption, + getIndexOptions, + getTimeFieldOptions, + GroupByExpression, + COMPARATORS, +} from './common'; + export { Plugin }; export * from './plugin'; // TODO remove this import when we expose the Rules tables as a component diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 9092c097de9fc..75ca6d8fd2987 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -30,6 +30,8 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getAlertsTableStateLazy } from './common/get_alerts_table_state'; +import { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state'; function createStartMock(): TriggersAndActionsUIPublicPluginStart { const actionTypeRegistry = new TypeRegistry(); @@ -62,6 +64,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { ruleTypeRegistry, }); }, + getAlertsStateTable: (props: AlertsTableStateProps) => { + return getAlertsTableStateLazy(props); + }, getAlertsTable: (props: AlertsTableProps) => { return getAlertsTableLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 522cef6865a74..f9df34a5e4abb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -59,6 +59,8 @@ import type { import { TriggersActionsUiConfigType } from '../common/types'; import { registerAlertsTableConfiguration } from './application/sections/alerts_table/alerts_page/register_alerts_table_configuration'; import { PLUGIN_ID } from './common/constants'; +import type { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state'; +import { getAlertsTableStateLazy } from './common/get_alerts_table_state'; export interface TriggersAndActionsUIPublicPluginSetup { actionTypeRegistry: TypeRegistry; @@ -83,6 +85,7 @@ export interface TriggersAndActionsUIPublicPluginStart { props: Omit ) => ReactElement; getAlertsTable: (props: AlertsTableProps) => ReactElement; + getAlertsStateTable: (props: AlertsTableStateProps) => ReactElement; getRuleStatusDropdown: (props: RuleStatusDropdownProps) => ReactElement; getRuleTagFilter: (props: RuleTagFilterProps) => ReactElement; getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; @@ -255,6 +258,9 @@ export class Plugin ruleTypeRegistry: this.ruleTypeRegistry, }); }, + getAlertsStateTable: (props: AlertsTableStateProps) => { + return getAlertsTableStateLazy(props); + }, getAlertsTable: (props: AlertsTableProps) => { return getAlertsTableLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 7e6d743efb960..8607a69449511 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -13,11 +13,11 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { IconType } from '@elastic/eui'; -import { AlertConsumers } from '@kbn/rule-data-utils'; import { EuiDataGridColumn, EuiDataGridControlColumn, EuiDataGridCellValueElementProps, + EuiDataGridSorting, } from '@elastic/eui'; import { ActionType, @@ -46,6 +46,9 @@ import { RuleType as CommonRuleType, } from '@kbn/alerting-plugin/common'; import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; +import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; + +import { SortCombinations } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { TypeRegistry } from './application/type_registry'; import type { ComponentOpts as RuleStatusDropdownProps } from './application/sections/rules_list/components/rule_status_dropdown'; import type { RuleTagFilterProps } from './application/sections/rules_list/components/rule_tag_filter'; @@ -387,19 +390,18 @@ export enum AlertsField { reason = 'kibana.alert.reason', } -export type AlertsData = Record; - export interface FetchAlertData { activePage: number; - alerts: AlertsData[]; + alerts: EcsFieldsResponse[]; alertsCount: number; isInitializing: boolean; isLoading: boolean; getInspectQuery: () => { request: {}; response: {} }; onColumnsChange: (columns: EuiDataGridControlColumn[]) => void; onPageChange: (pagination: RuleRegistrySearchRequestPagination) => void; - onSortChange: (sort: Array<{ id: string; direction: 'asc' | 'desc' }>) => void; + onSortChange: (sort: EuiDataGridSorting['columns']) => void; refresh: () => void; + sort: SortCombinations[]; } export interface BulkActionsObjectProp { @@ -409,8 +411,7 @@ export interface BulkActionsObjectProp { } export interface AlertsTableProps { - configurationId: string; - consumers: AlertConsumers[]; + columns: EuiDataGridColumn[]; bulkActions: BulkActionsObjectProp; // defaultCellActions: TGridCellAction[]; deletedEventIds: string[]; @@ -422,18 +423,18 @@ export interface AlertsTableProps { showCheckboxes: boolean; trailingControlColumns: EuiDataGridControlColumn[]; useFetchAlertsData: () => FetchAlertData; - alerts: AlertsData[]; 'data-test-subj': string; } export type RenderCellValueProps = EuiDataGridCellValueElementProps & { - alert: AlertsData; - field: AlertsField; + alert: EcsFieldsResponse; + field: string; }; export interface AlertsTableConfigurationRegistry { id: string; columns: EuiDataGridColumn[]; + sort?: SortCombinations[]; } export type RuleStatus = 'enabled' | 'disabled' | 'snoozed'; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_feature_ids_by_registration_contexts.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_feature_ids_by_registration_contexts.ts new file mode 100644 index 0000000000000..5b4063cfe5285 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_feature_ids_by_registration_contexts.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { superUser, obsOnlySpacesAll, secOnlyRead } from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_FEATURE_IDS_URL = `${TEST_URL}/_feature_ids`; + const SPACE1 = 'space1'; + + const getApmFeatureIdByRegistrationContexts = async ( + user: User, + space: string, + expectedStatusCode: number = 200 + ) => { + const resp = await supertestWithoutAuth + .get( + `${getSpaceUrlPrefix(space)}${ALERTS_FEATURE_IDS_URL}?registrationContext=observability.apm` + ) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(expectedStatusCode); + return resp.body as string[]; + }; + + const getSecurityFeatureIdByRegistrationContexts = async ( + user: User, + space: string, + expectedStatusCode: number = 200 + ) => { + const resp = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${ALERTS_FEATURE_IDS_URL}?registrationContext=security`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(expectedStatusCode); + + return resp.body as string[]; + }; + + describe('Alert - Get feature ids by registration context', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + describe('Users:', () => { + it(`${obsOnlySpacesAll.username} should be able to get feature id for registration context 'observability.apm' in ${SPACE1}`, async () => { + const featureIds = await getApmFeatureIdByRegistrationContexts(obsOnlySpacesAll, SPACE1); + expect(featureIds).to.eql(['apm']); + }); + + it(`${superUser.username} should be able to get feature id for registration context 'observability.apm' in ${SPACE1}`, async () => { + const featureIds = await getApmFeatureIdByRegistrationContexts(superUser, SPACE1); + expect(featureIds).to.eql(['apm']); + }); + + it(`${secOnlyRead.username} should NOT be able to get feature id for registration context 'observability.apm' in ${SPACE1}`, async () => { + const featureIds = await getApmFeatureIdByRegistrationContexts(secOnlyRead, SPACE1); + expect(featureIds).to.eql([]); + }); + + it(`${secOnlyRead.username} should be able to get feature id for registration context 'security' in ${SPACE1}`, async () => { + const featureIds = await getSecurityFeatureIdByRegistrationContexts(secOnlyRead, SPACE1); + expect(featureIds).to.eql(['siem']); + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts index d010cbfce9150..e96239f37cdfb 100644 --- a/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts @@ -25,6 +25,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { // loadTestFile(require.resolve('./update_alert')); // loadTestFile(require.resolve('./bulk_update_alerts')); + loadTestFile(require.resolve('./get_feature_ids_by_registration_contexts')); loadTestFile(require.resolve('./get_alerts_index')); loadTestFile(require.resolve('./find_alerts')); loadTestFile(require.resolve('./search_strategy'));