mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[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:
parent
ad60119cf1
commit
859a795469
47 changed files with 2045 additions and 160 deletions
|
@ -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} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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']);
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
||||
|
|
|
@ -18,6 +18,7 @@ const createAlertsClientMock = () => {
|
|||
getAuthorizedAlertsIndices: jest.fn(),
|
||||
bulkUpdate: jest.fn(),
|
||||
find: jest.fn(),
|
||||
getFeatureIdsByRegistrationContexts: jest.fn(),
|
||||
};
|
||||
return mocked;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'] },
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export const ruleDataServiceMock = {
|
|||
initializeIndex: jest.fn(),
|
||||
findIndexByName: jest.fn(),
|
||||
findIndexByFeature: jest.fn(),
|
||||
findFeatureIdsByRegistrationContexts: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {} }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
];
|
|
@ -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';
|
||||
|
|
|
@ -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`,
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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'));
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue