[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:
parent
643912e4f5
commit
569b1f6606
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`JSONDownloader renders correctly against snapshot 1`] = `<styled.a />`;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}, []);
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RuleDownloader renders correctly against snapshot 1`] = `<styled.a />`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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…',
|
||||
}
|
||||
);
|
|
@ -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', {
|
||||
|
|
Loading…
Reference in a new issue