mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution][Detections] adds confirmation modal window for bulk exporting action (#136418)
## Summary - addresses https://github.com/elastic/kibana/issues/127746 - when users select rules for bulk export confirmation dialog is displayed, that shows how many rules can be exported. Only custom rules are exportable - if no rules can be exported, dialog will show users, that action is not available - changes successful export message, by showing note that prebuilt rules are excluded, only when rule have been excluded ### Modal windows #### no rules can be exported <img width="1293" alt="Screenshot 2022-07-18 at 14 01 36" src="https://user-images.githubusercontent.com/92328789/179517392-913f3dd9-4118-46eb-ba35-77d46906efd2.png"> #### some rules can be exported <img width="1267" alt="Screenshot 2022-07-18 at 14 02 30" src="https://user-images.githubusercontent.com/92328789/179517376-cff64ee2-af9a-448b-aa2a-ce19e1542d6b.png"> ### Implementation details - we won't need dry run action here, because export doesn't mutate state - once user click on export, we download file in browser, read it, and display message to user how many rules can/can't be exported - since all failed export are immutable rules, we can safely display immutable message error to users in modal window - user can proceed with download of exported rules OR cancel export action, thus experience will become consistent with bulk edit ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Release note improves user experience for bulk export of security rules, by displaying confirmation modal, that show how many rules can be exported
This commit is contained in:
parent
12259ce281
commit
0cc899cdb0
21 changed files with 679 additions and 266 deletions
|
@ -10,7 +10,6 @@ import {
|
|||
CUSTOM_RULES_BTN,
|
||||
MODAL_CONFIRMATION_BTN,
|
||||
SELECT_ALL_RULES_ON_PAGE_CHECKBOX,
|
||||
LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN,
|
||||
RULES_TAGS_FILTER_BTN,
|
||||
RULE_CHECKBOX,
|
||||
RULES_TAGS_POPOVER_BTN,
|
||||
|
@ -29,11 +28,12 @@ import {
|
|||
waitForRulesTableToBeLoaded,
|
||||
selectAllRules,
|
||||
goToTheRuleDetailsOf,
|
||||
waitForRulesTableToBeRefreshed,
|
||||
selectNumberOfRules,
|
||||
testAllTagsBadges,
|
||||
testTagsBadge,
|
||||
testMultipleSelectedRulesLabel,
|
||||
loadPrebuiltDetectionRulesFromHeaderBtn,
|
||||
switchToElasticRules,
|
||||
} from '../../tasks/alerts_detection_rules';
|
||||
|
||||
import {
|
||||
|
@ -105,14 +105,11 @@ describe('Detection rules, bulk edit', () => {
|
|||
it('should show warning modal windows when some of the selected rules cannot be edited', () => {
|
||||
createMachineLearningRule(getMachineLearningRule(), '7');
|
||||
|
||||
cy.get(LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN)
|
||||
.pipe(($el) => $el.trigger('click'))
|
||||
.should('not.exist');
|
||||
loadPrebuiltDetectionRulesFromHeaderBtn();
|
||||
|
||||
// select few Elastic rules, check if we can't proceed further, as ELastic rules are not editable
|
||||
// filter rules, only Elastic rule to show
|
||||
cy.get(ELASTIC_RULES_BTN).click();
|
||||
waitForRulesTableToBeRefreshed();
|
||||
switchToElasticRules();
|
||||
|
||||
// check modal window for few selected rules
|
||||
selectNumberOfRules(numberOfRulesPerPage);
|
||||
|
|
|
@ -5,13 +5,24 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { expectedExportedRule, getNewRule } from '../../objects/rule';
|
||||
import { expectedExportedRule, getNewRule, totalNumberOfPrebuiltRules } from '../../objects/rule';
|
||||
|
||||
import { TOASTER_BODY } from '../../screens/alerts_detection_rules';
|
||||
import {
|
||||
TOASTER_BODY,
|
||||
MODAL_CONFIRMATION_BODY,
|
||||
MODAL_CONFIRMATION_BTN,
|
||||
} from '../../screens/alerts_detection_rules';
|
||||
|
||||
import { exportFirstRule } from '../../tasks/alerts_detection_rules';
|
||||
import {
|
||||
exportFirstRule,
|
||||
loadPrebuiltDetectionRulesFromHeaderBtn,
|
||||
switchToElasticRules,
|
||||
selectNumberOfRules,
|
||||
bulkExportRules,
|
||||
selectAllRules,
|
||||
} from '../../tasks/alerts_detection_rules';
|
||||
import { createCustomRule } from '../../tasks/api_calls/rules';
|
||||
import { cleanKibana } from '../../tasks/common';
|
||||
import { cleanKibana, deleteAlertsAndRules } from '../../tasks/common';
|
||||
import { login, visitWithoutDateRange } from '../../tasks/login';
|
||||
|
||||
import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation';
|
||||
|
@ -23,6 +34,7 @@ describe('Export rules', () => {
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
deleteAlertsAndRules();
|
||||
// Rules get exported via _bulk_action endpoint
|
||||
cy.intercept('POST', '/api/detection_engine/rules/_bulk_action').as('bulk_action');
|
||||
visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL);
|
||||
|
@ -33,10 +45,45 @@ describe('Export rules', () => {
|
|||
exportFirstRule();
|
||||
cy.wait('@bulk_action').then(({ response }) => {
|
||||
cy.wrap(response?.body).should('eql', expectedExportedRule(this.ruleResponse));
|
||||
cy.get(TOASTER_BODY).should(
|
||||
'have.text',
|
||||
'Successfully exported 1 of 1 rule. Prebuilt rules were excluded from the resulting file.'
|
||||
);
|
||||
cy.get(TOASTER_BODY).should('have.text', 'Successfully exported 1 of 1 rule.');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a modal saying that no rules can be exported if all the selected rules are prebuilt', function () {
|
||||
const expectedElasticRulesCount = 7;
|
||||
|
||||
loadPrebuiltDetectionRulesFromHeaderBtn();
|
||||
|
||||
switchToElasticRules();
|
||||
selectNumberOfRules(expectedElasticRulesCount);
|
||||
bulkExportRules();
|
||||
|
||||
cy.get(MODAL_CONFIRMATION_BODY).contains(
|
||||
`${expectedElasticRulesCount} prebuilt Elastic rules (exporting prebuilt rules is not supported)`
|
||||
);
|
||||
});
|
||||
|
||||
it('exports only custom rules', function () {
|
||||
const expectedNumberCustomRulesToBeExported = 1;
|
||||
const totalNumberOfRules = expectedNumberCustomRulesToBeExported + totalNumberOfPrebuiltRules;
|
||||
|
||||
loadPrebuiltDetectionRulesFromHeaderBtn();
|
||||
|
||||
selectAllRules();
|
||||
bulkExportRules();
|
||||
|
||||
cy.get(MODAL_CONFIRMATION_BODY).contains(
|
||||
`${totalNumberOfPrebuiltRules} prebuilt Elastic rules (exporting prebuilt rules is not supported)`
|
||||
);
|
||||
|
||||
// proceed with exporting only custom rules
|
||||
cy.get(MODAL_CONFIRMATION_BTN)
|
||||
.should('have.text', `Export ${expectedNumberCustomRulesToBeExported} Custom rule`)
|
||||
.click();
|
||||
|
||||
cy.get(TOASTER_BODY).should(
|
||||
'contain',
|
||||
`Successfully exported ${expectedNumberCustomRulesToBeExported} of ${totalNumberOfRules} rules. Prebuilt rules were excluded from the resulting file.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,6 +35,8 @@ export const ELASTIC_RULES_BTN = '[data-test-subj="showElasticRulesFilterButton"
|
|||
|
||||
export const EXPORT_ACTION_BTN = '[data-test-subj="exportRuleAction"]';
|
||||
|
||||
export const BULK_EXPORT_ACTION_BTN = '[data-test-subj="exportRuleBulk"]';
|
||||
|
||||
export const FIRST_RULE = 0;
|
||||
|
||||
export const FOURTH_RULE = 3;
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
DELETE_RULE_ACTION_BTN,
|
||||
DELETE_RULE_BULK_BTN,
|
||||
LOAD_PREBUILT_RULES_BTN,
|
||||
LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN,
|
||||
RULES_TABLE_INITIAL_LOADING_INDICATOR,
|
||||
RULES_TABLE_REFRESH_INDICATOR,
|
||||
RULES_TABLE_AUTOREFRESH_INDICATOR,
|
||||
|
@ -50,6 +51,8 @@ import {
|
|||
SELECTED_RULES_NUMBER_LABEL,
|
||||
REFRESH_SETTINGS_POPOVER,
|
||||
REFRESH_SETTINGS_SWITCH,
|
||||
ELASTIC_RULES_BTN,
|
||||
BULK_EXPORT_ACTION_BTN,
|
||||
} from '../screens/alerts_detection_rules';
|
||||
import { ALL_ACTIONS } from '../screens/rule_details';
|
||||
import { LOADING_INDICATOR } from '../screens/security_header';
|
||||
|
@ -168,6 +171,12 @@ export const loadPrebuiltDetectionRules = () => {
|
|||
.should('be.disabled');
|
||||
};
|
||||
|
||||
export const loadPrebuiltDetectionRulesFromHeaderBtn = () => {
|
||||
cy.get(LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN)
|
||||
.pipe(($el) => $el.trigger('click'))
|
||||
.should('not.exist');
|
||||
};
|
||||
|
||||
export const openIntegrationsPopover = () => {
|
||||
cy.get(INTEGRATIONS_POPOVER).click();
|
||||
};
|
||||
|
@ -328,3 +337,13 @@ export const mockGlobalClock = () => {
|
|||
|
||||
cy.clock(Date.now(), ['setInterval', 'clearInterval', 'Date']);
|
||||
};
|
||||
|
||||
export const switchToElasticRules = () => {
|
||||
cy.get(ELASTIC_RULES_BTN).click();
|
||||
waitForRulesTableToBeRefreshed();
|
||||
};
|
||||
|
||||
export const bulkExportRules = () => {
|
||||
cy.get(BULK_ACTIONS_BTN).click();
|
||||
cy.get(BULK_EXPORT_ACTION_BTN).click();
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@ import type { Rule } from '../../../containers/detection_engine/rules';
|
|||
import {
|
||||
executeRulesBulkAction,
|
||||
goToRuleEditPage,
|
||||
bulkExportRules,
|
||||
} from '../../../pages/detection_engine/rules/all/actions';
|
||||
import * as i18nActions from '../../../pages/detection_engine/rules/translations';
|
||||
import * as i18n from './translations';
|
||||
|
@ -108,7 +109,7 @@ const RuleActionsOverflowComponent = ({
|
|||
onClick={async () => {
|
||||
startTransaction({ name: SINGLE_RULE_ACTIONS.EXPORT });
|
||||
closePopover();
|
||||
await executeRulesBulkAction({
|
||||
await bulkExportRules({
|
||||
action: BulkAction.export,
|
||||
onSuccess: noop,
|
||||
search: { ids: [rule.id] },
|
||||
|
|
|
@ -16,8 +16,8 @@ import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts';
|
|||
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../../common/lib/telemetry';
|
||||
import { downloadBlob } from '../../../../../common/utils/download_blob';
|
||||
import type {
|
||||
BulkActionResponse,
|
||||
BulkActionSummary,
|
||||
BulkActionResponse,
|
||||
} from '../../../../containers/detection_engine/rules';
|
||||
import { performBulkAction } from '../../../../containers/detection_engine/rules';
|
||||
import * as i18n from '../translations';
|
||||
|
@ -34,19 +34,39 @@ export const goToRuleEditPage = (
|
|||
});
|
||||
};
|
||||
|
||||
interface ExecuteRulesBulkActionArgs {
|
||||
type OnActionSuccessCallback = (
|
||||
toasts: UseAppToasts,
|
||||
action: BulkAction,
|
||||
summary: BulkActionSummary
|
||||
) => void;
|
||||
|
||||
type OnActionErrorCallback = (toasts: UseAppToasts, action: BulkAction, error: HTTPError) => void;
|
||||
|
||||
interface BaseRulesBulkActionArgs {
|
||||
visibleRuleIds?: string[];
|
||||
action: BulkAction;
|
||||
toasts: UseAppToasts;
|
||||
search: { query: string } | { ids: string[] };
|
||||
payload?: { edit?: BulkActionEditPayload[] };
|
||||
onSuccess?: (toasts: UseAppToasts, action: BulkAction, summary: BulkActionSummary) => void;
|
||||
onError?: (toasts: UseAppToasts, action: BulkAction, error: HTTPError) => void;
|
||||
onError?: OnActionErrorCallback;
|
||||
onFinish?: () => void;
|
||||
onSuccess?: OnActionSuccessCallback;
|
||||
setLoadingRules?: RulesTableActions['setLoadingRules'];
|
||||
}
|
||||
|
||||
export const executeRulesBulkAction = async ({
|
||||
interface RulesBulkActionArgs extends BaseRulesBulkActionArgs {
|
||||
action: Exclude<BulkAction, BulkAction.export>;
|
||||
}
|
||||
interface ExportRulesBulkActionArgs extends BaseRulesBulkActionArgs {
|
||||
action: BulkAction.export;
|
||||
}
|
||||
|
||||
// export bulk actions API returns blob, the rest of actions returns BulkActionResponse object
|
||||
// hence method overloading to make type safe calls
|
||||
export async function executeRulesBulkAction(args: ExportRulesBulkActionArgs): Promise<Blob | null>;
|
||||
export async function executeRulesBulkAction(
|
||||
args: RulesBulkActionArgs
|
||||
): Promise<BulkActionResponse | null>;
|
||||
export async function executeRulesBulkAction({
|
||||
visibleRuleIds = [],
|
||||
action,
|
||||
setLoadingRules,
|
||||
|
@ -56,20 +76,18 @@ export const executeRulesBulkAction = async ({
|
|||
onSuccess = defaultSuccessHandler,
|
||||
onError = defaultErrorHandler,
|
||||
onFinish,
|
||||
}: ExecuteRulesBulkActionArgs) => {
|
||||
}: RulesBulkActionArgs | ExportRulesBulkActionArgs) {
|
||||
let response: Blob | BulkActionResponse | null = null;
|
||||
try {
|
||||
setLoadingRules?.({ ids: visibleRuleIds, action });
|
||||
|
||||
if (action === BulkAction.export) {
|
||||
const response = await performBulkAction({ ...search, action });
|
||||
downloadBlob(response, `${i18n.EXPORT_FILENAME}.ndjson`);
|
||||
onSuccess(toasts, action, await getExportedRulesCounts(response));
|
||||
// on successToast for export handles separately outside of action execution method
|
||||
response = await performBulkAction({ ...search, action });
|
||||
} else {
|
||||
const response = await performBulkAction({ ...search, action, edit: payload?.edit });
|
||||
response = await performBulkAction({ ...search, action, edit: payload?.edit });
|
||||
sendTelemetry(action, response);
|
||||
onSuccess(toasts, action, response.attributes.summary);
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
onError(toasts, action, error);
|
||||
|
@ -77,7 +95,47 @@ export const executeRulesBulkAction = async ({
|
|||
setLoadingRules?.({ ids: [], action: null });
|
||||
onFinish?.();
|
||||
}
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* downloads exported rules, received from export action
|
||||
* @param params.response - Blob results with exported rules
|
||||
* @param params.toasts - {@link UseAppToasts} toasts service
|
||||
* @param params.onSuccess - {@link OnActionSuccessCallback} optional toast to display when action successful
|
||||
* @param params.onError - {@link OnActionErrorCallback} optional toast to display when action failed
|
||||
*/
|
||||
export async function downloadExportedRules({
|
||||
response,
|
||||
toasts,
|
||||
onSuccess = defaultSuccessHandler,
|
||||
onError = defaultErrorHandler,
|
||||
}: {
|
||||
response: Blob;
|
||||
toasts: UseAppToasts;
|
||||
onSuccess?: OnActionSuccessCallback;
|
||||
onError?: OnActionErrorCallback;
|
||||
}) {
|
||||
try {
|
||||
downloadBlob(response, `${i18n.EXPORT_FILENAME}.ndjson`);
|
||||
onSuccess(toasts, BulkAction.export, await getExportedRulesCounts(response));
|
||||
} catch (error) {
|
||||
onError(toasts, BulkAction.export, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* executes bulk export action and downloads exported rules
|
||||
* @param params - {@link ExportRulesBulkActionArgs}
|
||||
*/
|
||||
export async function bulkExportRules(params: ExportRulesBulkActionArgs) {
|
||||
const response = await executeRulesBulkAction(params);
|
||||
|
||||
// if response null, likely network error happened and export rules haven't been received
|
||||
if (response) {
|
||||
await downloadExportedRules({ response, toasts: params.toasts, onSuccess: params.onSuccess });
|
||||
}
|
||||
}
|
||||
|
||||
function defaultErrorHandler(toasts: UseAppToasts, action: BulkAction, error: HTTPError) {
|
||||
// if response doesn't have number of failed rules, it means the whole bulk action failed
|
||||
|
@ -130,6 +188,18 @@ function defaultErrorHandler(toasts: UseAppToasts, action: BulkAction, error: HT
|
|||
toasts.addError(error, { title, toastMessage });
|
||||
}
|
||||
|
||||
const getExportSuccessToastMessage = (succeeded: number, total: number) => {
|
||||
const message = [i18n.RULES_BULK_EXPORT_SUCCESS_DESCRIPTION(succeeded, total)];
|
||||
|
||||
// if not all rules are successfully exported it means there included prebuilt rules
|
||||
// display message to users that prebuilt rules were excluded
|
||||
if (total > succeeded) {
|
||||
message.push(i18n.RULES_BULK_EXPORT_PREBUILT_RULES_EXCLUDED_DESCRIPTION);
|
||||
}
|
||||
|
||||
return message.join(' ');
|
||||
};
|
||||
|
||||
async function defaultSuccessHandler(
|
||||
toasts: UseAppToasts,
|
||||
action: BulkAction,
|
||||
|
@ -141,7 +211,7 @@ async function defaultSuccessHandler(
|
|||
switch (action) {
|
||||
case BulkAction.export:
|
||||
title = i18n.RULES_BULK_EXPORT_SUCCESS;
|
||||
text = i18n.RULES_BULK_EXPORT_SUCCESS_DESCRIPTION(summary.succeeded, summary.total);
|
||||
text = getExportSuccessToastMessage(summary.succeeded, summary.total);
|
||||
break;
|
||||
case BulkAction.duplicate:
|
||||
title = i18n.RULES_BULK_DUPLICATE_SUCCESS;
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 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 { EuiConfirmModal } from '@elastic/eui';
|
||||
|
||||
import * as i18n from '../../translations';
|
||||
import { BulkActionRuleErrorsList } from './bulk_action_rule_errors_list';
|
||||
import { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
|
||||
import { assertUnreachable } from '../../../../../../../common/utility_types';
|
||||
|
||||
import type { BulkActionForConfirmation, DryRunResult } from './types';
|
||||
|
||||
const getActionRejectedTitle = (
|
||||
bulkAction: BulkActionForConfirmation,
|
||||
failedRulesCount: number
|
||||
) => {
|
||||
switch (bulkAction) {
|
||||
case BulkAction.edit:
|
||||
return i18n.BULK_EDIT_CONFIRMATION_REJECTED_TITLE(failedRulesCount);
|
||||
case BulkAction.export:
|
||||
return i18n.BULK_EXPORT_CONFIRMATION_REJECTED_TITLE(failedRulesCount);
|
||||
default:
|
||||
assertUnreachable(bulkAction);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionConfirmLabel = (
|
||||
bulkAction: BulkActionForConfirmation,
|
||||
succeededRulesCount: number
|
||||
) => {
|
||||
switch (bulkAction) {
|
||||
case BulkAction.edit:
|
||||
return i18n.BULK_EDIT_CONFIRMATION_CONFIRM(succeededRulesCount);
|
||||
case BulkAction.export:
|
||||
return i18n.BULK_EXPORT_CONFIRMATION_CONFIRM(succeededRulesCount);
|
||||
default:
|
||||
assertUnreachable(bulkAction);
|
||||
}
|
||||
};
|
||||
|
||||
interface BulkEditDryRunConfirmationProps {
|
||||
bulkAction: BulkActionForConfirmation;
|
||||
result?: DryRunResult;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const BulkActionDryRunConfirmationComponent = ({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
result,
|
||||
bulkAction,
|
||||
}: BulkEditDryRunConfirmationProps) => {
|
||||
const { failedRulesCount = 0, succeededRulesCount = 0, ruleErrors = [] } = result ?? {};
|
||||
|
||||
// if no rule can be edited, modal window that denies bulk edit action will be displayed
|
||||
if (succeededRulesCount === 0) {
|
||||
return (
|
||||
<EuiConfirmModal
|
||||
title={getActionRejectedTitle(bulkAction, failedRulesCount)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onCancel}
|
||||
confirmButtonText={i18n.BULK_ACTION_CONFIRMATION_CLOSE}
|
||||
defaultFocusedButton="confirm"
|
||||
data-test-subj="bulkActionRejectModal"
|
||||
>
|
||||
<BulkActionRuleErrorsList bulkAction={bulkAction} ruleErrors={ruleErrors} />
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
// if there are rules that can and cannot be edited, modal window that propose edit of some the rules will be displayed
|
||||
return (
|
||||
<EuiConfirmModal
|
||||
title={i18n.BULK_ACTION_CONFIRMATION_PARTLY_TITLE(succeededRulesCount)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonText={getActionConfirmLabel(bulkAction, succeededRulesCount)}
|
||||
cancelButtonText={i18n.BULK_EDIT_CONFIRMATION_CANCEL}
|
||||
defaultFocusedButton="confirm"
|
||||
data-test-subj="bulkActionConfirmationModal"
|
||||
>
|
||||
<BulkActionRuleErrorsList bulkAction={bulkAction} ruleErrors={ruleErrors} />
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulkActionDryRunConfirmation = React.memo(BulkActionDryRunConfirmationComponent);
|
||||
|
||||
BulkActionDryRunConfirmation.displayName = 'BulkActionDryRunConfirmation';
|
|
@ -10,9 +10,10 @@ import React from 'react';
|
|||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import type { DryRunResult } from './use_bulk_actions_dry_run';
|
||||
import { BulkEditRuleErrorsList } from './bulk_edit_rule_errors_list';
|
||||
import { BulkActionRuleErrorsList } from './bulk_action_rule_errors_list';
|
||||
import { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
|
||||
import type { DryRunResult } from './types';
|
||||
import { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
|
||||
|
||||
const Wrapper: FC = ({ children }) => {
|
||||
return (
|
||||
|
@ -24,7 +25,12 @@ const Wrapper: FC = ({ children }) => {
|
|||
|
||||
describe('Component BulkEditRuleErrorsList', () => {
|
||||
test('should not render component if no errors present', () => {
|
||||
const { container } = render(<BulkEditRuleErrorsList ruleErrors={[]} />, { wrapper: Wrapper });
|
||||
const { container } = render(
|
||||
<BulkActionRuleErrorsList bulkAction={BulkAction.edit} ruleErrors={[]} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
}
|
||||
);
|
||||
|
||||
expect(container.childElementCount).toEqual(0);
|
||||
});
|
||||
|
@ -40,7 +46,9 @@ describe('Component BulkEditRuleErrorsList', () => {
|
|||
ruleIds: ['rule:1'],
|
||||
},
|
||||
];
|
||||
render(<BulkEditRuleErrorsList ruleErrors={ruleErrors} />, { wrapper: Wrapper });
|
||||
render(<BulkActionRuleErrorsList bulkAction={BulkAction.edit} ruleErrors={ruleErrors} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByText("2 rules can't be edited (test failure)")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 rule can't be edited (another failure)")).toBeInTheDocument();
|
||||
|
@ -68,7 +76,9 @@ describe('Component BulkEditRuleErrorsList', () => {
|
|||
ruleIds: ['rule:1', 'rule:2'],
|
||||
},
|
||||
];
|
||||
render(<BulkEditRuleErrorsList ruleErrors={ruleErrors} />, { wrapper: Wrapper });
|
||||
render(<BulkActionRuleErrorsList bulkAction={BulkAction.edit} ruleErrors={ruleErrors} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByText(value)).toBeInTheDocument();
|
||||
});
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* 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 { EuiSpacer } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
|
||||
import { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
|
||||
|
||||
import type { DryRunResult, BulkActionForConfirmation } from './types';
|
||||
|
||||
interface BulkActionRuleErrorItemProps {
|
||||
errorCode: BulkActionsDryRunErrCode | undefined;
|
||||
message: string;
|
||||
rulesCount: number;
|
||||
}
|
||||
|
||||
const BulkEditRuleErrorItem = ({
|
||||
errorCode,
|
||||
message,
|
||||
rulesCount,
|
||||
}: BulkActionRuleErrorItemProps) => {
|
||||
switch (errorCode) {
|
||||
case BulkActionsDryRunErrCode.IMMUTABLE:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.elasticRulesEditDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# prebuilt Elastic rule} other {# prebuilt Elastic rules}} (editing prebuilt rules is not supported)"
|
||||
values={{ rulesCount }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
case BulkActionsDryRunErrCode.MACHINE_LEARNING_INDEX_PATTERN:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesIndexEditDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# custom Machine Learning rule} other {# custom Machine Learning rules}} (these rules don't have index patterns)"
|
||||
values={{ rulesCount }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
case BulkActionsDryRunErrCode.MACHINE_LEARNING_AUTH:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesAuthDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# Machine Learning rule} other {# Machine Learning rules}} can't be edited ({message})"
|
||||
values={{ rulesCount, message }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.defaultRulesEditFailureDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# rule} other {# rules}} can't be edited ({message})"
|
||||
values={{ rulesCount, message }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const BulkExportRuleErrorItem = ({
|
||||
errorCode,
|
||||
message,
|
||||
rulesCount,
|
||||
}: BulkActionRuleErrorItemProps) => {
|
||||
switch (errorCode) {
|
||||
case BulkActionsDryRunErrCode.IMMUTABLE:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.elasticRulesExportDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# prebuilt Elastic rule} other {# prebuilt Elastic rules}} (exporting prebuilt rules is not supported)"
|
||||
values={{ rulesCount }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.defaultRulesExportFailureDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# rule} other {# rules}} can't be exported ({message})"
|
||||
values={{ rulesCount, message }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface BulkActionRuleErrorsListProps {
|
||||
ruleErrors: DryRunResult['ruleErrors'];
|
||||
bulkAction: BulkActionForConfirmation;
|
||||
}
|
||||
|
||||
const BulkActionRuleErrorsListComponent = ({
|
||||
ruleErrors = [],
|
||||
bulkAction,
|
||||
}: BulkActionRuleErrorsListProps) => {
|
||||
if (ruleErrors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.actionRejectionDescription"
|
||||
defaultMessage="This action can't be applied to the following rules:"
|
||||
/>
|
||||
<EuiSpacer />
|
||||
<ul>
|
||||
{ruleErrors.map(({ message, errorCode, ruleIds }) => {
|
||||
const rulesCount = ruleIds.length;
|
||||
switch (bulkAction) {
|
||||
case BulkAction.edit:
|
||||
return (
|
||||
<BulkEditRuleErrorItem
|
||||
message={message}
|
||||
errorCode={errorCode}
|
||||
rulesCount={rulesCount}
|
||||
/>
|
||||
);
|
||||
|
||||
case BulkAction.export:
|
||||
return (
|
||||
<BulkExportRuleErrorItem
|
||||
message={message}
|
||||
errorCode={errorCode}
|
||||
rulesCount={rulesCount}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulkActionRuleErrorsList = React.memo(BulkActionRuleErrorsListComponent);
|
||||
|
||||
BulkActionRuleErrorsList.displayName = 'BulkActionRuleErrorsList';
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* 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 { EuiConfirmModal } from '@elastic/eui';
|
||||
|
||||
import * as i18n from '../../translations';
|
||||
import type { DryRunResult } from './use_bulk_actions_dry_run';
|
||||
import { BulkEditRuleErrorsList } from './bulk_edit_rule_errors_list';
|
||||
|
||||
interface BulkEditDryRunConfirmationProps {
|
||||
result?: DryRunResult;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const BulkEditDryRunConfirmationComponent = ({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
result,
|
||||
}: BulkEditDryRunConfirmationProps) => {
|
||||
const { failedRulesCount = 0, succeededRulesCount = 0, ruleErrors = [] } = result ?? {};
|
||||
|
||||
// if no rule can be edited, modal window that denies bulk edit action will be displayed
|
||||
if (succeededRulesCount === 0) {
|
||||
return (
|
||||
<EuiConfirmModal
|
||||
title={i18n.BULK_EDIT_CONFIRMATION_DENIED_TITLE(failedRulesCount)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onCancel}
|
||||
confirmButtonText={i18n.BULK_EDIT_CONFIRMATION_CLOSE}
|
||||
defaultFocusedButton="confirm"
|
||||
data-test-subj="bulkEditRejectModal"
|
||||
>
|
||||
<BulkEditRuleErrorsList ruleErrors={ruleErrors} />
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
// if there are rules that can and cannot be edited, modal window that propose edit of some the rules will be displayed
|
||||
return (
|
||||
<EuiConfirmModal
|
||||
title={i18n.BULK_EDIT_CONFIRMATION_PARTLY_TITLE(succeededRulesCount)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonText={i18n.BULK_EDIT_CONFIRMATION_CONFIRM(succeededRulesCount)}
|
||||
cancelButtonText={i18n.BULK_EDIT_CONFIRMATION_CANCEL}
|
||||
defaultFocusedButton="confirm"
|
||||
data-test-subj="bulkEditConfirmationModal"
|
||||
>
|
||||
<BulkEditRuleErrorsList ruleErrors={ruleErrors} />
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulkEditDryRunConfirmation = React.memo(BulkEditDryRunConfirmationComponent);
|
||||
|
||||
BulkEditDryRunConfirmation.displayName = 'BulkEditDryRunConfirmation';
|
|
@ -1,84 +0,0 @@
|
|||
/*
|
||||
* 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 { EuiSpacer } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import type { DryRunResult } from './use_bulk_actions_dry_run';
|
||||
import { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
|
||||
|
||||
interface BulkEditRuleErrorsListProps {
|
||||
ruleErrors: DryRunResult['ruleErrors'];
|
||||
}
|
||||
|
||||
const BulkEditRuleErrorsListComponent = ({ ruleErrors = [] }: BulkEditRuleErrorsListProps) => {
|
||||
if (ruleErrors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.actionRejectionDescription"
|
||||
defaultMessage="This action can't be applied to the following rules:"
|
||||
/>
|
||||
<EuiSpacer />
|
||||
<ul>
|
||||
{ruleErrors.map(({ message, errorCode, ruleIds }) => {
|
||||
const rulesCount = ruleIds.length;
|
||||
switch (errorCode) {
|
||||
case BulkActionsDryRunErrCode.IMMUTABLE:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.elasticRulesEditDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# prebuilt Elastic rule} other {# prebuilt Elastic rules}} (editing prebuilt rules is not supported)"
|
||||
values={{ rulesCount }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
case BulkActionsDryRunErrCode.MACHINE_LEARNING_INDEX_PATTERN:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesIndexEditDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# custom Machine Learning rule} other {# custom Machine Learning rules}} (these rules don't have index patterns)"
|
||||
values={{ rulesCount }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
case BulkActionsDryRunErrCode.MACHINE_LEARNING_AUTH:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesAuthDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# Machine Learning rule} other {# Machine Learning rules}} can't be edited ({message})"
|
||||
values={{ rulesCount, message }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<li key={message}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.defaultRulesEditFailureDescription"
|
||||
defaultMessage="{rulesCount, plural, =1 {# rule} other {# rules}} can't be edited ({message})"
|
||||
values={{ rulesCount, message }}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulkEditRuleErrorsList = React.memo(BulkEditRuleErrorsListComponent);
|
||||
|
||||
BulkEditRuleErrorsList.displayName = 'BulkEditRuleErrorsList';
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
|
||||
import type { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
|
||||
|
||||
/**
|
||||
* Only 2 bulk actions are supported for for confirmation dry run modal:
|
||||
* * export
|
||||
* * edit
|
||||
*/
|
||||
export type BulkActionForConfirmation = BulkAction.export | BulkAction.edit;
|
||||
|
||||
/**
|
||||
* transformed results of dry run
|
||||
*/
|
||||
export interface DryRunResult {
|
||||
/**
|
||||
* total number of rules that succeeded validation in dry run
|
||||
*/
|
||||
succeededRulesCount?: number;
|
||||
/**
|
||||
* total number of rules that failed validation in dry run
|
||||
*/
|
||||
failedRulesCount?: number;
|
||||
/**
|
||||
* rule failures errors(message and error code) and ids of rules, that failed
|
||||
*/
|
||||
ruleErrors: Array<{
|
||||
message: string;
|
||||
errorCode?: BulkActionsDryRunErrCode;
|
||||
ruleIds: string[];
|
||||
}>;
|
||||
}
|
|
@ -24,9 +24,11 @@ import { canEditRuleWithActions } from '../../../../../../common/utils/privilege
|
|||
import { useRulesTableContext } from '../rules_table/rules_table_context';
|
||||
import * as detectionI18n from '../../../translations';
|
||||
import * as i18n from '../../translations';
|
||||
import { executeRulesBulkAction } from '../actions';
|
||||
import { executeRulesBulkAction, downloadExportedRules } from '../actions';
|
||||
import { getExportedRulesDetails } from '../helpers';
|
||||
import { useHasActionsPrivileges } from '../use_has_actions_privileges';
|
||||
import { useHasMlPermissions } from '../use_has_ml_permissions';
|
||||
import { transformExportDetailsToDryRunResult } from './utils/dry_run_result';
|
||||
import type { ExecuteBulkActionsDryRun } from './use_bulk_actions_dry_run';
|
||||
import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts';
|
||||
import { convertRulesFilterToKQL } from '../../../../../containers/detection_engine/rules/utils';
|
||||
|
@ -40,10 +42,15 @@ import { BULK_RULE_ACTIONS } from '../../../../../../common/lib/apm/user_actions
|
|||
import { useStartTransaction } from '../../../../../../common/lib/apm/use_start_transaction';
|
||||
import { useInvalidatePrePackagedRulesStatus } from '../../../../../containers/detection_engine/rules/use_pre_packaged_rules_status';
|
||||
|
||||
import type { DryRunResult, BulkActionForConfirmation } from './types';
|
||||
|
||||
interface UseBulkActionsArgs {
|
||||
filterOptions: FilterOptions;
|
||||
confirmDeletion: () => Promise<boolean>;
|
||||
confirmBulkEdit: () => Promise<boolean>;
|
||||
showBulkActionConfirmation: (
|
||||
result: DryRunResult | undefined,
|
||||
action: BulkActionForConfirmation
|
||||
) => Promise<boolean>;
|
||||
completeBulkEditForm: (
|
||||
bulkActionEditType: BulkActionEditType
|
||||
) => Promise<BulkActionEditPayload | null>;
|
||||
|
@ -54,7 +61,7 @@ interface UseBulkActionsArgs {
|
|||
export const useBulkActions = ({
|
||||
filterOptions,
|
||||
confirmDeletion,
|
||||
confirmBulkEdit,
|
||||
showBulkActionConfirmation,
|
||||
completeBulkEditForm,
|
||||
reFetchTags,
|
||||
executeBulkActionsDryRun,
|
||||
|
@ -193,13 +200,32 @@ export const useBulkActions = ({
|
|||
closePopover();
|
||||
startTransaction({ name: BULK_RULE_ACTIONS.EXPORT });
|
||||
|
||||
await executeRulesBulkAction({
|
||||
const response = await executeRulesBulkAction({
|
||||
visibleRuleIds: selectedRuleIds,
|
||||
action: BulkAction.export,
|
||||
setLoadingRules,
|
||||
toasts,
|
||||
search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds },
|
||||
});
|
||||
|
||||
// if response null, likely network error happened and export rules haven't been received
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const details = await getExportedRulesDetails(response);
|
||||
|
||||
// if there are failed exported rules, show modal window to users.
|
||||
// they can either cancel action or proceed with export of succeeded rules
|
||||
const hasActionBeenConfirmed = await showBulkActionConfirmation(
|
||||
transformExportDetailsToDryRunResult(details),
|
||||
BulkAction.export
|
||||
);
|
||||
if (hasActionBeenConfirmed === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadExportedRules({ response, toasts });
|
||||
};
|
||||
|
||||
const handleBulkEdit = (bulkEditActionType: BulkActionEditType) => async () => {
|
||||
|
@ -217,11 +243,12 @@ export const useBulkActions = ({
|
|||
: { ids: selectedRuleIds },
|
||||
});
|
||||
|
||||
// show bulk edit confirmation window only if there is at least one failed rule
|
||||
const hasFailedRules = (dryRunResult?.failedRulesCount ?? 0) > 0;
|
||||
|
||||
if (hasFailedRules && (await confirmBulkEdit()) === false) {
|
||||
// User has cancelled edit action or there are no custom rules to proceed
|
||||
// User has cancelled edit action or there are no custom rules to proceed
|
||||
const hasActionBeenConfirmed = await showBulkActionConfirmation(
|
||||
dryRunResult,
|
||||
BulkAction.edit
|
||||
);
|
||||
if (hasActionBeenConfirmed === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -347,10 +374,7 @@ export const useBulkActions = ({
|
|||
key: i18n.BULK_ACTION_EXPORT,
|
||||
name: i18n.BULK_ACTION_EXPORT,
|
||||
'data-test-subj': 'exportRuleBulk',
|
||||
disabled:
|
||||
(containsImmutable && !isAllSelected) ||
|
||||
containsLoading ||
|
||||
selectedRuleIds.length === 0,
|
||||
disabled: containsLoading || selectedRuleIds.length === 0,
|
||||
onClick: handleExportAction,
|
||||
icon: undefined,
|
||||
},
|
||||
|
@ -446,17 +470,17 @@ export const useBulkActions = ({
|
|||
setLoadingRules,
|
||||
toasts,
|
||||
filterQuery,
|
||||
updateRulesCache,
|
||||
invalidateRules,
|
||||
invalidatePrePackagedRulesStatus,
|
||||
clearRulesSelection,
|
||||
confirmDeletion,
|
||||
confirmBulkEdit,
|
||||
completeBulkEditForm,
|
||||
showBulkActionConfirmation,
|
||||
executeBulkActionsDryRun,
|
||||
filterOptions,
|
||||
completeBulkEditForm,
|
||||
getIsMounted,
|
||||
resolveTagsRefetch,
|
||||
updateRulesCache,
|
||||
clearRulesSelection,
|
||||
executeBulkActionsDryRun,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { useState, useCallback } from 'react';
|
||||
import { useAsyncConfirmation } from '../rules_table/use_async_confirmation';
|
||||
|
||||
import { useBoolState } from '../../../../../../common/hooks/use_bool_state';
|
||||
|
||||
import type { DryRunResult, BulkActionForConfirmation } from './types';
|
||||
|
||||
/**
|
||||
* hook that controls bulk actions confirmation modal window and its content
|
||||
*/
|
||||
export const useBulkActionsConfirmation = () => {
|
||||
const [bulkAction, setBulkAction] = useState<BulkActionForConfirmation>();
|
||||
const [dryRunResult, setDryRunResult] = useState<DryRunResult>();
|
||||
const [isBulkActionConfirmationVisible, showModal, hideModal] = useBoolState();
|
||||
|
||||
const [confirmForm, onConfirm, onCancel] = useAsyncConfirmation({
|
||||
onInit: showModal,
|
||||
onFinish: hideModal,
|
||||
});
|
||||
|
||||
const showBulkActionConfirmation = useCallback(
|
||||
async (result: DryRunResult | undefined, action: BulkActionForConfirmation) => {
|
||||
setBulkAction(action);
|
||||
setDryRunResult(result);
|
||||
|
||||
// show bulk action confirmation window only if there is at least one failed rule, otherwise return early
|
||||
const hasFailedRules = (result?.failedRulesCount ?? 0) > 0;
|
||||
if (!hasFailedRules) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const confirmation = await confirmForm();
|
||||
if (confirmation) {
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
return confirmation;
|
||||
},
|
||||
[confirmForm, onConfirm]
|
||||
);
|
||||
|
||||
return {
|
||||
bulkActionsDryRunResult: dryRunResult,
|
||||
bulkAction,
|
||||
isBulkActionConfirmationVisible,
|
||||
showBulkActionConfirmation,
|
||||
cancelBulkActionConfirmation: onCancel,
|
||||
approveBulkActionConfirmation: onConfirm,
|
||||
};
|
||||
};
|
|
@ -8,8 +8,6 @@
|
|||
import type { UseMutateAsyncFunction } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import type { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
|
||||
|
||||
import type {
|
||||
BulkAction,
|
||||
BulkActionEditType,
|
||||
|
@ -17,48 +15,12 @@ import type {
|
|||
import type { BulkActionResponse } from '../../../../../containers/detection_engine/rules';
|
||||
import { performBulkAction } from '../../../../../containers/detection_engine/rules';
|
||||
import { computeDryRunPayload } from './utils/compute_dry_run_payload';
|
||||
import { processDryRunResult } from './utils/dry_run_result';
|
||||
|
||||
import type { DryRunResult } from './types';
|
||||
|
||||
const BULK_ACTIONS_DRY_RUN_QUERY_KEY = 'bulkActionsDryRun';
|
||||
|
||||
export interface DryRunResult {
|
||||
/**
|
||||
* total number of rules that succeeded validation in dry run
|
||||
*/
|
||||
succeededRulesCount?: number;
|
||||
/**
|
||||
* total number of rules that failed validation in dry run
|
||||
*/
|
||||
failedRulesCount?: number;
|
||||
/**
|
||||
* rule failures errors(message and error code) and ids of rules, that failed
|
||||
*/
|
||||
ruleErrors: Array<{
|
||||
message: string;
|
||||
errorCode?: BulkActionsDryRunErrCode;
|
||||
ruleIds: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* helper utility that transforms raw BulkActionResponse response to DryRunResult format
|
||||
* @param response - raw bulk_actions API response ({@link BulkActionResponse})
|
||||
* @returns dry run result ({@link DryRunResult})
|
||||
*/
|
||||
const processDryRunResult = (response: BulkActionResponse | undefined): DryRunResult => {
|
||||
const processed = {
|
||||
succeededRulesCount: response?.attributes.summary.succeeded,
|
||||
failedRulesCount: response?.attributes.summary.failed,
|
||||
ruleErrors:
|
||||
response?.attributes.errors?.map(({ message, err_code: errorCode, rules }) => ({
|
||||
message,
|
||||
errorCode,
|
||||
ruleIds: rules.map(({ id }) => id),
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
return processed;
|
||||
};
|
||||
|
||||
export type ExecuteBulkActionsDryRun = UseMutateAsyncFunction<
|
||||
DryRunResult | undefined,
|
||||
unknown,
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { BulkActionsDryRunErrCode } from '../../../../../../../../common/constants';
|
||||
import type { ExportRulesDetails } from '../../../../../../../../common/detection_engine/schemas/response/export_rules_details_schema';
|
||||
import type { BulkActionResponse } from '../../../../../../containers/detection_engine/rules';
|
||||
|
||||
import type { DryRunResult } from '../types';
|
||||
|
||||
/**
|
||||
* helper utility that transforms raw BulkActionResponse response to DryRunResult format
|
||||
* @param response - raw bulk_actions API response ({@link BulkActionResponse})
|
||||
* @returns dry run result ({@link DryRunResult})
|
||||
*/
|
||||
export const processDryRunResult = (response: BulkActionResponse | undefined): DryRunResult => {
|
||||
const processed = {
|
||||
succeededRulesCount: response?.attributes.summary.succeeded,
|
||||
failedRulesCount: response?.attributes.summary.failed,
|
||||
ruleErrors:
|
||||
response?.attributes.errors?.map(({ message, err_code: errorCode, rules }) => ({
|
||||
message,
|
||||
errorCode,
|
||||
ruleIds: rules.map(({ id }) => id),
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
return processed;
|
||||
};
|
||||
|
||||
/**
|
||||
* transform rules export details {@link ExportRulesDetails} to dry run result format {@link DryRunResult}
|
||||
* @param details - {@link ExportRulesDetails} rules export details
|
||||
* @returns transformed to {@link DryRunResult}
|
||||
*/
|
||||
export const transformExportDetailsToDryRunResult = (details: ExportRulesDetails): DryRunResult => {
|
||||
return {
|
||||
succeededRulesCount: details.exported_count,
|
||||
failedRulesCount: details.missing_rules_count,
|
||||
// if there are rules that can't be exported, it means they are immutable. So we can safely put error code as immutable
|
||||
ruleErrors: details.missing_rules.length
|
||||
? [
|
||||
{
|
||||
errorCode: BulkActionsDryRunErrCode.IMMUTABLE,
|
||||
message: "Prebuilt rules can't be exported.",
|
||||
ruleIds: details.missing_rules.map(({ rule_id: ruleId }) => ruleId),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
};
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { DryRunResult } from '../use_bulk_actions_dry_run';
|
||||
import type { DryRunResult } from '../types';
|
||||
import type { FilterOptions } from '../../../../../../containers/detection_engine/rules/types';
|
||||
|
||||
import { convertRulesFilterToKQL } from '../../../../../../containers/detection_engine/rules/utils';
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { DryRunResult } from '../use_bulk_actions_dry_run';
|
||||
import type { DryRunResult } from '../types';
|
||||
import type { FilterOptions } from '../../../../../../containers/detection_engine/rules/types';
|
||||
|
||||
import { convertRulesFilterToKQL } from '../../../../../../containers/detection_engine/rules/utils';
|
||||
|
|
|
@ -14,7 +14,7 @@ import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts';
|
|||
import { canEditRuleWithActions } from '../../../../../common/utils/privileges';
|
||||
import type { Rule } from '../../../../containers/detection_engine/rules';
|
||||
import * as i18n from '../translations';
|
||||
import { executeRulesBulkAction, goToRuleEditPage } from './actions';
|
||||
import { executeRulesBulkAction, goToRuleEditPage, bulkExportRules } from './actions';
|
||||
import type { RulesTableActions } from './rules_table/rules_table_context';
|
||||
import type { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction';
|
||||
import { SINGLE_RULE_ACTIONS } from '../../../../../common/lib/apm/user_actions';
|
||||
|
@ -91,7 +91,7 @@ export const getRulesTableActions = ({
|
|||
name: i18n.EXPORT_RULE,
|
||||
onClick: async (rule: Rule) => {
|
||||
startTransaction({ name: SINGLE_RULE_ACTIONS.EXPORT });
|
||||
await executeRulesBulkAction({
|
||||
await bulkExportRules({
|
||||
action: BulkAction.export,
|
||||
setLoadingRules,
|
||||
visibleRuleIds: [rule.id],
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
|
||||
import {
|
||||
EuiBasicTable,
|
||||
EuiConfirmModal,
|
||||
|
@ -33,8 +35,9 @@ import { useAsyncConfirmation } from './rules_table/use_async_confirmation';
|
|||
import { RulesTableFilters } from './rules_table_filters/rules_table_filters';
|
||||
import { AllRulesUtilityBar } from './utility_bar';
|
||||
import { useBulkActionsDryRun } from './bulk_actions/use_bulk_actions_dry_run';
|
||||
import { useBulkActionsConfirmation } from './bulk_actions/use_bulk_actions_confirmation';
|
||||
import { useBulkEditFormFlyout } from './bulk_actions/use_bulk_edit_form_flyout';
|
||||
import { BulkEditDryRunConfirmation } from './bulk_actions/bulk_edit_dry_run_confirmation';
|
||||
import { BulkActionDryRunConfirmation } from './bulk_actions/bulk_action_dry_run_confirmation';
|
||||
import { BulkEditFlyout } from './bulk_actions/bulk_edit_flyout';
|
||||
import { useBulkActions } from './bulk_actions/use_bulk_actions';
|
||||
|
||||
|
@ -126,13 +129,14 @@ export const RulesTables = React.memo<RulesTableProps>(
|
|||
onFinish: hideDeleteConfirmation,
|
||||
});
|
||||
|
||||
const [isBulkEditConfirmationVisible, showBulkEditConfirmation, hideBulkEditConfirmation] =
|
||||
useBoolState();
|
||||
|
||||
const [confirmBulkEdit, handleBulkEditConfirm, handleBulkEditCancel] = useAsyncConfirmation({
|
||||
onInit: showBulkEditConfirmation,
|
||||
onFinish: hideBulkEditConfirmation,
|
||||
});
|
||||
const {
|
||||
bulkActionsDryRunResult,
|
||||
bulkAction,
|
||||
isBulkActionConfirmationVisible,
|
||||
showBulkActionConfirmation,
|
||||
cancelBulkActionConfirmation,
|
||||
approveBulkActionConfirmation,
|
||||
} = useBulkActionsConfirmation();
|
||||
|
||||
const {
|
||||
bulkEditActionType,
|
||||
|
@ -145,13 +149,12 @@ export const RulesTables = React.memo<RulesTableProps>(
|
|||
const selectedItemsCount = isAllSelected ? pagination.total : selectedRuleIds.length;
|
||||
const hasPagination = pagination.total > pagination.perPage;
|
||||
|
||||
const { bulkActionsDryRunResult, isBulkActionsDryRunLoading, executeBulkActionsDryRun } =
|
||||
useBulkActionsDryRun();
|
||||
const { isBulkActionsDryRunLoading, executeBulkActionsDryRun } = useBulkActionsDryRun();
|
||||
|
||||
const getBulkItemsPopoverContent = useBulkActions({
|
||||
filterOptions,
|
||||
confirmDeletion,
|
||||
confirmBulkEdit,
|
||||
showBulkActionConfirmation,
|
||||
completeBulkEditForm,
|
||||
reFetchTags,
|
||||
executeBulkActionsDryRun,
|
||||
|
@ -312,11 +315,12 @@ export const RulesTables = React.memo<RulesTableProps>(
|
|||
<p>{i18n.DELETE_CONFIRMATION_BODY}</p>
|
||||
</EuiConfirmModal>
|
||||
)}
|
||||
{isBulkEditConfirmationVisible && (
|
||||
<BulkEditDryRunConfirmation
|
||||
{isBulkActionConfirmationVisible && bulkAction && (
|
||||
<BulkActionDryRunConfirmation
|
||||
bulkAction={bulkAction}
|
||||
result={bulkActionsDryRunResult}
|
||||
onCancel={handleBulkEditCancel}
|
||||
onConfirm={handleBulkEditConfirm}
|
||||
onCancel={cancelBulkActionConfirmation}
|
||||
onConfirm={approveBulkActionConfirmation}
|
||||
/>
|
||||
)}
|
||||
{isBulkEditFlyoutVisible && bulkEditActionType !== undefined && (
|
||||
|
|
|
@ -210,7 +210,16 @@ export const BULK_EDIT_WARNING_TOAST_NOTIFY = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const BULK_EDIT_CONFIRMATION_DENIED_TITLE = (rulesCount: number) =>
|
||||
export const BULK_EXPORT_CONFIRMATION_REJECTED_TITLE = (rulesCount: number) =>
|
||||
i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkExportConfirmationDeniedTitle',
|
||||
{
|
||||
values: { rulesCount },
|
||||
defaultMessage: '{rulesCount, plural, =1 {# rule} other {# rules}} cannot be exported',
|
||||
}
|
||||
);
|
||||
|
||||
export const BULK_EDIT_CONFIRMATION_REJECTED_TITLE = (rulesCount: number) =>
|
||||
i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationDeniedTitle',
|
||||
{
|
||||
|
@ -219,9 +228,9 @@ export const BULK_EDIT_CONFIRMATION_DENIED_TITLE = (rulesCount: number) =>
|
|||
}
|
||||
);
|
||||
|
||||
export const BULK_EDIT_CONFIRMATION_PARTLY_TITLE = (customRulesCount: number) =>
|
||||
export const BULK_ACTION_CONFIRMATION_PARTLY_TITLE = (customRulesCount: number) =>
|
||||
i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationPartlyTitle',
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkActionConfirmationPartlyTitle',
|
||||
{
|
||||
values: { customRulesCount },
|
||||
defaultMessage:
|
||||
|
@ -236,8 +245,8 @@ export const BULK_EDIT_CONFIRMATION_CANCEL = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const BULK_EDIT_CONFIRMATION_CLOSE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditConfirmationCloseButtonLabel',
|
||||
export const BULK_ACTION_CONFIRMATION_CLOSE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkActionConfirmationCloseButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Close',
|
||||
}
|
||||
|
@ -252,6 +261,16 @@ export const BULK_EDIT_CONFIRMATION_CONFIRM = (customRulesCount: number) =>
|
|||
}
|
||||
);
|
||||
|
||||
export const BULK_EXPORT_CONFIRMATION_CONFIRM = (customRulesCount: number) =>
|
||||
i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkExportConfirmation.confirmButtonLabel',
|
||||
{
|
||||
values: { customRulesCount },
|
||||
defaultMessage:
|
||||
'Export {customRulesCount, plural, =1 {# Custom rule} other {# Custom rules}}',
|
||||
}
|
||||
);
|
||||
|
||||
export const BULK_EDIT_FLYOUT_FORM_SAVE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.saveButtonLabel',
|
||||
{
|
||||
|
@ -843,10 +862,17 @@ export const RULES_BULK_EXPORT_SUCCESS_DESCRIPTION = (exportedRules: number, tot
|
|||
{
|
||||
values: { totalRules, exportedRules },
|
||||
defaultMessage:
|
||||
'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}. Prebuilt rules were excluded from the resulting file.',
|
||||
'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}.',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULES_BULK_EXPORT_PREBUILT_RULES_EXCLUDED_DESCRIPTION = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.prebuiltRulesExcludedToastDescription',
|
||||
{
|
||||
defaultMessage: 'Prebuilt rules were excluded from the resulting file.',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULES_BULK_EXPORT_FAILURE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.errorToastTitle',
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue