[SIEM] Use import/export API instead of client implementation (#54680)

## Summary

This PR switches the Rule Import / Export functionality away from the client-side implementation (that was leveraging the create/read Rule API) to the new explicit `/rules/_import` & `/rules/_export` API introduced in https://github.com/elastic/kibana/pull/54332.

Note: This PR also disables the ability to export `immutable` rules.

![image](https://user-images.githubusercontent.com/2946766/72311962-c0963680-3643-11ea-812f-237bc51be7dc.png)


Sample error message:

<img width="800" alt="Screen Shot 2020-01-13 at 20 22 45" src="https://user-images.githubusercontent.com/2946766/72311909-8cbb1100-3643-11ea-94ab-023a5ff56e20.png">


### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

- [X] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)
- [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~
- [X] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~

### For maintainers

- [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
- [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
This commit is contained in:
Garrett Spong 2020-01-14 09:25:07 -07:00 committed by GitHub
parent 643912e4f5
commit 569b1f6606
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 322 additions and 210 deletions

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiGlobalToastList, EuiGlobalToastListToast as Toast, EuiButton } from '@elastic/eui';
import { EuiButton, EuiGlobalToastList, EuiGlobalToastListToast as Toast } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { createContext, Dispatch, useReducer, useContext, useState } from 'react';
import React, { createContext, Dispatch, useContext, useReducer, useState } from 'react';
import styled from 'styled-components';
import uuid from 'uuid';
@ -143,7 +143,7 @@ export const displayErrorToast = (
errorTitle: string,
errorMessages: string[],
dispatchToaster: React.Dispatch<ActionToaster>
) => {
): void => {
const toast: AppToast = {
id: uuid.v4(),
title: errorTitle,
@ -156,3 +156,25 @@ export const displayErrorToast = (
toast,
});
};
/**
* Displays a success toast for the provided title and message
*
* @param title success message to display in toaster and modal
* @param dispatchToaster provided by useStateToaster()
*/
export const displaySuccessToast = (
title: string,
dispatchToaster: React.Dispatch<ActionToaster>
): void => {
const toast: AppToast = {
id: uuid.v4(),
title,
color: 'success',
iconType: 'check',
};
dispatchToaster({
type: 'addToaster',
toast,
});
};

View file

@ -16,13 +16,17 @@ import {
Rule,
FetchRuleProps,
BasicFetchProps,
ImportRulesProps,
ExportRulesProps,
RuleError,
ImportRulesResponse,
} from './types';
import { throwIfNotOk } from '../../../hooks/api/api';
import {
DETECTION_ENGINE_RULES_URL,
DETECTION_ENGINE_PREPACKAGED_URL,
} from '../../../../common/constants';
import * as i18n from '../../../pages/detection_engine/rules/translations';
/**
* Add provided Rule
@ -223,3 +227,78 @@ export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promi
await throwIfNotOk(response);
return true;
};
/**
* Imports rules in the same format as exported via the _export API
*
* @param fileToImport File to upload containing rules to import
* @param overwrite whether or not to overwrite rules with the same ruleId
* @param signal AbortSignal for cancelling request
*
* @throws An error if response is not OK
*/
export const importRules = async ({
fileToImport,
overwrite = false,
signal,
}: ImportRulesProps): Promise<ImportRulesResponse> => {
const formData = new FormData();
formData.append('file', fileToImport);
const response = await fetch(
`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_import?overwrite=${overwrite}`,
{
method: 'POST',
credentials: 'same-origin',
headers: {
'kbn-xsrf': 'true',
},
body: formData,
signal,
}
);
await throwIfNotOk(response);
return response.json();
};
/**
* Export rules from the server as a file download
*
* @param excludeExportDetails whether or not to exclude additional details at bottom of exported file (defaults to false)
* @param filename of exported rules. Be sure to include `.ndjson` extension! (defaults to localized `rules_export.ndjson`)
* @param ruleIds array of rule_id's (not id!) to export (empty array exports _all_ rules)
* @param signal AbortSignal for cancelling request
*
* @throws An error if response is not OK
*/
export const exportRules = async ({
excludeExportDetails = false,
filename = `${i18n.EXPORT_FILENAME}.ndjson`,
ruleIds = [],
signal,
}: ExportRulesProps): Promise<Blob> => {
const body =
ruleIds.length > 0
? JSON.stringify({ objects: ruleIds.map(rule => ({ rule_id: rule })) })
: undefined;
const response = await fetch(
`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_export?exclude_export_details=${excludeExportDetails}&file_name=${encodeURIComponent(
filename
)}`,
{
method: 'POST',
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
'kbn-xsrf': 'true',
},
body,
signal,
}
);
await throwIfNotOk(response);
return response.blob();
};

View file

@ -148,3 +148,30 @@ export interface DuplicateRulesProps {
export interface BasicFetchProps {
signal: AbortSignal;
}
export interface ImportRulesProps {
fileToImport: File;
overwrite?: boolean;
signal: AbortSignal;
}
export interface ImportRulesResponseError {
rule_id: string;
error: {
status_code: number;
message: string;
};
}
export interface ImportRulesResponse {
success: boolean;
success_count: number;
errors: ImportRulesResponseError[];
}
export interface ExportRulesProps {
ruleIds?: string[];
filename?: string;
excludeExportDetails?: boolean;
signal: AbortSignal;
}

View file

@ -51,7 +51,7 @@ export const getBatchItems = (
<EuiContextMenuItem
key={i18n.BATCH_ACTION_EXPORT_SELECTED}
icon="exportAction"
disabled={containsLoading || selectedState.length === 0}
disabled={containsImmutable || containsLoading || selectedState.length === 0}
onClick={async () => {
closePopover();
await exportRulesAction(

View file

@ -64,6 +64,7 @@ const getActions = (
icon: 'exportAction',
name: i18n.EXPORT_RULE,
onClick: (rowItem: TableData) => exportRulesAction([rowItem.sourceRule], dispatch),
enabled: (rowItem: TableData) => !rowItem.immutable,
},
{
description: i18n.DELETE_RULE,

View file

@ -31,7 +31,7 @@ import { getBatchItems } from './batch_actions';
import { EuiBasicTableOnChange, TableData } from '../types';
import { allRulesReducer, State } from './reducer';
import * as i18n from '../translations';
import { JSONDownloader } from '../components/json_downloader';
import { RuleDownloader } from '../components/rule_downloader';
import { useStateToaster } from '../../../../components/toasters';
const initialState: State = {
@ -150,9 +150,9 @@ export const AllRules = React.memo<{
return (
<>
<JSONDownloader
<RuleDownloader
filename={`${i18n.EXPORT_FILENAME}.ndjson`}
payload={exportPayload}
rules={exportPayload}
onExportComplete={exportCount => {
dispatchToaster({
type: 'addToaster',

View file

@ -20,7 +20,7 @@ export interface State {
filterOptions: FilterOptions;
refreshToggle: boolean;
tableData: TableData[];
exportPayload?: object[];
exportPayload?: Rule[];
}
export type Action =
@ -28,7 +28,7 @@ export type Action =
| { type: 'loading'; isLoading: boolean }
| { type: 'deleteRules'; rules: Rule[] }
| { type: 'duplicate'; rule: Rule }
| { type: 'setExportPayload'; exportPayload?: object[] }
| { type: 'setExportPayload'; exportPayload?: Rule[] }
| { type: 'setSelected'; selectedItems: TableData[] }
| { type: 'updateLoading'; ids: string[]; isLoading: boolean }
| { type: 'updateRules'; rules: Rule[]; appendRuleId?: string; pagination?: PaginationOptions }
@ -143,7 +143,7 @@ export const allRulesReducer = (state: State, action: Action): State => {
case 'setExportPayload': {
return {
...state,
exportPayload: action.exportPayload,
exportPayload: [...(action.exportPayload ?? [])],
};
}
default:

View file

@ -28,9 +28,8 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = `
display="large"
fullWidth={true}
id="rule-file-picker"
initialPromptText="Select or drag and drop files"
initialPromptText="Select or drag and drop a valid rules_export.ndjson file"
isLoading={false}
multiple={true}
onChange={[Function]}
/>
<EuiSpacer
@ -39,10 +38,10 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = `
<EuiCheckbox
checked={false}
compressed={false}
disabled={true}
disabled={false}
id="rule-overwrite-saved-object"
indeterminate={false}
label="Automatically overwrite saved objects with the same name"
label="Automatically overwrite saved objects with the same rule ID"
onChange={[Function]}
/>
</EuiModalBody>

View file

@ -8,28 +8,25 @@ import {
EuiButton,
EuiButtonEmpty,
EuiCheckbox,
// @ts-ignore no-exported-member
EuiFilePicker,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
// @ts-ignore no-exported-member
EuiFilePicker,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { useCallback, useState } from 'react';
import { failure } from 'io-ts/lib/PathReporter';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import uuid from 'uuid';
import { duplicateRules, RulesSchema } from '../../../../../containers/detection_engine/rules';
import { useStateToaster } from '../../../../../components/toasters';
import { ndjsonToJSON } from '../json_downloader';
import { importRules } from '../../../../../containers/detection_engine/rules';
import {
displayErrorToast,
displaySuccessToast,
useStateToaster,
} from '../../../../../components/toasters';
import * as i18n from './translations';
interface ImportRuleModalProps {
@ -40,10 +37,6 @@ interface ImportRuleModalProps {
/**
* Modal component for importing Rules from a json file
*
* @param filename name of file to be downloaded
* @param payload JSON string to write to file
*
*/
export const ImportRuleModalComponent = ({
showModal,
@ -52,6 +45,7 @@ export const ImportRuleModalComponent = ({
}: ImportRuleModalProps) => {
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [overwrite, setOverwrite] = useState(false);
const [, dispatchToaster] = useStateToaster();
const cleanupAndCloseModal = () => {
@ -60,49 +54,41 @@ export const ImportRuleModalComponent = ({
closeModal();
};
const importRules = useCallback(async () => {
const importRulesCallback = useCallback(async () => {
if (selectedFiles != null) {
setIsImporting(true);
const reader = new FileReader();
reader.onload = async event => {
// @ts-ignore type is string, not ArrayBuffer as FileReader.readAsText is called
const importedRules = ndjsonToJSON(event?.target?.result ?? '');
const abortCtrl = new AbortController();
const decodedRules = pipe(
RulesSchema.decode(importedRules),
fold(errors => {
cleanupAndCloseModal();
dispatchToaster({
type: 'addToaster',
toast: {
id: uuid.v4(),
title: i18n.IMPORT_FAILED,
color: 'danger',
iconType: 'alert',
errors: failure(errors),
},
});
throw new Error(failure(errors).join('\n'));
}, identity)
);
try {
const importResponse = await importRules({
fileToImport: selectedFiles[0],
overwrite,
signal: abortCtrl.signal,
});
// TODO: Improve error toast details for better debugging failed imports
// e.g. When success == true && success_count === 0 that means no rules were overwritten, etc
if (importResponse.success) {
displaySuccessToast(
i18n.SUCCESSFULLY_IMPORTED_RULES(importResponse.success_count),
dispatchToaster
);
}
if (importResponse.errors.length > 0) {
const formattedErrors = importResponse.errors.map(e =>
i18n.IMPORT_FAILED_DETAILED(e.rule_id, e.error.status_code, e.error.message)
);
displayErrorToast(i18n.IMPORT_FAILED, formattedErrors, dispatchToaster);
}
const duplicatedRules = await duplicateRules({ rules: decodedRules });
importComplete();
cleanupAndCloseModal();
dispatchToaster({
type: 'addToaster',
toast: {
id: uuid.v4(),
title: i18n.SUCCESSFULLY_IMPORTED_RULES(duplicatedRules.length),
color: 'success',
iconType: 'check',
},
});
};
Object.values(selectedFiles).map(f => reader.readAsText(f));
} catch (e) {
cleanupAndCloseModal();
displayErrorToast(i18n.IMPORT_FAILED, [e.message], dispatchToaster);
}
}
}, [selectedFiles]);
}, [selectedFiles, overwrite]);
return (
<>
@ -121,7 +107,6 @@ export const ImportRuleModalComponent = ({
<EuiSpacer size="s" />
<EuiFilePicker
id="rule-file-picker"
multiple
initialPromptText={i18n.INITIAL_PROMPT_TEXT}
onChange={(files: FileList) => {
setSelectedFiles(Object.keys(files).length > 0 ? files : null);
@ -134,14 +119,18 @@ export const ImportRuleModalComponent = ({
<EuiCheckbox
id="rule-overwrite-saved-object"
label={i18n.OVERWRITE_WITH_SAME_NAME}
disabled={true}
onChange={() => noop}
checked={overwrite}
onChange={() => setOverwrite(!overwrite)}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={closeModal}>{i18n.CANCEL_BUTTON}</EuiButtonEmpty>
<EuiButton onClick={importRules} disabled={selectedFiles == null || isImporting} fill>
<EuiButton
onClick={importRulesCallback}
disabled={selectedFiles == null || isImporting}
fill
>
{i18n.IMPORT_RULE}
</EuiButton>
</EuiModalFooter>

View file

@ -23,14 +23,14 @@ export const SELECT_RULE = i18n.translate(
export const INITIAL_PROMPT_TEXT = i18n.translate(
'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription',
{
defaultMessage: 'Select or drag and drop files',
defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file',
}
);
export const OVERWRITE_WITH_SAME_NAME = i18n.translate(
'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription',
{
defaultMessage: 'Automatically overwrite saved objects with the same name',
defaultMessage: 'Automatically overwrite saved objects with the same rule ID',
}
);
@ -57,3 +57,12 @@ export const IMPORT_FAILED = i18n.translate(
defaultMessage: 'Failed to import rules',
}
);
export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) =>
i18n.translate(
'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle',
{
values: { ruleId, statusCode, message },
defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}',
}
);

View file

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JSONDownloader renders correctly against snapshot 1`] = `<styled.a />`;

View file

@ -1,60 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { JSONDownloaderComponent, jsonToNDJSON, ndjsonToJSON } from './index';
const jsonArray = [
{
description: 'Detecting root and admin users1',
created_by: 'elastic',
false_positives: [],
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
max_signals: 100,
},
{
description: 'Detecting root and admin users2',
created_by: 'elastic',
false_positives: [],
index: ['auditbeat-*', 'packetbeat-*', 'winlogbeat-*'],
max_signals: 101,
},
];
const ndjson = `{"description":"Detecting root and admin users1","created_by":"elastic","false_positives":[],"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"max_signals":100}
{"description":"Detecting root and admin users2","created_by":"elastic","false_positives":[],"index":["auditbeat-*","packetbeat-*","winlogbeat-*"],"max_signals":101}`;
const ndjsonSorted = `{"created_by":"elastic","description":"Detecting root and admin users1","false_positives":[],"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"max_signals":100}
{"created_by":"elastic","description":"Detecting root and admin users2","false_positives":[],"index":["auditbeat-*","packetbeat-*","winlogbeat-*"],"max_signals":101}`;
describe('JSONDownloader', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<JSONDownloaderComponent filename={'export_rules.ndjson'} onExportComplete={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
});
describe('jsonToNDJSON', () => {
test('converts to NDJSON', () => {
const output = jsonToNDJSON(jsonArray, false);
expect(output).toEqual(ndjson);
});
test('converts to NDJSON with keys sorted', () => {
const output = jsonToNDJSON(jsonArray);
expect(output).toEqual(ndjsonSorted);
});
});
describe('ndjsonToJSON', () => {
test('converts to JSON', () => {
const output = ndjsonToJSON(ndjson);
expect(output).toEqual(jsonArray);
});
});
});

View file

@ -1,75 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useRef } from 'react';
import styled from 'styled-components';
const InvisibleAnchor = styled.a`
display: none;
`;
export interface JSONDownloaderProps {
filename: string;
payload?: object[];
onExportComplete: (exportCount: number) => void;
}
/**
* Component for downloading JSON as a file. Download will occur on each update to `payload` param
*
* @param filename name of file to be downloaded
* @param payload JSON string to write to file
*
*/
export const JSONDownloaderComponent = ({
filename,
payload,
onExportComplete,
}: JSONDownloaderProps) => {
const anchorRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
if (anchorRef && anchorRef.current && payload != null) {
const blob = new Blob([jsonToNDJSON(payload)], { type: 'application/json' });
// @ts-ignore function is not always defined -- this is for supporting IE
if (window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveBlob(blob);
} else {
const objectURL = window.URL.createObjectURL(blob);
anchorRef.current.href = objectURL;
anchorRef.current.download = filename;
anchorRef.current.click();
window.URL.revokeObjectURL(objectURL);
}
onExportComplete(payload.length);
}
}, [payload]);
return <InvisibleAnchor ref={anchorRef} />;
};
JSONDownloaderComponent.displayName = 'JSONDownloaderComponent';
export const JSONDownloader = React.memo(JSONDownloaderComponent);
JSONDownloader.displayName = 'JSONDownloader';
export const jsonToNDJSON = (jsonArray: object[], sortKeys = true): string => {
return jsonArray
.map(j => JSON.stringify(j, sortKeys ? Object.keys(j).sort() : null, 0))
.join('\n');
};
export const ndjsonToJSON = (ndjson: string): object[] => {
const jsonLines = ndjson.split(/\r?\n/);
return jsonLines.reduce<object[]>((acc, line) => {
try {
return [...acc, JSON.parse(line)];
} catch (e) {
return acc;
}
}, []);
};

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RuleDownloader renders correctly against snapshot 1`] = `<styled.a />`;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { RuleDownloaderComponent } from './index';
describe('RuleDownloader', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<RuleDownloaderComponent filename={'export_rules.ndjson'} onExportComplete={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { isFunction } from 'lodash/fp';
import { exportRules, Rule } from '../../../../../containers/detection_engine/rules';
import { displayErrorToast, useStateToaster } from '../../../../../components/toasters';
import * as i18n from './translations';
const InvisibleAnchor = styled.a`
display: none;
`;
export interface RuleDownloaderProps {
filename: string;
rules?: Rule[];
onExportComplete: (exportCount: number) => void;
}
/**
* Component for downloading Rules as an exported .ndjson file. Download will occur on each update to `rules` param
*
* @param filename of file to be downloaded
* @param payload Rule[]
*
*/
export const RuleDownloaderComponent = ({
filename,
rules,
onExportComplete,
}: RuleDownloaderProps) => {
const anchorRef = useRef<HTMLAnchorElement>(null);
const [, dispatchToaster] = useStateToaster();
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
async function exportData() {
if (anchorRef && anchorRef.current && rules != null) {
try {
const exportResponse = await exportRules({
ruleIds: rules.map(r => r.rule_id),
signal: abortCtrl.signal,
});
if (isSubscribed) {
// this is for supporting IE
if (isFunction(window.navigator.msSaveOrOpenBlob)) {
window.navigator.msSaveBlob(exportResponse);
} else {
const objectURL = window.URL.createObjectURL(exportResponse);
// These are safe-assignments as writes to anchorRef are isolated to exportData
anchorRef.current.href = objectURL; // eslint-disable-line require-atomic-updates
anchorRef.current.download = filename; // eslint-disable-line require-atomic-updates
anchorRef.current.click();
window.URL.revokeObjectURL(objectURL);
}
onExportComplete(rules.length);
}
} catch (error) {
if (isSubscribed) {
displayErrorToast(i18n.EXPORT_FAILURE, [error.message], dispatchToaster);
}
}
}
}
exportData();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [rules]);
return <InvisibleAnchor ref={anchorRef} />;
};
RuleDownloaderComponent.displayName = 'RuleDownloaderComponent';
export const RuleDownloader = React.memo(RuleDownloaderComponent);
RuleDownloader.displayName = 'RuleDownloader';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const EXPORT_FAILURE = i18n.translate(
'xpack.siem.detectionEngine.rules.components.ruleDownloader.exportFailureTitle',
{
defaultMessage: 'Failed to export rules…',
}
);

View file

@ -123,7 +123,7 @@ export const SUCCESSFULLY_EXPORTED_RULES = (totalRules: number) =>
i18n.translate('xpack.siem.detectionEngine.rules.allRules.successfullyExportedRulesTitle', {
values: { totalRules },
defaultMessage:
'Successfully exported {totalRules} {totalRules, plural, =1 {rule} other {rules}}',
'Successfully exported {totalRules, plural, =0 {all rules} =1 {{totalRules} rule} other {{totalRules} rules}}',
});
export const ALL_RULES = i18n.translate('xpack.siem.detectionEngine.rules.allRules.tableTitle', {