[RAM] Alert Table from triggers_actions_ui plugin (#131883)

* wip I

* add alert table state in case

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* add new API to get FeatureID form registrationContext and update UI to use this new API

* rm dead code

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* remove unnecessary memo

* adds tests for case view helpers

* Move http call to API and add tests for getFeatureIds

* fix type + unit test

* add unit tests + cleanup

* add new api integration test for _feature_ids

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* Fix small type creating typescript slowness

* remove console log

* use import type for validfeatureId

* force any to improve typescript performance

* Update APM (#132270)

Co-authored-by: Renovate Bot <bot@renovateapp.com>

* [ResponseOps][Docs] Updating ServiceNow docs with OAuth setup instructions (#131344)

* Updating ServiceNow docs. Need screenshots

* Adding screenshots

* Fix nested screenshots and lists

* Tweaks and screenshots

* Updates

* blergh

* Apply suggestions from code review

Co-authored-by: Lisa Cawley <lcawley@elastic.co>

* Apply suggestions from code review

Co-authored-by: Mike Côté <mikecote@users.noreply.github.com>

Co-authored-by: lcawl <lcawley@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Mike Côté <mikecote@users.noreply.github.com>

* Show polling options when 'Data streams' option is selected in the Console Settings modal. (#132277)

* [Osquery] Make Osquery All with All base privillege (#130523)

* [XY] Add normalizeTable function to correct works with esdocs (#131917)

* Add normalizeTable function to correct works with esdocs

* Fix types

* Fix types

* Fix CI

* Fix CI

* Some fixes

* Remove fallback with min/max value for domain

* Added tests

* Some refactoring

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Yaroslav Kuznietsov <kuznetsov.yaroslav.yk@gmail.com>

* [Osquery] Add default osquery_saved_query objects (#129461)

* [Unified Search] Show error message for invalid date filter value (#131290)

* feat: added show error message for invalid date

* refact: move logic in HOC

* feat: refactoring code and added translation

* refact show error

* refact: show error message

* refact: remove translation

* refactor: changed menu for show FilterEdit

* fix: open/close popover

* feat: field.type => KBN_FIELD_TYPES

* feat: remove extra code with with input check and refactored filter item

* feat: added tests and refactoring code

* refact: getFieldValidityAndErrorMessage

* feat: return isInvalid checking in valur input type for string, ip

* Update navigation landing pages to use appLinks config (#132027)

* Update navigation landing pages to use appLinks config

* Please code review

* align app links changes

* Update links descriptions

* Rollback title changes

* Fix wrong links descriptions

* Fix unit tests

* Fix description

Co-authored-by: semd <sergi.massaneda@elastic.co>

* [Cloud Posture] add resource findings page flyout  (#132243)

* [Discover] Add a tour for Document Explorer (#131125)

* [Discover] Add "Take a tour" button to the Document Explorer callout

* [Discover] Tmp

* [Discover] Add a first Document Explorer tour step

* [Discover] Add other Document Explorer tour steps

* [Discover] Update tour steps positioning

* [Discover] Add gifs to tour steps

* [Discover] Refactor how tour steps are registered

* [Discover] Add new step to the tour. Update tour steps text.

* [Discover] Improve steps positioning

* [Discover] Fix positioning for Add field step

* [Discover] Add icons to tour steps

* [Discover] Reorganize components

* [Discover] Skip Columns step when it's not available

* [Discover] Rename components

* [Discover] Add some tests

* [Discover] Fix positioning

* [Discover] Fix props

* [Discover] Render steps only if the tour is active

* [Discover] Update gifs

* [Discover] Add image alt text for gifs

* [Discover] Tag the Take tour button

* [Discover] Update text and tests

* [Discover] Add more tests

* [Discover] Rename assets directory

* [Discover] Fix tour in mobile view. Improve steps positioning and animation.

* [Discover] Update text in tour steps

* [Discover] Update sort.gif

* [Discover] Update image width

* Update src/plugins/discover/public/components/discover_tour/discover_tour_provider.tsx

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* Update src/plugins/discover/public/components/discover_tour/discover_tour_provider.tsx

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* [Discover] Update sort.gif

* [Discover] Fix code style

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

* [XY] Add `minTimeBarInterval` arg (#128726)

* Added `xAxisInterval` arg

* Add validation

* Add tests

* Rename xAxisInterval to minTimeBarInterval and add validation

* Fix imports

* Add tests to validation

* Fix conflicts

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Fix tests

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

* do not use barrel imports

* do not use barrel import

* do not use barrel import

* do not use barrel imports

* do not use barrel import

* import types

* Add tests

* Fix cases bundle size

* Add more tests

* [Fleet] Add new API to get current upgrades (#132276)

* Add support of Data View switching for Agg-Based visualizations (#132184)

* Add support of Data View switching for Agg-Based visualizations

* fix CI

* add use_date_view_updates

* implement sync with state

* cleanup

* cleanup

* cleanup

* Update index.ts

* fix PR comments

* Update use_data_view_updates.ts

* Update use_data_view_updates.ts

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

* [Security Solution] Responsive styling fixes (#131951)

* [Discover] Add Analytics No Data Page (#131965)

* [Discover] Add Analytics No Data Page

* Make showEmptyPrompt parameter optional

* Remove unused import

* Remove unnecessary test

* Fix test

* Update failing test?

* Update failing test

* Changing the order of functional tests

* Fix error handling

* Addressing PR comments

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

* Remove barrel export from public index file

* remove barrel export

* Re-export missing exports

* Turn off feature flag

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Esteban Beltran <esteban.beltran@elastic.co>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Ying Mao <ying.mao@elastic.co>
Co-authored-by: lcawl <lcawley@elastic.co>
Co-authored-by: Mike Côté <mikecote@users.noreply.github.com>
Co-authored-by: CJ Cenizal <cj.cenizal@elastic.co>
Co-authored-by: Tomasz Ciecierski <ciecierskitomek@gmail.com>
Co-authored-by: Uladzislau Lasitsa <Uladzislau_Lasitsa@epam.com>
Co-authored-by: Yaroslav Kuznietsov <kuznetsov.yaroslav.yk@gmail.com>
Co-authored-by: Nodir Latipov <nodir.latypov@gmail.com>
Co-authored-by: Pablo Machado <pablo.nevesmachado@elastic.co>
Co-authored-by: semd <sergi.massaneda@elastic.co>
Co-authored-by: Or Ouziel <or.ouziel@elastic.co>
Co-authored-by: Julia Rechkunova <julia.rechkunova@elastic.co>
Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: Nicolas Chaulet <nicolas.chaulet@elastic.co>
Co-authored-by: Alexey Antonov <alexwizp@gmail.com>
Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
Co-authored-by: Maja Grubic <maja.grubic@elastic.co>
This commit is contained in:
Xavier Mouligneau 2022-05-17 14:20:06 -04:00 committed by GitHub
parent ad60119cf1
commit 859a795469
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2045 additions and 160 deletions

View file

@ -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<CaseViewPageProps>(
({
@ -194,12 +187,7 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
{
id: CASE_VIEW_PAGE_TABS.ALERTS,
name: ALERTS_TAB,
content: (
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoKibana" size="xl" />}
title={<h2>{'Alerts table placeholder'}</h2>}
/>
),
content: <CaseViewAlerts caseData={caseData} />,
},
]
: []),

View file

@ -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(<div data-test-subj="alerts-table" />);
jest.clearAllMocks();
});
it('should render the alerts table', async () => {
const result = appMockRender.render(<CaseViewAlerts caseData={caseData} />);
await waitFor(async () => {
expect(result.getByTestId('alerts-table')).toBeTruthy();
});
});
it('should call the alerts table with correct props', async () => {
appMockRender.render(<CaseViewAlerts caseData={caseData} />);
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(<CaseViewAlerts caseData={caseData} />);
await waitFor(async () => {
expect(getFeatureIdsMock).toHaveBeenCalledWith(
{ registrationContext: ['matchme'] },
expect.anything()
);
});
});
});

View file

@ -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';

View file

@ -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']);
});
});
});

View file

@ -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<string>());
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<string>());
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;
};

View file

@ -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<string>());
return [...dedupeAlerts];
return Array.from(dedupeAlerts);
};

View file

@ -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<ActionLicense[]> =>
Promise.resolve(actionLicenses);
export const getFeatureIds = async (
_query: { registrationContext: string[] },
_signal: AbortSignal
): Promise<ValidFeatureId[]> => Promise.resolve(['siem', 'observability']);

View file

@ -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']);
});
});
});

View file

@ -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<CaseResponse, Case>(decodeCaseResponse(response));
};
export const getFeatureIds = async (
query: { registrationContext: string[] },
signal: AbortSignal
): Promise<ValidFeatureId[]> => {
return KibanaServices.get().http.fetch<ValidFeatureId[]>(
`${BASE_RAC_ALERTS_API_PATH}/_feature_ids`,
{
signal,
query,
}
);
};

View file

@ -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,

View file

@ -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<string, ValidFeatureId[]>(() => useGetFeatureIds(['context1']), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
act(() => {
expect(result.current).toEqual([]);
});
});
it('fetches data and returns it correctly', async () => {
const spy = jest.spyOn(api, 'getFeatureIds');
const { result } = renderHook<string, ValidFeatureId[]>(() => useGetFeatureIds(['context1']), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
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<string, ValidFeatureId[]>(() => useGetFeatureIds(['context1']), {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(result.current).toEqual([]);
});
});

View file

@ -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<ValidFeatureId[]>([]);
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;
};

View file

@ -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<QueryDslQueryContainer, 'bool' | 'ids'>;
sort?: SortCombinations[];
pagination?: RuleRegistrySearchRequestPagination;
};
@ -52,15 +57,16 @@ type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${'' extends P ? '' : '.'}${P}`
: never
: never;
: string;
type DotNestedKeys<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? { [K in keyof T]-?: Join<K, DotNestedKeys<T[K], Prev[D]>> }[keyof T]
: '';
: never;
type EcsFieldsResponse = {
[Property in DotNestedKeys<Ecs>]: string[];
export type EcsFields = DotNestedKeys<Omit<Ecs, 'ecs'>>;
export type EcsFieldsResponse = {
[Property in EcsFields]: string[];
};
export type RuleRegistrySearchResponse = IEsSearchResponse<EcsFieldsResponse>;

View file

@ -18,6 +18,7 @@ const createAlertsClientMock = () => {
getAuthorizedAlertsIndices: jest.fn(),
bulkUpdate: jest.fn(),
find: jest.fn(),
getFeatureIdsByRegistrationContexts: jest.fn(),
};
return mocked;
};

View file

@ -681,4 +681,39 @@ export class AlertsClient {
throw Boom.failedDependency(errMessage);
}
}
public async getFeatureIdsByRegistrationContexts(
RegistrationContexts: string[]
): Promise<string[]> {
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<string>();
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);
}
}
}

View file

@ -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'] },
});

View file

@ -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<typeof serverMock.create>;
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',
});
});
});

View file

@ -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<RacRequestHandlerContext>) => {
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,
},
},
});
}
}
);
};

View file

@ -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<RacRequestHandlerContext>) {
getAlertByIdRoute(router);
@ -19,4 +20,5 @@ export function defineRoutes(router: IRouter<RacRequestHandlerContext>) {
getAlertsIndexRoute(router);
bulkUpdateAlertsRoute(router);
findAlertsByQueryRoute(router);
getFeatureIdsByRegistrationContexts(router);
}

View file

@ -17,6 +17,7 @@ export const ruleDataServiceMock = {
initializeIndex: jest.fn(),
findIndexByName: jest.fn(),
findIndexByFeature: jest.fn(),
findFeatureIdsByRegistrationContexts: jest.fn(),
}),
};

View file

@ -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<string, IndexInfo>;
private readonly indicesByFeatureId: Map<string, IndexInfo[]>;
private readonly registrationContextByFeatureId: Map<string, string>;
private readonly resourceInstaller: IResourceInstaller;
private installCommonResources: Promise<Either<Error, 'ok'>>;
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<WaitResult> => {
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) {

View file

@ -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: {} }
);
});
});

View file

@ -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,

View file

@ -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 {

View file

@ -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';

View file

@ -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<AlertsFlyoutProps> = ({
<EuiLoadingContent lines={1} />
) : (
<EuiText size="s" data-test-subj="alertsFlyoutName">
{get(alert, AlertsField.name, [])[0]}
{/* any is required here to improve typescript performance */}
{get(alert as any, AlertsField.name, [])[0] as string}
</EuiText>
)}
</EuiFlexItem>
@ -112,7 +114,8 @@ export const AlertsFlyout: React.FunctionComponent<AlertsFlyoutProps> = ({
<EuiLoadingContent lines={3} />
) : (
<EuiText size="s" data-test-subj="alertsFlyoutReason">
{get(alert, AlertsField.reason, [])[0]}
{/* any is required here to improve typescript performance */}
{get(alert as any, AlertsField.reason, [])[0] as string}
</EuiText>
)}
</EuiFlexItem>

View file

@ -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<AlertsData[]>([]);
const [alerts, setAlerts] = useState<EcsFieldsResponse[]>([]);
const [sort, setSort] = useState<estypes.SortCombinations[]>(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<EcsFieldsResponse>(
(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,

View file

@ -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(<AlertsTable {...tableProps} />);
@ -184,18 +157,4 @@ describe('AlertsTable', () => {
});
});
});
describe('Alerts table configuration registry', () => {
it('should read the configuration from the registry', async () => {
render(<AlertsTable {...tableProps} />);
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(<AlertsTable {...props} />);
expect(result.getByTestId('alertsTableNoConfiguration')).toBeTruthy();
});
});
});

View file

@ -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<AlertsTableProps> = (props: AlertsTableProps) => {
const [rowClasses, setRowClasses] = useState<EuiDataGridStyle['rowClasses']>({});
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<AlertsTableProps> = (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<AlertsTableProps> = (props: AlertsTab
const handleFlyoutClose = useCallback(() => setFlyoutAlertIndex(-1), [setFlyoutAlertIndex]);
return hasAlertsTableConfiguration ? (
<section data-test-subj={props['data-test-subj']}>
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 (
<section style={{ width: '100%' }} data-test-subj={props['data-test-subj']}>
{flyoutAlertIndex > -1 && (
<Suspense fallback={null}>
<AlertsFlyout
alert={props.alerts[flyoutAlertIndex]}
alert={alerts[flyoutAlertIndex]}
alertsCount={alertsCount}
onClose={handleFlyoutClose}
flyoutIndex={flyoutAlertIndex + pagination.pageIndex * pagination.pageSize}
@ -133,21 +141,13 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTab
<EuiDataGrid
aria-label="Alerts table"
data-test-subj="alertsTable"
columns={alertsTableConfiguration.columns}
columns={props.columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
trailingControlColumns={props.trailingControlColumns}
leadingControlColumns={leadingControlColumns}
rowCount={alertsCount}
renderCellValue={(improper: EuiDataGridCellValueElementProps) => {
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<AlertsTableProps> = (props: AlertsTab
}}
/>
</section>
) : (
<EuiEmptyPrompt
data-test-subj="alertsTableNoConfiguration"
iconType="watchesApp"
title={<h2>{ALERTS_TABLE_CONF_ERROR_TITLE}</h2>}
body={<p>{ALERTS_TABLE_CONF_ERROR_MESSAGE}</p>}
/>
);
};

View file

@ -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<AlertsTableConfigurationRegistry>;
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(<AlertsTableState {...tableProps} />);
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(<AlertsTableState {...props} />);
expect(result.getByTestId('alertsTableNoConfiguration')).toBeTruthy();
});
});
});

View file

@ -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<AlertsTableConfigurationRegistry>;
configurationId: string;
id: string;
featureIds: ValidFeatureId[];
query: Pick<QueryDslQueryContainer, 'bool' | 'ids'>;
}
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<AlertsTableStorage>;
const storageAlertsTable = useRef<AlertsTableStorage>({
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<SortCombinations[]>(storageAlertsTable.current.sort);
const [pagination, setPagination] = useState(DefaultPagination);
const [columns, setColumns] = useState<EuiDataGridColumn[]>(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 && (
<EuiProgress size="xs" color="accent" data-test-subj="internalAlertsPageLoading" />
)}
<AlertsTable {...tableProps} />
</>
) : (
<EuiEmptyPrompt
data-test-subj="alertsTableNoConfiguration"
iconType="watchesApp"
title={<h2>{ALERTS_TABLE_CONF_ERROR_TITLE}</h2>}
body={<p>{ALERTS_TABLE_CONF_ERROR_MESSAGE}</p>}
/>
);
};
export { AlertsTableState };
// eslint-disable-next-line import/no-default-export
export { AlertsTableState as default };

View file

@ -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',
},
},
];

View file

@ -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';

View file

@ -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`,
}
);

View file

@ -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<IKibanaSearchResponse>(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<IKibanaSearchResponse>(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<IKibanaSearchResponse>({ ...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<IKibanaSearchResponse>({ ...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<IKibanaSearchResponse>({ 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<IKibanaSearchResponse>({
...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<IKibanaSearchResponse>({
...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,
},
]);
});
});

View file

@ -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<QueryDslQueryContainer, 'bool' | 'ids'>;
pagination: {
pageIndex: number;
pageSize: number;
};
sort: SortCombinations[];
skip: boolean;
}
type AlertRequest = Omit<FetchAlertsArgs, 'featureIds' | 'skip'>;
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<FetchAlertResp, 'getInspectQuery' | 'refetch'>;
interface AlertStateReducer {
loading: boolean;
request: Omit<FetchAlertsArgs, 'skip'>;
response: AlertResponseState;
}
type AlertActions =
| { type: 'loading'; loading: boolean }
| { type: 'response'; alerts: EcsFieldsResponse[]; totalAlerts: number }
| { type: 'resetPagination' }
| { type: 'request'; request: Omit<FetchAlertsArgs, 'skip'> };
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<Refetch>(noop);
const abortCtrl = useRef(new AbortController());
const searchSubscription$ = useRef(new Subscription());
const [{ loading, request: alertRequest, response: alertResponse }, dispatch] = useReducer(
alertReducer,
initialAlertState
);
const prevAlertRequest = useRef<AlertRequest | null>(null);
const inspectQuery = useRef<InspectQuery>({
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<RuleRegistrySearchRequest, RuleRegistrySearchResponse>(
{ ...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<EcsFieldsResponse[]>((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 };

View file

@ -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<RuleRegistrySearchRequestPagination>({
pageIndex,

View file

@ -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();
});

View file

@ -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<EuiDataGridSorting['columns']>(
formatGridColumns(defaultSort)
);
const onSort = useCallback(
(_state) => {
onSortChange(_state);

View file

@ -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]

View file

@ -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<AlertsTableStateProps> = lazy(
() => import('../application/sections/alerts_table/alerts_table_state')
);
export const getAlertsTableStateLazy = (props: AlertsTableStateProps) => (
<Suspense fallback={<EuiLoadingSpinner />}>
<AlertsTableStateLazy {...props} />
</Suspense>
);

View file

@ -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

View file

@ -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<ActionTypeModel>();
@ -62,6 +64,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart {
ruleTypeRegistry,
});
},
getAlertsStateTable: (props: AlertsTableStateProps) => {
return getAlertsTableStateLazy(props);
},
getAlertsTable: (props: AlertsTableProps) => {
return getAlertsTableLazy(props);
},

View file

@ -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<ActionTypeModel>;
@ -83,6 +85,7 @@ export interface TriggersAndActionsUIPublicPluginStart {
props: Omit<RuleEditProps, 'actionTypeRegistry' | 'ruleTypeRegistry'>
) => ReactElement<RuleEditProps>;
getAlertsTable: (props: AlertsTableProps) => ReactElement<AlertsTableProps>;
getAlertsStateTable: (props: AlertsTableStateProps) => ReactElement<AlertsTableStateProps>;
getRuleStatusDropdown: (props: RuleStatusDropdownProps) => ReactElement<RuleStatusDropdownProps>;
getRuleTagFilter: (props: RuleTagFilterProps) => ReactElement<RuleTagFilterProps>;
getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement<RuleStatusFilterProps>;
@ -255,6 +258,9 @@ export class Plugin
ruleTypeRegistry: this.ruleTypeRegistry,
});
},
getAlertsStateTable: (props: AlertsTableStateProps) => {
return getAlertsTableStateLazy(props);
},
getAlertsTable: (props: AlertsTableProps) => {
return getAlertsTableLazy(props);
},

View file

@ -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';
@ -388,19 +391,18 @@ export enum AlertsField {
reason = 'kibana.alert.reason',
}
export type AlertsData = Record<AlertsField, any[]>;
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 {
@ -410,8 +412,7 @@ export interface BulkActionsObjectProp {
}
export interface AlertsTableProps {
configurationId: string;
consumers: AlertConsumers[];
columns: EuiDataGridColumn[];
bulkActions: BulkActionsObjectProp;
// defaultCellActions: TGridCellAction[];
deletedEventIds: string[];
@ -423,18 +424,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';

View file

@ -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']);
});
});
});
};

View file

@ -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'));