diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar.test.tsx.snap
index 1f892acef7ef3..03a04983f9f86 100644
--- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar.test.tsx.snap
+++ b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/__snapshots__/utility_bar.test.tsx.snap
@@ -12,11 +12,7 @@ exports[`UtilityBar it renders 1`] = `
- Test popover
-
- }
+ popoverContent={[Function]}
>
Test action
diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.test.tsx b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.test.tsx
index 0ae247f5c9dd0..27688ec24530e 100644
--- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar.test.tsx
@@ -32,7 +32,7 @@ describe('UtilityBar', () => {
- {'Test popover'}}>
+ {'Test popover'}
}>
{'Test action'}
@@ -60,7 +60,7 @@ describe('UtilityBar', () => {
- {'Test popover'}}>
+ {'Test popover'}
}>
{'Test action'}
@@ -90,7 +90,7 @@ describe('UtilityBar', () => {
- {'Test popover'}}>
+ {'Test popover'}
}>
{'Test action'}
diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.test.tsx b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.test.tsx
index 74eed8cfabf2d..f71bdfda705d0 100644
--- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.test.tsx
@@ -28,7 +28,7 @@ describe('UtilityBarAction', () => {
test('it renders a popover', () => {
const wrapper = mount(
- {'Test popover'}}>
+ {'Test popover'}
}>
{'Test action'}
diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.tsx b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.tsx
index ae4362bdbcd7b..2ad48bc9b9c92 100644
--- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_action.tsx
@@ -5,7 +5,7 @@
*/
import { EuiPopover } from '@elastic/eui';
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
import { LinkIcon, LinkIconProps } from '../../link_icon';
import { BarAction } from './styles';
@@ -14,6 +14,8 @@ const Popover = React.memo(
({ children, color, iconSide, iconSize, iconType, popoverContent }) => {
const [popoverState, setPopoverState] = useState(false);
+ const closePopover = useCallback(() => setPopoverState(false), [setPopoverState]);
+
return (
(
closePopover={() => setPopoverState(false)}
isOpen={popoverState}
>
- {popoverContent}
+ {popoverContent?.(closePopover)}
);
}
@@ -38,7 +40,7 @@ const Popover = React.memo(
Popover.displayName = 'Popover';
export interface UtilityBarActionProps extends LinkIconProps {
- popoverContent?: React.ReactNode;
+ popoverContent?: (closePopover: () => void) => React.ReactNode;
}
export const UtilityBarAction = React.memo(
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts
new file mode 100644
index 0000000000000..333baefe034fd
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts
@@ -0,0 +1,172 @@
+/*
+ * 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 chrome from 'ui/chrome';
+import {
+ DeleteRulesProps,
+ DuplicateRulesProps,
+ EnableRulesProps,
+ FetchRulesProps,
+ FetchRulesResponse,
+ Rule,
+} from './types';
+import { throwIfNotOk } from '../../../hooks/api/api';
+
+/**
+ * Fetches all rules or single specified rule from the Detection Engine API
+ *
+ * @param filterOptions desired filters (e.g. filter/sortField/sortOrder)
+ * @param pagination desired pagination options (e.g. page/perPage)
+ * @param id if specified, will return specific rule if exists
+ * @param kbnVersion current Kibana Version to use for headers
+ */
+export const fetchRules = async ({
+ filterOptions = {
+ filter: '',
+ sortField: 'enabled',
+ sortOrder: 'desc',
+ },
+ pagination = {
+ page: 1,
+ perPage: 20,
+ total: 0,
+ },
+ id,
+ kbnVersion,
+ signal,
+}: FetchRulesProps): Promise => {
+ const queryParams = [
+ `page=${pagination.page}`,
+ `per_page=${pagination.perPage}`,
+ `sort_field=${filterOptions.sortField}`,
+ `sort_order=${filterOptions.sortOrder}`,
+ ...(filterOptions.filter.length !== 0
+ ? [`filter=alert.attributes.name:%20${encodeURIComponent(filterOptions.filter)}`]
+ : []),
+ ];
+
+ const endpoint =
+ id != null
+ ? `${chrome.getBasePath()}/api/detection_engine/rules?id="${id}"`
+ : `${chrome.getBasePath()}/api/detection_engine/rules/_find?${queryParams.join('&')}`;
+
+ const response = await fetch(endpoint, {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: {
+ 'content-type': 'application/json',
+ 'kbn-version': kbnVersion,
+ 'kbn-xsrf': kbnVersion,
+ },
+ signal,
+ });
+ await throwIfNotOk(response);
+ return id != null
+ ? {
+ page: 0,
+ perPage: 1,
+ total: 1,
+ data: response.json(),
+ }
+ : response.json();
+};
+
+/**
+ * Enables/Disables provided Rule ID's
+ *
+ * @param ids array of Rule ID's (not rule_id) to enable/disable
+ * @param enabled to enable or disable
+ * @param kbnVersion current Kibana Version to use for headers
+ */
+export const enableRules = async ({
+ ids,
+ enabled,
+ kbnVersion,
+}: EnableRulesProps): Promise => {
+ const requests = ids.map(id =>
+ fetch(`${chrome.getBasePath()}/api/detection_engine/rules`, {
+ method: 'PUT',
+ credentials: 'same-origin',
+ headers: {
+ 'content-type': 'application/json',
+ 'kbn-version': kbnVersion,
+ 'kbn-xsrf': kbnVersion,
+ },
+ body: JSON.stringify({ id, enabled }),
+ })
+ );
+
+ const responses = await Promise.all(requests);
+ await responses.map(response => throwIfNotOk(response));
+ return Promise.all(
+ responses.map>(response => response.json())
+ );
+};
+
+/**
+ * Deletes provided Rule ID's
+ *
+ * @param ids array of Rule ID's (not rule_id) to delete
+ * @param kbnVersion current Kibana Version to use for headers
+ */
+export const deleteRules = async ({ ids, kbnVersion }: DeleteRulesProps): Promise => {
+ // TODO: Don't delete if immutable!
+ const requests = ids.map(id =>
+ fetch(`${chrome.getBasePath()}/api/detection_engine/rules?id=${id}`, {
+ method: 'DELETE',
+ credentials: 'same-origin',
+ headers: {
+ 'content-type': 'application/json',
+ 'kbn-version': kbnVersion,
+ 'kbn-xsrf': kbnVersion,
+ },
+ })
+ );
+
+ const responses = await Promise.all(requests);
+ await responses.map(response => throwIfNotOk(response));
+ return Promise.all(
+ responses.map>(response => response.json())
+ );
+};
+
+/**
+ * Duplicates provided Rules
+ *
+ * @param rule to duplicate
+ * @param kbnVersion current Kibana Version to use for headers
+ */
+export const duplicateRules = async ({
+ rules,
+ kbnVersion,
+}: DuplicateRulesProps): Promise => {
+ const requests = rules.map(rule =>
+ fetch(`${chrome.getBasePath()}/api/detection_engine/rules`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'content-type': 'application/json',
+ 'kbn-version': kbnVersion,
+ 'kbn-xsrf': kbnVersion,
+ },
+ body: JSON.stringify({
+ ...rule,
+ name: `${rule.name} [Duplicate]`,
+ created_by: undefined,
+ id: undefined,
+ rule_id: undefined,
+ updated_by: undefined,
+ enabled: rule.enabled,
+ }),
+ })
+ );
+
+ const responses = await Promise.all(requests);
+ await responses.map(response => throwIfNotOk(response));
+ return Promise.all(
+ responses.map>(response => response.json())
+ );
+};
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/translations.ts
new file mode 100644
index 0000000000000..a1ea2afb822f9
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/translations.ts
@@ -0,0 +1,11 @@
+/*
+ * 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 RULE_FETCH_FAILURE = i18n.translate('xpack.siem.containers.detectionEngine.rules', {
+ defaultMessage: 'Failed to fetch Rules',
+});
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts
new file mode 100644
index 0000000000000..afb0158fea677
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts
@@ -0,0 +1,81 @@
+/*
+ * 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 * as t from 'io-ts';
+
+export const RuleSchema = t.intersection([
+ t.type({
+ created_by: t.string,
+ description: t.string,
+ enabled: t.boolean,
+ id: t.string,
+ index: t.array(t.string),
+ interval: t.string,
+ language: t.string,
+ name: t.string,
+ query: t.string,
+ rule_id: t.string,
+ severity: t.string,
+ type: t.string,
+ updated_by: t.string,
+ }),
+ t.partial({
+ false_positives: t.array(t.string),
+ from: t.string,
+ max_signals: t.number,
+ references: t.array(t.string),
+ tags: t.array(t.string),
+ to: t.string,
+ }),
+]);
+
+export const RulesSchema = t.array(RuleSchema);
+
+export type Rule = t.TypeOf;
+export type Rules = t.TypeOf;
+
+export interface PaginationOptions {
+ page: number;
+ perPage: number;
+ total: number;
+}
+
+export interface FetchRulesProps {
+ pagination?: PaginationOptions;
+ filterOptions?: FilterOptions;
+ id?: string;
+ kbnVersion: string;
+ signal: AbortSignal;
+}
+
+export interface FilterOptions {
+ filter: string;
+ sortField: string;
+ sortOrder: 'asc' | 'desc';
+}
+
+export interface FetchRulesResponse {
+ page: number;
+ perPage: number;
+ total: number;
+ data: Rule[];
+}
+
+export interface EnableRulesProps {
+ ids: string[];
+ enabled: boolean;
+ kbnVersion: string;
+}
+
+export interface DeleteRulesProps {
+ ids: string[];
+ kbnVersion: string;
+}
+
+export interface DuplicateRulesProps {
+ rules: Rules;
+ kbnVersion: string;
+}
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.tsx
new file mode 100644
index 0000000000000..2b8bb986a296a
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.tsx
@@ -0,0 +1,83 @@
+/*
+ * 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 { useEffect, useState } from 'react';
+
+import { useKibanaUiSetting } from '../../../lib/settings/use_kibana_ui_setting';
+import { DEFAULT_KBN_VERSION } from '../../../../common/constants';
+import { FetchRulesResponse, FilterOptions, PaginationOptions } from './types';
+import { useStateToaster } from '../../../components/toasters';
+import { fetchRules } from './api';
+import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
+import * as i18n from './translations';
+
+type Return = [boolean, FetchRulesResponse];
+
+/**
+ * Hook for using the list of Rules from the Detection Engine API
+ *
+ * @param pagination desired pagination options (e.g. page/perPage)
+ * @param filterOptions desired filters (e.g. filter/sortField/sortOrder)
+ * @param refetchToggle toggle for refetching data
+ */
+export const useRules = (
+ pagination: PaginationOptions,
+ filterOptions: FilterOptions,
+ refetchToggle: boolean
+): Return => {
+ const [rules, setRules] = useState({
+ page: 1,
+ perPage: 20,
+ total: 0,
+ data: [],
+ });
+ const [loading, setLoading] = useState(true);
+ const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION);
+ const [, dispatchToaster] = useStateToaster();
+
+ useEffect(() => {
+ let isSubscribed = true;
+ const abortCtrl = new AbortController();
+ setLoading(true);
+
+ async function fetchData() {
+ try {
+ const fetchRulesResult = await fetchRules({
+ filterOptions,
+ pagination,
+ kbnVersion,
+ signal: abortCtrl.signal,
+ });
+
+ if (isSubscribed) {
+ setRules(fetchRulesResult);
+ }
+ } catch (error) {
+ if (isSubscribed) {
+ errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster });
+ }
+ }
+ if (isSubscribed) {
+ setLoading(false);
+ }
+ }
+
+ fetchData();
+ return () => {
+ isSubscribed = false;
+ abortCtrl.abort();
+ };
+ }, [
+ refetchToggle,
+ pagination.page,
+ pagination.perPage,
+ filterOptions.filter,
+ filterOptions.sortField,
+ filterOptions.sortOrder,
+ ]);
+
+ return [loading, rules];
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx
index 9b63a6e160e42..f02e80ebfaf66 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx
@@ -48,7 +48,7 @@ const OpenSignals = React.memo(() => {
{'Batch actions context menu here.'}}
+ popoverContent={() => {'Batch actions context menu here.'}
}
>
{'Batch actions'}
@@ -70,7 +70,7 @@ const OpenSignals = React.memo(() => {
{'Customize columns context menu here.'}}
+ popoverContent={() => {'Customize columns context menu here.'}
}
>
{'Customize columns'}
@@ -100,7 +100,7 @@ const ClosedSignals = React.memo(() => {
{'Customize columns context menu here.'}}
+ popoverContent={() => {'Customize columns context menu here.'}
}
>
{'Customize columns'}
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rule_details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rule_details/index.tsx
index da3e5fb2083dd..b16036e3142fc 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rule_details/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rule_details/index.tsx
@@ -66,7 +66,7 @@ const OpenSignals = React.memo(() => {
{'Batch actions context menu here.'}}
+ popoverContent={() => {'Batch actions context menu here.'}
}
>
{'Batch actions'}
@@ -88,7 +88,7 @@ const OpenSignals = React.memo(() => {
{'Customize columns context menu here.'}}
+ popoverContent={() => {'Customize columns context menu here.'}
}
>
{'Customize columns'}
@@ -118,7 +118,7 @@ const ClosedSignals = React.memo(() => {
{'Customize columns context menu here.'}}
+ popoverContent={() => {'Customize columns context menu here.'}
}
>
{'Customize columns'}
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/activity_monitor/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/activity_monitor/columns.tsx
new file mode 100644
index 0000000000000..58e2b9f0cabc7
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/activity_monitor/columns.tsx
@@ -0,0 +1,87 @@
+/*
+ * 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 { EuiIconTip, EuiLink, EuiTextColor } from '@elastic/eui';
+import React from 'react';
+import { getEmptyTagValue } from '../../../../components/empty_value';
+import { ColumnTypes } from './index';
+
+const actions = [
+ {
+ available: (item: ColumnTypes) => item.status === 'Running',
+ description: 'Stop',
+ icon: 'stop',
+ isPrimary: true,
+ name: 'Stop',
+ onClick: () => {},
+ type: 'icon',
+ },
+ {
+ available: (item: ColumnTypes) => item.status === 'Stopped',
+ description: 'Resume',
+ icon: 'play',
+ isPrimary: true,
+ name: 'Resume',
+ onClick: () => {},
+ type: 'icon',
+ },
+];
+
+// Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes?
+export const columns = [
+ {
+ field: 'rule',
+ name: 'Rule',
+ render: (value: ColumnTypes['rule']) => {value.name},
+ sortable: true,
+ truncateText: true,
+ },
+ {
+ field: 'ran',
+ name: 'Ran',
+ render: (value: ColumnTypes['ran']) => '--',
+ sortable: true,
+ truncateText: true,
+ },
+ {
+ field: 'lookedBackTo',
+ name: 'Looked back to',
+ render: (value: ColumnTypes['lookedBackTo']) => '--',
+ sortable: true,
+ truncateText: true,
+ },
+ {
+ field: 'status',
+ name: 'Status',
+ sortable: true,
+ truncateText: true,
+ },
+ {
+ field: 'response',
+ name: 'Response',
+ render: (value: ColumnTypes['response']) => {
+ return value === undefined ? (
+ getEmptyTagValue()
+ ) : (
+ <>
+ {value === 'Fail' ? (
+
+ {value}
+
+ ) : (
+ {value}
+ )}
+ >
+ );
+ },
+ sortable: true,
+ truncateText: true,
+ },
+ {
+ actions,
+ width: '40px',
+ },
+];
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/activity_monitor/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/activity_monitor/index.tsx
new file mode 100644
index 0000000000000..d7306b8630bc2
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/activity_monitor/index.tsx
@@ -0,0 +1,337 @@
+/*
+ * 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 { EuiBasicTable, EuiPanel, EuiSpacer } from '@elastic/eui';
+import React, { useState } from 'react';
+import { HeaderSection } from '../../../../components/header_section';
+import {
+ UtilityBar,
+ UtilityBarAction,
+ UtilityBarGroup,
+ UtilityBarSection,
+ UtilityBarText,
+} from '../../../../components/detection_engine/utility_bar';
+import { columns } from './columns';
+
+export interface RuleTypes {
+ href: string;
+ name: string;
+}
+
+export interface ColumnTypes {
+ id: number;
+ rule: RuleTypes;
+ ran: string;
+ lookedBackTo: string;
+ status: string;
+ response: string | undefined;
+}
+
+export interface PageTypes {
+ index: number;
+ size: number;
+}
+
+export interface SortTypes {
+ field: string;
+ direction: string;
+}
+
+export const ActivityMonitor = React.memo(() => {
+ const sampleTableData = [
+ {
+ id: 1,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Running',
+ },
+ {
+ id: 2,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Stopped',
+ },
+ {
+ id: 3,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Fail',
+ },
+ {
+ id: 4,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 5,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 6,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 7,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 8,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 9,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 10,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 11,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 12,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 13,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 14,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 15,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 16,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 17,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 18,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 19,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 20,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ {
+ id: 21,
+ rule: {
+ href: '#/detection-engine/rules/rule-details',
+ name: 'Automated exfiltration',
+ },
+ ran: '2019-12-28 00:00:00.000-05:00',
+ lookedBackTo: '2019-12-28 00:00:00.000-05:00',
+ status: 'Completed',
+ response: 'Success',
+ },
+ ];
+
+ const [itemsTotalState] = useState(sampleTableData.length);
+ const [pageState, setPageState] = useState({ index: 0, size: 20 });
+ // const [selectedState, setSelectedState] = useState([]);
+ const [sortState, setSortState] = useState({ field: 'ran', direction: 'desc' });
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {'Showing: 39 activites'}
+
+
+
+ {'Selected: 2 activities'}
+
+ {'Stop selected'}
+
+
+
+ {'Clear 7 filters'}
+
+
+
+
+ {
+ setPageState(page);
+ setSortState(sort);
+ }}
+ pagination={{
+ pageIndex: pageState.index,
+ pageSize: pageState.size,
+ totalItemCount: itemsTotalState,
+ pageSizeOptions: [5, 10, 20],
+ }}
+ selection={{
+ selectable: (item: ColumnTypes) => item.status !== 'Completed',
+ selectableMessage: (selectable: boolean) =>
+ selectable ? undefined : 'Completed runs cannot be acted upon',
+ onSelectionChange: (selectedItems: ColumnTypes[]) => {
+ // setSelectedState(selectedItems);
+ },
+ }}
+ sorting={{
+ sort: sortState,
+ }}
+ />
+
+ >
+ );
+});
+ActivityMonitor.displayName = 'ActivityMonitor';
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/actions.tsx
new file mode 100644
index 0000000000000..a54296f65a382
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/actions.tsx
@@ -0,0 +1,59 @@
+/*
+ * 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 from 'react';
+import {
+ deleteRules,
+ duplicateRules,
+ enableRules,
+} from '../../../../containers/detection_engine/rules/api';
+import { Action } from './reducer';
+import { Rule } from '../../../../containers/detection_engine/rules/types';
+
+export const editRuleAction = () => {};
+
+export const runRuleAction = () => {};
+
+export const duplicateRuleAction = async (
+ rule: Rule,
+ dispatch: React.Dispatch,
+ kbnVersion: string
+) => {
+ dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true });
+ const duplicatedRule = await duplicateRules({ rules: [rule], kbnVersion });
+ dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false });
+ dispatch({ type: 'updateRules', rules: duplicatedRule });
+};
+
+export const exportRulesAction = async (rules: Rule[], dispatch: React.Dispatch) => {
+ dispatch({ type: 'setExportPayload', exportPayload: rules });
+};
+
+export const deleteRulesAction = async (
+ ids: string[],
+ dispatch: React.Dispatch,
+ kbnVersion: string
+) => {
+ dispatch({ type: 'updateLoading', ids, isLoading: true });
+ const deletedRules = await deleteRules({ ids, kbnVersion });
+ dispatch({ type: 'deleteRules', rules: deletedRules });
+};
+
+export const enableRulesAction = async (
+ ids: string[],
+ enabled: boolean,
+ dispatch: React.Dispatch,
+ kbnVersion: string
+) => {
+ try {
+ dispatch({ type: 'updateLoading', ids, isLoading: true });
+ const updatedRules = await enableRules({ ids, enabled, kbnVersion });
+ dispatch({ type: 'updateRules', rules: updatedRules });
+ } catch {
+ // TODO Add error toast support to actions (and @throw jsdoc to api calls)
+ dispatch({ type: 'updateLoading', ids, isLoading: false });
+ }
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/batch_actions.tsx
new file mode 100644
index 0000000000000..c8fb9d98fde6a
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/batch_actions.tsx
@@ -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 { EuiContextMenuItem } from '@elastic/eui';
+import React from 'react';
+import * as i18n from '../translations';
+import { TableData } from '../types';
+import { Action } from './reducer';
+import { deleteRulesAction, enableRulesAction, exportRulesAction } from './actions';
+
+export const getBatchItems = (
+ selectedState: TableData[],
+ dispatch: React.Dispatch,
+ closePopover: () => void,
+ kbnVersion: string
+) => {
+ const containsEnabled = selectedState.some(v => v.activate);
+ const containsDisabled = selectedState.some(v => !v.activate);
+ const containsLoading = selectedState.some(v => v.isLoading);
+
+ return [
+ {
+ closePopover();
+ const deactivatedIds = selectedState.filter(s => !s.activate).map(s => s.id);
+ await enableRulesAction(deactivatedIds, true, dispatch, kbnVersion);
+ }}
+ >
+ {i18n.BATCH_ACTION_ACTIVATE_SELECTED}
+ ,
+ {
+ closePopover();
+ const activatedIds = selectedState.filter(s => s.activate).map(s => s.id);
+ await enableRulesAction(activatedIds, false, dispatch, kbnVersion);
+ }}
+ >
+ {i18n.BATCH_ACTION_DEACTIVATE_SELECTED}
+ ,
+ {
+ closePopover();
+ await exportRulesAction(
+ selectedState.map(s => s.sourceRule),
+ dispatch
+ );
+ }}
+ >
+ {i18n.BATCH_ACTION_EXPORT_SELECTED}
+ ,
+ {
+ closePopover();
+ }}
+ >
+ {i18n.BATCH_ACTION_EDIT_INDEX_PATTERNS}
+ ,
+ {
+ closePopover();
+ await deleteRulesAction(
+ selectedState.map(({ sourceRule: { id } }) => id),
+ dispatch,
+ kbnVersion
+ );
+ }}
+ >
+ {i18n.BATCH_ACTION_DELETE_SELECTED}
+ ,
+ ];
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/columns.tsx
new file mode 100644
index 0000000000000..cae0fb3eaf906
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/columns.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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 { EuiBadge, EuiHealth, EuiIconTip, EuiLink, EuiTextColor } from '@elastic/eui';
+import React from 'react';
+import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
+import { getEmptyTagValue } from '../../../../components/empty_value';
+import {
+ deleteRulesAction,
+ duplicateRuleAction,
+ editRuleAction,
+ enableRulesAction,
+ exportRulesAction,
+ runRuleAction,
+} from './actions';
+
+import { Action } from './reducer';
+import { TableData } from '../types';
+import * as i18n from '../translations';
+import { PreferenceFormattedDate } from '../../../../components/formatted_date';
+import { RuleSwitch } from '../components/rule_switch';
+
+const getActions = (dispatch: React.Dispatch, kbnVersion: string) => [
+ {
+ description: i18n.EDIT_RULE_SETTINGS,
+ icon: 'visControls',
+ name: i18n.EDIT_RULE_SETTINGS,
+ onClick: editRuleAction,
+ enabled: () => false,
+ },
+ {
+ description: i18n.RUN_RULE_MANUALLY,
+ icon: 'play',
+ name: i18n.RUN_RULE_MANUALLY,
+ onClick: runRuleAction,
+ enabled: () => false,
+ },
+ {
+ description: i18n.DUPLICATE_RULE,
+ icon: 'copy',
+ name: i18n.DUPLICATE_RULE,
+ onClick: (rowItem: TableData) => duplicateRuleAction(rowItem.sourceRule, dispatch, kbnVersion),
+ },
+ {
+ description: i18n.EXPORT_RULE,
+ icon: 'exportAction',
+ name: i18n.EXPORT_RULE,
+ onClick: (rowItem: TableData) => exportRulesAction([rowItem.sourceRule], dispatch),
+ },
+ {
+ description: i18n.DELETE_RULE,
+ icon: 'trash',
+ name: i18n.DELETE_RULE,
+ onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch, kbnVersion),
+ },
+];
+
+// Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes?
+export const getColumns = (dispatch: React.Dispatch, kbnVersion: string) => [
+ {
+ field: 'rule',
+ name: i18n.COLUMN_RULE,
+ render: (value: TableData['rule']) => {value.name},
+ truncateText: true,
+ width: '24%',
+ },
+ {
+ field: 'method',
+ name: i18n.COLUMN_METHOD,
+ truncateText: true,
+ },
+ {
+ field: 'severity',
+ name: i18n.COLUMN_SEVERITY,
+ render: (value: TableData['severity']) => (
+
+ {value}
+
+ ),
+ truncateText: true,
+ },
+ {
+ field: 'lastCompletedRun',
+ name: i18n.COLUMN_LAST_COMPLETE_RUN,
+ render: (value: TableData['lastCompletedRun']) => {
+ return value == null ? (
+ getEmptyTagValue()
+ ) : (
+
+ );
+ },
+ sortable: true,
+ truncateText: true,
+ width: '16%',
+ },
+ {
+ field: 'lastResponse',
+ name: i18n.COLUMN_LAST_RESPONSE,
+ render: (value: TableData['lastResponse']) => {
+ return value == null ? (
+ getEmptyTagValue()
+ ) : (
+ <>
+ {value.type === 'Fail' ? (
+
+ {value.type}
+
+ ) : (
+ {value.type}
+ )}
+ >
+ );
+ },
+ truncateText: true,
+ },
+ {
+ field: 'tags',
+ name: i18n.COLUMN_TAGS,
+ render: (value: TableData['tags']) => (
+
+ <>
+ {value.map((tag, i) => (
+
+ {tag}
+
+ ))}
+ >
+
+ ),
+ truncateText: true,
+ width: '20%',
+ },
+ {
+ align: 'center',
+ field: 'activate',
+ name: i18n.COLUMN_ACTIVATE,
+ render: (value: TableData['activate'], item: TableData) => (
+ {
+ await enableRulesAction([id], enabled, dispatch, kbnVersion);
+ }}
+ />
+ ),
+ sortable: true,
+ width: '85px',
+ },
+ {
+ actions: getActions(dispatch, kbnVersion),
+ width: '40px',
+ },
+];
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/helpers.ts
new file mode 100644
index 0000000000000..db02d41771f68
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/helpers.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { Rule } from '../../../../containers/detection_engine/rules/types';
+import { TableData } from '../types';
+import { getEmptyValue } from '../../../../components/empty_value';
+
+export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] =>
+ rules.map(rule => ({
+ id: rule.id,
+ rule_id: rule.rule_id,
+ rule: {
+ href: `#/detection-engine/rules/rule-details/${encodeURIComponent(rule.id)}`,
+ name: rule.name,
+ status: 'Status Placeholder',
+ },
+ method: rule.type, // TODO: Map to i18n?
+ severity: rule.severity,
+ lastCompletedRun: undefined, // TODO: Not available yet
+ lastResponse: {
+ type: getEmptyValue(), // TODO: Not available yet
+ },
+ tags: rule.tags ?? [],
+ activate: rule.enabled,
+ sourceRule: rule,
+ isLoading: selectedIds?.includes(rule.id) ?? false,
+ }));
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/index.tsx
new file mode 100644
index 0000000000000..a73ebeb61db3c
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/index.tsx
@@ -0,0 +1,226 @@
+/*
+ * 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 {
+ EuiBasicTable,
+ EuiContextMenuPanel,
+ EuiFieldSearch,
+ EuiLoadingContent,
+ EuiSpacer,
+} from '@elastic/eui';
+import React, { useCallback, useEffect, useReducer, useState } from 'react';
+
+import uuid from 'uuid';
+import { HeaderSection } from '../../../../components/header_section';
+import {
+ UtilityBar,
+ UtilityBarAction,
+ UtilityBarGroup,
+ UtilityBarSection,
+ UtilityBarText,
+} from '../../../../components/detection_engine/utility_bar';
+import { getColumns } from './columns';
+import { useRules } from '../../../../containers/detection_engine/rules/use_rules';
+import { Loader } from '../../../../components/loader';
+import { Panel } from '../../../../components/panel';
+import { getBatchItems } from './batch_actions';
+import { EuiBasicTableOnChange, TableData } from '../types';
+import { allRulesReducer, State } from './reducer';
+import * as i18n from '../translations';
+import { useKibanaUiSetting } from '../../../../lib/settings/use_kibana_ui_setting';
+import { DEFAULT_KBN_VERSION } from '../../../../../common/constants';
+import { JSONDownloader } from '../components/json_downloader';
+import { useStateToaster } from '../../../../components/toasters';
+
+const initialState: State = {
+ isLoading: true,
+ rules: [],
+ tableData: [],
+ selectedItems: [],
+ refreshToggle: true,
+ pagination: {
+ page: 1,
+ perPage: 20,
+ total: 0,
+ },
+ filterOptions: {
+ filter: '',
+ sortField: 'enabled',
+ sortOrder: 'desc',
+ },
+};
+
+/**
+ * Table Component for displaying all Rules for a given cluster. Provides the ability to filter
+ * by name, sort by enabled, and perform the following actions:
+ * * Enable/Disable
+ * * Duplicate
+ * * Delete
+ * * Import/Export
+ */
+export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importCompleteToggle => {
+ const [
+ {
+ exportPayload,
+ filterOptions,
+ isLoading,
+ refreshToggle,
+ selectedItems,
+ tableData,
+ pagination,
+ },
+ dispatch,
+ ] = useReducer(allRulesReducer, initialState);
+
+ const [isInitialLoad, setIsInitialLoad] = useState(true);
+ const [isLoadingRules, rulesData] = useRules(pagination, filterOptions, refreshToggle);
+ const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION);
+ const [, dispatchToaster] = useStateToaster();
+
+ const getBatchItemsPopoverContent = useCallback(
+ (closePopover: () => void) => (
+
+ ),
+ [selectedItems, dispatch, kbnVersion]
+ );
+
+ useEffect(() => {
+ dispatch({ type: 'loading', isLoading: isLoadingRules });
+
+ if (!isLoadingRules) {
+ setIsInitialLoad(false);
+ }
+ }, [isLoadingRules]);
+
+ useEffect(() => {
+ if (!isInitialLoad) {
+ dispatch({ type: 'refresh' });
+ }
+ }, [importCompleteToggle]);
+
+ useEffect(() => {
+ dispatch({
+ type: 'updateRules',
+ rules: rulesData.data,
+ pagination: {
+ page: rulesData.page,
+ perPage: rulesData.perPage,
+ total: rulesData.total,
+ },
+ });
+ }, [rulesData]);
+
+ return (
+ <>
+ {
+ dispatchToaster({
+ type: 'addToaster',
+ toast: {
+ id: uuid.v4(),
+ title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount),
+ color: 'success',
+ iconType: 'check',
+ },
+ });
+ }}
+ />
+
+
+
+ {isInitialLoad ? (
+
+ ) : (
+ <>
+
+ {
+ dispatch({
+ type: 'updateFilterOptions',
+ filterOptions: {
+ ...filterOptions,
+ filter: filterString,
+ },
+ });
+ }}
+ />
+
+
+
+
+
+ {i18n.SHOWING_RULES(pagination.total ?? 0)}
+
+
+
+ {i18n.SELECTED_RULES(selectedItems.length)}
+
+ {i18n.BATCH_ACTIONS}
+
+ dispatch({ type: 'refresh' })}
+ >
+ {i18n.REFRESH}
+
+
+
+
+
+ {
+ dispatch({
+ type: 'updatePagination',
+ pagination: { ...pagination, page: page.index + 1, perPage: page.size },
+ });
+ dispatch({
+ type: 'updateFilterOptions',
+ filterOptions: {
+ ...filterOptions,
+ sortField: 'enabled', // Only enabled is supported for sorting currently
+ sortOrder: sort.direction,
+ },
+ });
+ }}
+ pagination={{
+ pageIndex: pagination.page - 1,
+ pageSize: pagination.perPage,
+ totalItemCount: pagination.total,
+ pageSizeOptions: [5, 10, 20],
+ }}
+ selection={{
+ selectable: (item: TableData) => !item.isLoading,
+ onSelectionChange: (selected: TableData[]) =>
+ dispatch({ type: 'setSelected', selectedItems: selected }),
+ }}
+ sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }}
+ />
+ {isLoading && }
+ >
+ )}
+
+ >
+ );
+});
+
+AllRules.displayName = 'AllRules';
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/reducer.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/reducer.ts
new file mode 100644
index 0000000000000..c59c5687c10c9
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all_rules/reducer.ts
@@ -0,0 +1,144 @@
+/*
+ * 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 {
+ FilterOptions,
+ PaginationOptions,
+ Rule,
+} from '../../../../containers/detection_engine/rules/types';
+import { TableData } from '../types';
+import { formatRules } from './helpers';
+
+export interface State {
+ isLoading: boolean;
+ rules: Rule[];
+ selectedItems: TableData[];
+ pagination: PaginationOptions;
+ filterOptions: FilterOptions;
+ refreshToggle: boolean;
+ tableData: TableData[];
+ exportPayload?: object[];
+}
+
+export type Action =
+ | { type: 'refresh' }
+ | { type: 'loading'; isLoading: boolean }
+ | { type: 'deleteRules'; rules: Rule[] }
+ | { type: 'duplicate'; rule: Rule }
+ | { type: 'setExportPayload'; exportPayload?: object[] }
+ | { type: 'setSelected'; selectedItems: TableData[] }
+ | { type: 'updateLoading'; ids: string[]; isLoading: boolean }
+ | { type: 'updateRules'; rules: Rule[]; pagination?: PaginationOptions }
+ | { type: 'updatePagination'; pagination: PaginationOptions }
+ | { type: 'updateFilterOptions'; filterOptions: FilterOptions }
+ | { type: 'failure' };
+
+export const allRulesReducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case 'refresh': {
+ return {
+ ...state,
+ refreshToggle: !state.refreshToggle,
+ };
+ }
+ case 'updateRules': {
+ // If pagination included, this was a hard refresh
+ if (action.pagination) {
+ return {
+ ...state,
+ rules: action.rules,
+ pagination: action.pagination,
+ tableData: formatRules(action.rules),
+ };
+ }
+
+ const ruleIds = state.rules.map(r => r.rule_id);
+ const updatedRules = action.rules.reduce(
+ (rules, updatedRule) =>
+ ruleIds.includes(updatedRule.rule_id)
+ ? rules.map(r => (updatedRule.rule_id === r.rule_id ? updatedRule : r))
+ : [...rules, updatedRule],
+ [...state.rules]
+ );
+
+ // Update enabled on selectedItems so that batch actions show correct available actions
+ const updatedRuleIdToState = action.rules.reduce>(
+ (acc, r) => ({ ...acc, [r.id]: r.enabled }),
+ {}
+ );
+ const updatedSelectedItems = state.selectedItems.map(selectedItem =>
+ Object.keys(updatedRuleIdToState).includes(selectedItem.id)
+ ? { ...selectedItem, activate: updatedRuleIdToState[selectedItem.id] }
+ : selectedItem
+ );
+
+ return {
+ ...state,
+ rules: updatedRules,
+ tableData: formatRules(updatedRules),
+ selectedItems: updatedSelectedItems,
+ };
+ }
+ case 'updatePagination': {
+ return {
+ ...state,
+ pagination: action.pagination,
+ };
+ }
+ case 'updateFilterOptions': {
+ return {
+ ...state,
+ filterOptions: action.filterOptions,
+ };
+ }
+ case 'deleteRules': {
+ const deletedRuleIds = action.rules.map(r => r.rule_id);
+ const updatedRules = state.rules.reduce(
+ (rules, rule) => (deletedRuleIds.includes(rule.rule_id) ? rules : [...rules, rule]),
+ []
+ );
+ return {
+ ...state,
+ rules: updatedRules,
+ tableData: formatRules(updatedRules),
+ };
+ }
+ case 'setSelected': {
+ return {
+ ...state,
+ selectedItems: action.selectedItems,
+ };
+ }
+ case 'updateLoading': {
+ return {
+ ...state,
+ rules: state.rules,
+ tableData: formatRules(state.rules, action.ids),
+ };
+ }
+ case 'loading': {
+ return {
+ ...state,
+ isLoading: action.isLoading,
+ };
+ }
+ case 'failure': {
+ return {
+ ...state,
+ isLoading: false,
+ rules: [],
+ };
+ }
+ case 'setExportPayload': {
+ return {
+ ...state,
+ exportPayload: action.exportPayload,
+ };
+ }
+ default:
+ return state;
+ }
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000000..6b0aa02d4edfa
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,66 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ImportRuleModal renders correctly against snapshot 1`] = `
+
+
+
+
+
+ Import rule
+
+
+
+
+
+ Select a SIEM rule (as exported from the Detection Engine UI) to import
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Import rule
+
+
+
+
+
+`;
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx
new file mode 100644
index 0000000000000..b397e50201f14
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx
@@ -0,0 +1,30 @@
+/*
+ * 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 toJson from 'enzyme-to-json';
+import * as React from 'react';
+import { ImportRuleModal } from './index';
+import { useKibanaUiSetting } from '../../../../../lib/settings/use_kibana_ui_setting';
+import { getMockKibanaUiSetting, MockFrameworks } from '../../../../../mock';
+import { DEFAULT_KBN_VERSION } from '../../../../../../common/constants';
+
+const mockUseKibanaUiSetting: jest.Mock = useKibanaUiSetting as jest.Mock;
+jest.mock('../../../../../lib/settings/use_kibana_ui_setting', () => ({
+ useKibanaUiSetting: jest.fn(),
+}));
+
+describe('ImportRuleModal', () => {
+ test('renders correctly against snapshot', () => {
+ mockUseKibanaUiSetting.mockImplementation(
+ getMockKibanaUiSetting((DEFAULT_KBN_VERSION as unknown) as MockFrameworks)
+ );
+ const wrapper = shallow(
+
+ );
+ expect(toJson(wrapper)).toMatchSnapshot();
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx
new file mode 100644
index 0000000000000..fdcf6263f414f
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx
@@ -0,0 +1,160 @@
+/*
+ * 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 {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiCheckbox,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiOverlayMask,
+ // @ts-ignore no-exported-member
+ EuiFilePicker,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+
+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 * as i18n from './translations';
+import { duplicateRules } from '../../../../../containers/detection_engine/rules/api';
+import { useKibanaUiSetting } from '../../../../../lib/settings/use_kibana_ui_setting';
+import { DEFAULT_KBN_VERSION } from '../../../../../../common/constants';
+import { ndjsonToJSON } from '../json_downloader';
+import { RulesSchema } from '../../../../../containers/detection_engine/rules/types';
+import { useStateToaster } from '../../../../../components/toasters';
+
+interface ImportRuleModalProps {
+ showModal: boolean;
+ closeModal: () => void;
+ importComplete: () => void;
+}
+
+/**
+ * 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 ImportRuleModal = React.memo(
+ ({ showModal, closeModal, importComplete }) => {
+ const [selectedFiles, setSelectedFiles] = useState(null);
+ const [isImporting, setIsImporting] = useState(false);
+ const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION);
+ const [, dispatchToaster] = useStateToaster();
+
+ const cleanupAndCloseModal = () => {
+ setIsImporting(false);
+ setSelectedFiles(null);
+ closeModal();
+ };
+
+ const importRules = 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 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)
+ );
+
+ const duplicatedRules = await duplicateRules({ rules: decodedRules, kbnVersion });
+ 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));
+ }
+ }, [selectedFiles]);
+
+ return (
+ <>
+ {showModal && (
+
+
+
+ {i18n.IMPORT_RULE}
+
+
+
+
+ {i18n.SELECT_RULE}
+
+
+
+ {
+ setSelectedFiles(Object.keys(files).length > 0 ? files : null);
+ }}
+ display={'large'}
+ fullWidth={true}
+ isLoading={isImporting}
+ />
+
+ {}}
+ />
+
+
+
+ {i18n.CANCEL_BUTTON}
+
+ {i18n.IMPORT_RULE}
+
+
+
+
+ )}
+ >
+ );
+ }
+);
+
+ImportRuleModal.displayName = 'ImportRuleModal';
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts
new file mode 100644
index 0000000000000..50c3c75b6109f
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 IMPORT_RULE = i18n.translate(
+ 'xpack.siem.detectionEngine.components.importRuleModal.importRuleTitle',
+ {
+ defaultMessage: 'Import rule',
+ }
+);
+
+export const SELECT_RULE = i18n.translate(
+ 'xpack.siem.detectionEngine.components.importRuleModal.selectRuleDescription',
+ {
+ defaultMessage: 'Select a SIEM rule (as exported from the Detection Engine UI) to import',
+ }
+);
+
+export const INITIAL_PROMPT_TEXT = i18n.translate(
+ 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription',
+ {
+ defaultMessage: 'Select or drag and drop files',
+ }
+);
+
+export const OVERWRITE_WITH_SAME_NAME = i18n.translate(
+ 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription',
+ {
+ defaultMessage: 'Automatically overwrite saved objects with the same name',
+ }
+);
+
+export const CANCEL_BUTTON = i18n.translate(
+ 'xpack.siem.detectionEngine.components.importRuleModal.cancelTitle',
+ {
+ defaultMessage: 'Cancel',
+ }
+);
+
+export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) =>
+ i18n.translate(
+ 'xpack.siem.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle',
+ {
+ values: { totalRules },
+ defaultMessage:
+ 'Successfully imported {totalRules} {totalRules, plural, =1 {rule} other {rules}}',
+ }
+ );
+
+export const IMPORT_FAILED = i18n.translate(
+ 'xpack.siem.detectionEngine.components.importRuleModal.importFailedTitle',
+ {
+ defaultMessage: 'Failed to import rules',
+ }
+);
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000000..c4377c265c2c2
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`JSONDownloader renders correctly against snapshot 1`] = ``;
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.test.tsx
new file mode 100644
index 0000000000000..ef6493f89f383
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * 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 toJson from 'enzyme-to-json';
+import * as React from 'react';
+import { JSONDownloader, 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(
+
+ );
+ expect(toJson(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);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.tsx
new file mode 100644
index 0000000000000..e9c2c69f067cc
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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 JSONDownloader = React.memo(
+ ({ filename, payload, onExportComplete }) => {
+ const anchorRef = useRef(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 ;
+ }
+);
+
+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