[SIEM] [Detections] Refactor the all rules page (#58428)

* Refactor the all rules page to be easier to test

* review with Garrett

* bring back utility bar under condition

* fix bugs tags and allow switch to show loading when enable/disable rule

* fix rules selection when trigerring new rules

* fix imports/exports can only use rule_id as learned today

* review I
This commit is contained in:
Xavier Mouligneau 2020-02-25 21:59:44 -05:00 committed by GitHub
parent 24b8ecf74d
commit b56eead4ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 463 additions and 577 deletions

View file

@ -5,9 +5,8 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { useRules, ReturnRules } from './use_rules';
import { useRules, UseRules, ReturnRules } from './use_rules';
import * as api from './api';
import { PaginationOptions, FilterOptions } from '.';
jest.mock('./api');
@ -17,55 +16,40 @@ describe('useRules', () => {
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
[PaginationOptions, FilterOptions],
ReturnRules
>(props =>
useRules(
{
const { result, waitForNextUpdate } = renderHook<UseRules, ReturnRules>(props =>
useRules({
pagination: {
page: 1,
perPage: 10,
total: 100,
},
{
filterOptions: {
filter: '',
sortField: 'created_at',
sortOrder: 'desc',
}
)
},
})
);
await waitForNextUpdate();
expect(result.current).toEqual([
true,
{
data: [],
page: 1,
perPage: 20,
total: 0,
},
null,
]);
expect(result.current).toEqual([true, null, result.current[2]]);
});
});
test('fetch rules', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
[PaginationOptions, FilterOptions],
ReturnRules
>(() =>
useRules(
{
const { result, waitForNextUpdate } = renderHook<UseRules, ReturnRules>(() =>
useRules({
pagination: {
page: 1,
perPage: 10,
total: 100,
},
{
filterOptions: {
filter: '',
sortField: 'created_at',
sortOrder: 'desc',
}
)
},
})
);
await waitForNextUpdate();
await waitForNextUpdate();
@ -148,22 +132,19 @@ describe('useRules', () => {
test('re-fetch rules', async () => {
const spyOnfetchRules = jest.spyOn(api, 'fetchRules');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
[PaginationOptions, FilterOptions],
ReturnRules
>(id =>
useRules(
{
const { result, waitForNextUpdate } = renderHook<UseRules, ReturnRules>(id =>
useRules({
pagination: {
page: 1,
perPage: 10,
total: 100,
},
{
filterOptions: {
filter: '',
sortField: 'created_at',
sortOrder: 'desc',
}
)
},
})
);
await waitForNextUpdate();
await waitForNextUpdate();
@ -178,37 +159,37 @@ describe('useRules', () => {
test('fetch rules if props changes', async () => {
const spyOnfetchRules = jest.spyOn(api, 'fetchRules');
await act(async () => {
const { rerender, waitForNextUpdate } = renderHook<
[PaginationOptions, FilterOptions],
ReturnRules
>(args => useRules(args[0], args[1]), {
initialProps: [
{
page: 1,
perPage: 10,
total: 100,
},
{
filter: '',
sortField: 'created_at',
sortOrder: 'desc',
},
],
});
await waitForNextUpdate();
await waitForNextUpdate();
rerender([
const { rerender, waitForNextUpdate } = renderHook<UseRules, ReturnRules>(
args => useRules(args),
{
initialProps: {
pagination: {
page: 1,
perPage: 10,
total: 100,
},
filterOptions: {
filter: '',
sortField: 'created_at',
sortOrder: 'desc',
},
},
}
);
await waitForNextUpdate();
await waitForNextUpdate();
rerender({
pagination: {
page: 1,
perPage: 10,
total: 100,
},
{
filterOptions: {
filter: 'hello world',
sortField: 'created_at',
sortOrder: 'desc',
},
]);
});
await waitForNextUpdate();
expect(spyOnfetchRules).toHaveBeenCalledTimes(2);
});

View file

@ -4,16 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { noop } from 'lodash/fp';
import { useEffect, useState, useRef } from 'react';
import { FetchRulesResponse, FilterOptions, PaginationOptions } from './types';
import { FetchRulesResponse, FilterOptions, PaginationOptions, Rule } 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 Func = () => void;
export type ReturnRules = [boolean, FetchRulesResponse, Func | null];
export type ReturnRules = [
boolean,
FetchRulesResponse | null,
(refreshPrePackagedRule?: boolean) => void
];
export interface UseRules {
pagination: PaginationOptions;
filterOptions: FilterOptions;
refetchPrePackagedRulesStatus?: () => void;
dispatchRulesInReducer?: (rules: Rule[]) => void;
}
/**
* Hook for using the list of Rules from the Detection Engine API
@ -21,17 +32,14 @@ export type ReturnRules = [boolean, FetchRulesResponse, Func | null];
* @param pagination desired pagination options (e.g. page/perPage)
* @param filterOptions desired filters (e.g. filter/sortField/sortOrder)
*/
export const useRules = (
pagination: PaginationOptions,
filterOptions: FilterOptions
): ReturnRules => {
const [rules, setRules] = useState<FetchRulesResponse>({
page: 1,
perPage: 20,
total: 0,
data: [],
});
const reFetchRules = useRef<Func | null>(null);
export const useRules = ({
pagination,
filterOptions,
refetchPrePackagedRulesStatus,
dispatchRulesInReducer,
}: UseRules): ReturnRules => {
const [rules, setRules] = useState<FetchRulesResponse | null>(null);
const reFetchRules = useRef<(refreshPrePackagedRule?: boolean) => void>(noop);
const [loading, setLoading] = useState(true);
const [, dispatchToaster] = useStateToaster();
@ -50,10 +58,16 @@ export const useRules = (
if (isSubscribed) {
setRules(fetchRulesResult);
if (dispatchRulesInReducer != null) {
dispatchRulesInReducer(fetchRulesResult.data);
}
}
} catch (error) {
if (isSubscribed) {
errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster });
if (dispatchRulesInReducer != null) {
dispatchRulesInReducer([]);
}
}
}
if (isSubscribed) {
@ -62,7 +76,12 @@ export const useRules = (
}
fetchData();
reFetchRules.current = fetchData.bind(null, true);
reFetchRules.current = (refreshPrePackagedRule: boolean = false) => {
fetchData(true);
if (refreshPrePackagedRule && refetchPrePackagedRulesStatus != null) {
refetchPrePackagedRulesStatus();
}
};
return () => {
isSubscribed = false;
abortCtrl.abort();
@ -76,6 +95,7 @@ export const useRules = (
filterOptions.tags?.sort().join(),
filterOptions.showCustomRules,
filterOptions.showElasticRules,
refetchPrePackagedRulesStatus,
]);
return [loading, rules, reFetchRules.current];

View file

@ -14,7 +14,7 @@ describe('useTags', () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<unknown, ReturnTags>(() => useTags());
await waitForNextUpdate();
expect(result.current).toEqual([true, []]);
expect(result.current).toEqual([true, [], result.current[2]]);
});
});
@ -23,7 +23,11 @@ describe('useTags', () => {
const { result, waitForNextUpdate } = renderHook<unknown, ReturnTags>(() => useTags());
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual([false, ['elastic', 'love', 'quality', 'code']]);
expect(result.current).toEqual([
false,
['elastic', 'love', 'quality', 'code'],
result.current[2],
]);
});
});
});

View file

@ -4,13 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { noop } from 'lodash/fp';
import { useEffect, useState, useRef } from 'react';
import { useStateToaster } from '../../../components/toasters';
import { fetchTags } from './api';
import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
import * as i18n from './translations';
export type ReturnTags = [boolean, string[]];
export type ReturnTags = [boolean, string[], () => void];
/**
* Hook for using the list of Tags from the Detection Engine API
@ -20,6 +21,7 @@ export const useTags = (): ReturnTags => {
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [, dispatchToaster] = useStateToaster();
const reFetchTags = useRef<() => void>(noop);
useEffect(() => {
let isSubscribed = true;
@ -46,6 +48,7 @@ export const useTags = (): ReturnTags => {
}
fetchData();
reFetchTags.current = fetchData;
return () => {
isSubscribed = false;
@ -53,5 +56,5 @@ export const useTags = (): ReturnTags => {
};
}, []);
return [loading, tags];
return [loading, tags, reFetchTags.current];
};

View file

@ -5,7 +5,6 @@
*/
import { Rule, RuleError } from '../../../../../containers/detection_engine/rules';
import { TableData } from '../../types';
export const mockRule = (id: string): Rule => ({
created_at: '2020-01-10T21:11:45.839Z',
@ -50,103 +49,3 @@ export const mockRules: Rule[] = [
mockRule('abe6c564-050d-45a5-aaf0-386c37dd1f61'),
mockRule('63f06f34-c181-4b2d-af35-f2ace572a1ee'),
];
export const mockTableData: TableData[] = [
{
activate: true,
id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61',
immutable: false,
isLoading: false,
risk_score: 21,
rule: {
href: '#/detections/rules/id/abe6c564-050d-45a5-aaf0-386c37dd1f61',
name: 'Home Grown!',
},
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
severity: 'low',
sourceRule: {
created_at: '2020-01-10T21:11:45.839Z',
created_by: 'elastic',
description: '24/7',
enabled: true,
false_positives: [],
filters: [],
from: 'now-300s',
id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61',
immutable: false,
index: ['auditbeat-*'],
interval: '5m',
language: 'kuery',
max_signals: 100,
meta: { from: '0m' },
name: 'Home Grown!',
output_index: '.siem-signals-default',
query: '',
references: [],
risk_score: 21,
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
saved_id: "Garrett's IP",
severity: 'low',
tags: [],
threat: [],
timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
timeline_title: 'Untitled timeline',
to: 'now',
type: 'saved_query',
updated_at: '2020-01-10T21:11:45.839Z',
updated_by: 'elastic',
version: 1,
},
status: null,
statusDate: null,
tags: [],
},
{
activate: true,
id: '63f06f34-c181-4b2d-af35-f2ace572a1ee',
immutable: false,
isLoading: false,
risk_score: 21,
rule: {
href: '#/detections/rules/id/63f06f34-c181-4b2d-af35-f2ace572a1ee',
name: 'Home Grown!',
},
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
severity: 'low',
sourceRule: {
created_at: '2020-01-10T21:11:45.839Z',
created_by: 'elastic',
description: '24/7',
enabled: true,
false_positives: [],
filters: [],
from: 'now-300s',
id: '63f06f34-c181-4b2d-af35-f2ace572a1ee',
immutable: false,
index: ['auditbeat-*'],
interval: '5m',
language: 'kuery',
max_signals: 100,
meta: { from: '0m' },
name: 'Home Grown!',
output_index: '.siem-signals-default',
query: '',
references: [],
risk_score: 21,
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
saved_id: "Garrett's IP",
severity: 'low',
tags: [],
threat: [],
timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
timeline_title: 'Untitled timeline',
to: 'now',
type: 'saved_query',
updated_at: '2020-01-10T21:11:45.839Z',
updated_by: 'elastic',
version: 1,
},
status: null,
statusDate: null,
tags: [],
},
];

View file

@ -32,53 +32,58 @@ export const editRuleAction = (rule: Rule, history: H.History) => {
export const duplicateRulesAction = async (
rules: Rule[],
ruleIds: string[],
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>
) => {
try {
const ruleIds = rules.map(r => r.id);
dispatch({ type: 'updateLoading', ids: ruleIds, isLoading: true });
const duplicatedRules = await duplicateRules({ rules });
dispatch({ type: 'refresh' });
displaySuccessToast(
i18n.SUCCESSFULLY_DUPLICATED_RULES(duplicatedRules.length),
dispatchToaster
);
dispatch({ type: 'loadingRuleIds', ids: ruleIds, actionType: 'duplicate' });
const response = await duplicateRules({ rules });
const { errors } = bucketRulesResponse(response);
if (errors.length > 0) {
displayErrorToast(
i18n.DUPLICATE_RULE_ERROR,
errors.map(e => e.error.message),
dispatchToaster
);
} else {
displaySuccessToast(i18n.SUCCESSFULLY_DUPLICATED_RULES(ruleIds.length), dispatchToaster);
}
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
} catch (e) {
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
displayErrorToast(i18n.DUPLICATE_RULE_ERROR, [e.message], dispatchToaster);
}
};
export const exportRulesAction = async (rules: Rule[], dispatch: React.Dispatch<Action>) => {
dispatch({ type: 'setExportPayload', exportPayload: rules });
export const exportRulesAction = (exportRuleId: string[], dispatch: React.Dispatch<Action>) => {
dispatch({ type: 'exportRuleIds', ids: exportRuleId });
};
export const deleteRulesAction = async (
ids: string[],
ruleIds: string[],
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
onRuleDeleted?: () => void
) => {
try {
dispatch({ type: 'loading', isLoading: true });
const response = await deleteRules({ ids });
dispatch({ type: 'loadingRuleIds', ids: ruleIds, actionType: 'delete' });
const response = await deleteRules({ ids: ruleIds });
const { errors } = bucketRulesResponse(response);
dispatch({ type: 'refresh' });
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
if (errors.length > 0) {
displayErrorToast(
i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length),
i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length),
errors.map(e => e.error.message),
dispatchToaster
);
} else {
// FP: See https://github.com/typescript-eslint/typescript-eslint/issues/1138#issuecomment-566929566
onRuleDeleted?.(); // eslint-disable-line no-unused-expressions
} else if (onRuleDeleted) {
onRuleDeleted();
}
} catch (e) {
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
displayErrorToast(
i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length),
i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length),
[e.message],
dispatchToaster
);
@ -96,7 +101,7 @@ export const enableRulesAction = async (
: i18n.BATCH_ACTION_DEACTIVATE_SELECTED_ERROR(ids.length);
try {
dispatch({ type: 'updateLoading', ids, isLoading: true });
dispatch({ type: 'loadingRuleIds', ids, actionType: enabled ? 'enable' : 'disable' });
const response = await enableRules({ ids, enabled });
const { rules, errors } = bucketRulesResponse(response);
@ -125,6 +130,6 @@ export const enableRulesAction = async (
}
} catch (e) {
displayErrorToast(errorTitle, [e.message], dispatchToaster);
dispatch({ type: 'updateLoading', ids, isLoading: false });
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
}
};

View file

@ -6,9 +6,7 @@
import { EuiContextMenuItem } from '@elastic/eui';
import React, { Dispatch } from 'react';
import * as H from 'history';
import * as i18n from '../translations';
import { TableData } from '../types';
import { Action } from './reducer';
import {
deleteRulesAction,
@ -17,18 +15,37 @@ import {
exportRulesAction,
} from './actions';
import { ActionToaster } from '../../../../components/toasters';
import { Rule } from '../../../../containers/detection_engine/rules';
export const getBatchItems = (
selectedState: TableData[],
dispatch: Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
history: H.History,
closePopover: () => void
) => {
const containsEnabled = selectedState.some(v => v.activate);
const containsDisabled = selectedState.some(v => !v.activate);
const containsLoading = selectedState.some(v => v.isLoading);
const containsImmutable = selectedState.some(v => v.immutable);
interface GetBatchItems {
closePopover: () => void;
dispatch: Dispatch<Action>;
dispatchToaster: Dispatch<ActionToaster>;
loadingRuleIds: string[];
reFetchRules: (refreshPrePackagedRule?: boolean) => void;
rules: Rule[];
selectedRuleIds: string[];
}
export const getBatchItems = ({
closePopover,
dispatch,
dispatchToaster,
loadingRuleIds,
reFetchRules,
rules,
selectedRuleIds,
}: GetBatchItems) => {
const containsEnabled = selectedRuleIds.some(
id => rules.find(r => r.id === id)?.enabled ?? false
);
const containsDisabled = selectedRuleIds.some(
id => !rules.find(r => r.id === id)?.enabled ?? false
);
const containsLoading = selectedRuleIds.some(id => loadingRuleIds.includes(id));
const containsImmutable = selectedRuleIds.some(
id => rules.find(r => r.id === id)?.immutable ?? false
);
return [
<EuiContextMenuItem
@ -37,7 +54,9 @@ export const getBatchItems = (
disabled={containsLoading || !containsDisabled}
onClick={async () => {
closePopover();
const deactivatedIds = selectedState.filter(s => !s.activate).map(s => s.id);
const deactivatedIds = selectedRuleIds.filter(
id => !rules.find(r => r.id === id)?.enabled ?? false
);
await enableRulesAction(deactivatedIds, true, dispatch, dispatchToaster);
}}
>
@ -49,7 +68,9 @@ export const getBatchItems = (
disabled={containsLoading || !containsEnabled}
onClick={async () => {
closePopover();
const activatedIds = selectedState.filter(s => s.activate).map(s => s.id);
const activatedIds = selectedRuleIds.filter(
id => rules.find(r => r.id === id)?.enabled ?? false
);
await enableRulesAction(activatedIds, false, dispatch, dispatchToaster);
}}
>
@ -58,11 +79,11 @@ export const getBatchItems = (
<EuiContextMenuItem
key={i18n.BATCH_ACTION_EXPORT_SELECTED}
icon="exportAction"
disabled={containsImmutable || containsLoading || selectedState.length === 0}
onClick={async () => {
disabled={containsImmutable || containsLoading || selectedRuleIds.length === 0}
onClick={() => {
closePopover();
await exportRulesAction(
selectedState.map(s => s.sourceRule),
exportRulesAction(
rules.filter(r => selectedRuleIds.includes(r.id)).map(r => r.rule_id),
dispatch
);
}}
@ -72,14 +93,16 @@ export const getBatchItems = (
<EuiContextMenuItem
key={i18n.BATCH_ACTION_DUPLICATE_SELECTED}
icon="copy"
disabled={containsLoading || selectedState.length === 0}
disabled={containsLoading || selectedRuleIds.length === 0}
onClick={async () => {
closePopover();
await duplicateRulesAction(
selectedState.map(s => s.sourceRule),
rules.filter(r => selectedRuleIds.includes(r.id)),
selectedRuleIds,
dispatch,
dispatchToaster
);
reFetchRules(true);
}}
>
{i18n.BATCH_ACTION_DUPLICATE_SELECTED}
@ -88,14 +111,11 @@ export const getBatchItems = (
key={i18n.BATCH_ACTION_DELETE_SELECTED}
icon="trash"
title={containsImmutable ? i18n.BATCH_ACTION_DELETE_SELECTED_IMMUTABLE : undefined}
disabled={containsLoading || selectedState.length === 0}
disabled={containsLoading || selectedRuleIds.length === 0}
onClick={async () => {
closePopover();
await deleteRulesAction(
selectedState.map(({ sourceRule: { id } }) => id),
dispatch,
dispatchToaster
);
await deleteRulesAction(selectedRuleIds, dispatch, dispatchToaster);
reFetchRules(true);
}}
>
{i18n.BATCH_ACTION_DELETE_SELECTED}

View file

@ -15,72 +15,92 @@ import {
} from '@elastic/eui';
import * as H from 'history';
import React, { Dispatch } from 'react';
import { Rule } from '../../../../containers/detection_engine/rules';
import { getEmptyTagValue } from '../../../../components/empty_value';
import { FormattedDate } from '../../../../components/formatted_date';
import { getRuleDetailsUrl } from '../../../../components/link_to/redirect_to_detection_engine';
import { ActionToaster } from '../../../../components/toasters';
import { TruncatableText } from '../../../../components/truncatable_text';
import { getStatusColor } from '../components/rule_status/helpers';
import { RuleSwitch } from '../components/rule_switch';
import { SeverityBadge } from '../components/severity_badge';
import * as i18n from '../translations';
import {
deleteRulesAction,
duplicateRulesAction,
editRuleAction,
exportRulesAction,
} from './actions';
import { Action } from './reducer';
import { TableData } from '../types';
import * as i18n from '../translations';
import { FormattedDate } from '../../../../components/formatted_date';
import { RuleSwitch } from '../components/rule_switch';
import { SeverityBadge } from '../components/severity_badge';
import { ActionToaster } from '../../../../components/toasters';
import { getStatusColor } from '../components/rule_status/helpers';
import { TruncatableText } from '../../../../components/truncatable_text';
const getActions = (
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
history: H.History
history: H.History,
reFetchRules: (refreshPrePackagedRule?: boolean) => void
) => [
{
description: i18n.EDIT_RULE_SETTINGS,
icon: 'visControls',
name: i18n.EDIT_RULE_SETTINGS,
onClick: (rowItem: TableData) => editRuleAction(rowItem.sourceRule, history),
enabled: (rowItem: TableData) => !rowItem.sourceRule.immutable,
onClick: (rowItem: Rule) => editRuleAction(rowItem, history),
enabled: (rowItem: Rule) => !rowItem.immutable,
},
{
description: i18n.DUPLICATE_RULE,
icon: 'copy',
name: i18n.DUPLICATE_RULE,
onClick: (rowItem: TableData) =>
duplicateRulesAction([rowItem.sourceRule], dispatch, dispatchToaster),
onClick: (rowItem: Rule) => {
duplicateRulesAction([rowItem], [rowItem.id], dispatch, dispatchToaster);
reFetchRules(true);
},
},
{
description: i18n.EXPORT_RULE,
icon: 'exportAction',
name: i18n.EXPORT_RULE,
onClick: (rowItem: TableData) => exportRulesAction([rowItem.sourceRule], dispatch),
enabled: (rowItem: TableData) => !rowItem.immutable,
onClick: (rowItem: Rule) => exportRulesAction([rowItem.rule_id], dispatch),
enabled: (rowItem: Rule) => !rowItem.immutable,
},
{
description: i18n.DELETE_RULE,
icon: 'trash',
name: i18n.DELETE_RULE,
onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch, dispatchToaster),
onClick: (rowItem: Rule) => {
deleteRulesAction([rowItem.id], dispatch, dispatchToaster);
reFetchRules(true);
},
},
];
type RulesColumns = EuiBasicTableColumn<TableData> | EuiTableActionsColumnType<TableData>;
type RulesColumns = EuiBasicTableColumn<Rule> | EuiTableActionsColumnType<Rule>;
interface GetColumns {
dispatch: React.Dispatch<Action>;
dispatchToaster: Dispatch<ActionToaster>;
history: H.History;
hasNoPermissions: boolean;
loadingRuleIds: string[];
reFetchRules: (refreshPrePackagedRule?: boolean) => void;
}
// Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes?
export const getColumns = (
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
history: H.History,
hasNoPermissions: boolean
): RulesColumns[] => {
export const getColumns = ({
dispatch,
dispatchToaster,
history,
hasNoPermissions,
loadingRuleIds,
reFetchRules,
}: GetColumns): RulesColumns[] => {
const cols: RulesColumns[] = [
{
field: 'rule',
field: 'name',
name: i18n.COLUMN_RULE,
render: (value: TableData['rule']) => <EuiLink href={value.href}>{value.name}</EuiLink>,
render: (value: Rule['name'], item: Rule) => (
<EuiLink href={getRuleDetailsUrl(item.id)}>{value}</EuiLink>
),
truncateText: true,
width: '24%',
},
@ -93,14 +113,14 @@ export const getColumns = (
{
field: 'severity',
name: i18n.COLUMN_SEVERITY,
render: (value: TableData['severity']) => <SeverityBadge value={value} />,
render: (value: Rule['severity']) => <SeverityBadge value={value} />,
truncateText: true,
width: '16%',
},
{
field: 'statusDate',
field: 'status_date',
name: i18n.COLUMN_LAST_COMPLETE_RUN,
render: (value: TableData['statusDate']) => {
render: (value: Rule['status_date']) => {
return value == null ? (
getEmptyTagValue()
) : (
@ -114,7 +134,7 @@ export const getColumns = (
{
field: 'status',
name: i18n.COLUMN_LAST_RESPONSE,
render: (value: TableData['status']) => {
render: (value: Rule['status']) => {
return (
<>
<EuiHealth color={getStatusColor(value ?? null)}>
@ -129,7 +149,7 @@ export const getColumns = (
{
field: 'tags',
name: i18n.COLUMN_TAGS,
render: (value: TableData['tags']) => (
render: (value: Rule['tags']) => (
<TruncatableText>
{value.map((tag, i) => (
<EuiBadge color="hollow" key={`${tag}-${i}`}>
@ -145,13 +165,13 @@ export const getColumns = (
align: 'center',
field: 'activate',
name: i18n.COLUMN_ACTIVATE,
render: (value: TableData['activate'], item: TableData) => (
render: (value: Rule['enabled'], item: Rule) => (
<RuleSwitch
dispatch={dispatch}
id={item.id}
enabled={item.activate}
enabled={item.enabled}
isDisabled={hasNoPermissions}
isLoading={item.isLoading}
isLoading={loadingRuleIds.includes(item.id)}
/>
),
sortable: true,
@ -160,9 +180,9 @@ export const getColumns = (
];
const actions: RulesColumns[] = [
{
actions: getActions(dispatch, dispatchToaster, history),
actions: getActions(dispatch, dispatchToaster, history, reFetchRules),
width: '40px',
} as EuiTableActionsColumnType<TableData>,
} as EuiTableActionsColumnType<Rule>,
];
return hasNoPermissions ? cols : [...cols, ...actions];

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { bucketRulesResponse, formatRules } from './helpers';
import { mockRule, mockRuleError, mockRules, mockTableData } from './__mocks__/mock';
import { bucketRulesResponse } from './helpers';
import { mockRule, mockRuleError } from './__mocks__/mock';
import uuid from 'uuid';
import { Rule, RuleError } from '../../../../containers/detection_engine/rules';
@ -15,20 +15,6 @@ describe('AllRulesTable Helpers', () => {
const mockRuleError1: Readonly<RuleError> = mockRuleError(uuid.v4());
const mockRuleError2: Readonly<RuleError> = mockRuleError(uuid.v4());
describe('formatRules', () => {
test('formats rules with no selection', () => {
const formattedRules = formatRules(mockRules);
expect(formattedRules).toEqual(mockTableData);
});
test('formats rules with selection', () => {
const mockTableDataWithSelected = [...mockTableData];
mockTableDataWithSelected[0].isLoading = true;
const formattedRules = formatRules(mockRules, [mockRules[0].id]);
expect(formattedRules).toEqual(mockTableDataWithSelected);
});
});
describe('bucketRulesResponse', () => {
test('buckets empty response', () => {
const bucketedResponse = bucketRulesResponse([]);

View file

@ -9,32 +9,6 @@ import {
RuleError,
RuleResponseBuckets,
} from '../../../../containers/detection_engine/rules';
import { TableData } from '../types';
/**
* Formats rules into the correct format for the AllRulesTable
*
* @param rules as returned from the Rules API
* @param selectedIds ids of the currently selected rules
*/
export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] =>
rules.map(rule => ({
id: rule.id,
immutable: rule.immutable,
rule_id: rule.rule_id,
rule: {
href: `#/detections/rules/id/${encodeURIComponent(rule.id)}`,
name: rule.name,
},
risk_score: rule.risk_score,
severity: rule.severity,
tags: rule.tags ?? [],
activate: rule.enabled,
status: rule.status ?? null,
statusDate: rule.status_date ?? null,
sourceRule: rule,
isLoading: selectedIds?.includes(rule.id) ?? false,
}));
/**
* Separates rules/errors from bulk rules API response (create/update/delete)
@ -52,14 +26,11 @@ export const bucketRulesResponse = (response: Array<Rule | RuleError>) =>
);
export const showRulesTable = ({
isInitialLoad,
rulesCustomInstalled,
rulesInstalled,
}: {
isInitialLoad: boolean;
rulesCustomInstalled: number | null;
rulesInstalled: number | null;
}) =>
!isInitialLoad &&
((rulesCustomInstalled != null && rulesCustomInstalled > 0) ||
(rulesInstalled != null && rulesInstalled > 0));
(rulesCustomInstalled != null && rulesCustomInstalled > 0) ||
(rulesInstalled != null && rulesInstalled > 0);

View file

@ -11,15 +11,16 @@ import {
EuiLoadingContent,
EuiSpacer,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import uuid from 'uuid';
import {
useRules,
CreatePreBuiltRules,
FilterOptions,
Rule,
} from '../../../../containers/detection_engine/rules';
import { HeaderSection } from '../../../../components/header_section';
import {
@ -36,35 +37,39 @@ import { PrePackagedRulesPrompt } from '../components/pre_packaged_rules/load_em
import { RuleDownloader } from '../components/rule_downloader';
import { getPrePackagedRuleStatus } from '../helpers';
import * as i18n from '../translations';
import { EuiBasicTableOnChange, TableData } from '../types';
import { EuiBasicTableOnChange } from '../types';
import { getBatchItems } from './batch_actions';
import { getColumns } from './columns';
import { showRulesTable } from './helpers';
import { allRulesReducer, State } from './reducer';
import { RulesTableFilters } from './rules_table_filters/rules_table_filters';
// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way
// after few hours of fight with typescript !!!! I lost :(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any;
const initialState: State = {
isLoading: true,
rules: [],
tableData: [],
selectedItems: [],
refreshToggle: true,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
exportRuleIds: [],
filterOptions: {
filter: '',
sortField: 'enabled',
sortOrder: 'desc',
},
loadingRuleIds: [],
loadingRulesAction: null,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
rules: [],
selectedRuleIds: [],
};
interface AllRulesProps {
createPrePackagedRules: CreatePreBuiltRules | null;
hasNoPermissions: boolean;
importCompleteToggle: boolean;
loading: boolean;
loadingCreatePrePackagedRules: boolean;
refetchPrePackagedRulesStatus: () => void;
@ -72,7 +77,7 @@ interface AllRulesProps {
rulesInstalled: number | null;
rulesNotInstalled: number | null;
rulesNotUpdated: number | null;
setRefreshRulesData: (refreshRule: () => void) => void;
setRefreshRulesData: (refreshRule: (refreshPrePackagedRule?: boolean) => void) => void;
}
/**
@ -87,7 +92,6 @@ export const AllRules = React.memo<AllRulesProps>(
({
createPrePackagedRules,
hasNoPermissions,
importCompleteToggle,
loading,
loadingCreatePrePackagedRules,
refetchPrePackagedRulesStatus,
@ -97,24 +101,36 @@ export const AllRules = React.memo<AllRulesProps>(
rulesNotUpdated,
setRefreshRulesData,
}) => {
const [initLoading, setInitLoading] = useState(true);
const tableRef = useRef<EuiBasicTable>();
const [
{
exportPayload,
exportRuleIds,
filterOptions,
isLoading,
refreshToggle,
selectedItems,
tableData,
loadingRuleIds,
loadingRulesAction,
pagination,
rules,
selectedRuleIds,
},
dispatch,
] = useReducer(allRulesReducer, initialState);
] = useReducer(allRulesReducer(tableRef), initialState);
const history = useHistory();
const [oldRefreshToggle, setOldRefreshToggle] = useState(refreshToggle);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isGlobalLoading, setIsGlobalLoad] = useState(false);
const [, dispatchToaster] = useStateToaster();
const [isLoadingRules, rulesData, reFetchRulesData] = useRules(pagination, filterOptions);
const setRules = useCallback((newRules: Rule[]) => {
dispatch({
type: 'setRules',
rules: newRules,
});
}, []);
const [isLoadingRules, , reFetchRulesData] = useRules({
pagination,
filterOptions,
refetchPrePackagedRulesStatus,
dispatchRulesInReducer: setRules,
});
const prePackagedRuleStatus = getPrePackagedRuleStatus(
rulesInstalled,
@ -125,10 +141,18 @@ export const AllRules = React.memo<AllRulesProps>(
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => (
<EuiContextMenuPanel
items={getBatchItems(selectedItems, dispatch, dispatchToaster, history, closePopover)}
items={getBatchItems({
closePopover,
dispatch,
dispatchToaster,
loadingRuleIds,
selectedRuleIds,
reFetchRules: reFetchRulesData,
rules,
})}
/>
),
[selectedItems, dispatch, dispatchToaster, history]
[dispatch, dispatchToaster, loadingRuleIds, reFetchRulesData, rules, selectedRuleIds]
);
const tableOnChangeCallback = useCallback(
@ -146,46 +170,19 @@ export const AllRules = React.memo<AllRulesProps>(
);
const columns = useMemo(() => {
return getColumns(dispatch, dispatchToaster, history, hasNoPermissions);
}, [dispatch, dispatchToaster, history]);
useEffect(() => {
dispatch({ type: 'loading', isLoading: isLoadingRules });
}, [isLoadingRules]);
useEffect(() => {
if (!isLoadingRules && !loading && isInitialLoad) {
setIsInitialLoad(false);
}
}, [isInitialLoad, isLoadingRules, loading]);
useEffect(() => {
if (!isGlobalLoading && (isLoadingRules || isLoading)) {
setIsGlobalLoad(true);
} else if (isGlobalLoading && !isLoadingRules && !isLoading) {
setIsGlobalLoad(false);
}
}, [setIsGlobalLoad, isGlobalLoading, isLoadingRules, isLoading]);
useEffect(() => {
if (!isInitialLoad) {
dispatch({ type: 'refresh' });
}
}, [importCompleteToggle]);
useEffect(() => {
if (!isInitialLoad && reFetchRulesData != null && oldRefreshToggle !== refreshToggle) {
setOldRefreshToggle(refreshToggle);
reFetchRulesData();
refetchPrePackagedRulesStatus();
}
}, [
isInitialLoad,
refreshToggle,
oldRefreshToggle,
reFetchRulesData,
refetchPrePackagedRulesStatus,
]);
return getColumns({
dispatch,
dispatchToaster,
history,
hasNoPermissions,
loadingRuleIds:
loadingRulesAction != null &&
(loadingRulesAction === 'enable' || loadingRulesAction === 'disable')
? loadingRuleIds
: [],
reFetchRules: reFetchRulesData,
});
}, [dispatch, dispatchToaster, history, loadingRuleIds, loadingRulesAction, reFetchRulesData]);
useEffect(() => {
if (reFetchRulesData != null) {
@ -194,31 +191,25 @@ export const AllRules = React.memo<AllRulesProps>(
}, [reFetchRulesData, setRefreshRulesData]);
useEffect(() => {
dispatch({
type: 'updateRules',
rules: rulesData.data,
pagination: {
page: rulesData.page,
perPage: rulesData.perPage,
total: rulesData.total,
},
});
}, [rulesData]);
if (initLoading && !loading && !isLoadingRules) {
setInitLoading(false);
}
}, [initLoading, loading, isLoadingRules]);
const handleCreatePrePackagedRules = useCallback(async () => {
if (createPrePackagedRules != null) {
if (createPrePackagedRules != null && reFetchRulesData != null) {
await createPrePackagedRules();
dispatch({ type: 'refresh' });
reFetchRulesData(true);
}
}, [createPrePackagedRules]);
}, [createPrePackagedRules, reFetchRulesData]);
const euiBasicTableSelectionProps = useMemo(
() => ({
selectable: (item: TableData) => !item.isLoading,
onSelectionChange: (selected: TableData[]) =>
dispatch({ type: 'setSelected', selectedItems: selected }),
selectable: (item: Rule) => !loadingRuleIds.includes(item.id),
onSelectionChange: (selected: Rule[]) =>
dispatch({ type: 'selectedRuleIds', ids: selected.map(r => r.id) }),
}),
[]
[loadingRuleIds]
);
const onFilterChangedCallback = useCallback((newFilterOptions: Partial<FilterOptions>) => {
@ -237,12 +228,25 @@ export const AllRules = React.memo<AllRulesProps>(
);
}, []);
const isLoadingAnActionOnRule = useMemo(() => {
if (
loadingRuleIds.length > 0 &&
(loadingRulesAction === 'disable' || loadingRulesAction === 'enable')
) {
return false;
} else if (loadingRuleIds.length > 0) {
return true;
}
return false;
}, [loadingRuleIds, loadingRulesAction]);
return (
<>
<RuleDownloader
filename={`${i18n.EXPORT_FILENAME}.ndjson`}
rules={exportPayload}
ruleIds={exportRuleIds}
onExportComplete={exportCount => {
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
dispatchToaster({
type: 'addToaster',
toast: {
@ -256,22 +260,17 @@ export const AllRules = React.memo<AllRulesProps>(
/>
<EuiSpacer />
<Panel loading={isGlobalLoading}>
<Panel loading={loading || isLoadingRules}>
<>
{((rulesCustomInstalled && rulesCustomInstalled > 0) ||
(rulesInstalled != null && rulesInstalled > 0)) && (
<HeaderSection split title={i18n.ALL_RULES}>
<RulesTableFilters
onFilterChanged={onFilterChangedCallback}
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}
/>
</HeaderSection>
)}
{isInitialLoad && (
<EuiLoadingContent data-test-subj="initialLoadingPanelAllRulesTable" lines={10} />
)}
{isGlobalLoading && !isEmpty(tableData) && !isInitialLoad && (
<HeaderSection split title={i18n.ALL_RULES}>
<RulesTableFilters
onFilterChanged={onFilterChangedCallback}
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}
/>
</HeaderSection>
{(loading || isLoadingRules || isLoadingAnActionOnRule) && !initLoading && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{rulesCustomInstalled != null &&
@ -283,7 +282,10 @@ export const AllRules = React.memo<AllRulesProps>(
userHasNoPermissions={hasNoPermissions}
/>
)}
{showRulesTable({ isInitialLoad, rulesCustomInstalled, rulesInstalled }) && (
{initLoading && (
<EuiLoadingContent data-test-subj="initialLoadingPanelAllRulesTable" lines={10} />
)}
{showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading && (
<>
<UtilityBar border>
<UtilityBarSection>
@ -292,7 +294,7 @@ export const AllRules = React.memo<AllRulesProps>(
</UtilityBarGroup>
<UtilityBarGroup>
<UtilityBarText>{i18n.SELECTED_RULES(selectedItems.length)}</UtilityBarText>
<UtilityBarText>{i18n.SELECTED_RULES(selectedRuleIds.length)}</UtilityBarText>
{!hasNoPermissions && (
<UtilityBarAction
iconSide="right"
@ -303,21 +305,20 @@ export const AllRules = React.memo<AllRulesProps>(
</UtilityBarAction>
)}
<UtilityBarAction
iconSide="right"
iconSide="left"
iconType="refresh"
onClick={() => dispatch({ type: 'refresh' })}
onClick={() => reFetchRulesData(true)}
>
{i18n.REFRESH}
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
<EuiBasicTable
<MyEuiBasicTable
columns={columns}
isSelectable={!hasNoPermissions ?? false}
itemId="id"
items={tableData}
items={rules ?? []}
noItemsMessage={emptyPrompt}
onChange={tableOnChangeCallback}
pagination={{
@ -326,7 +327,8 @@ export const AllRules = React.memo<AllRulesProps>(
totalItemCount: pagination.total,
pageSizeOptions: [5, 10, 20, 50, 100, 200, 300],
}}
sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }}
ref={tableRef}
sorting={{ sort: { field: 'enabled', direction: filterOptions.sortOrder } }}
selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps}
/>
</>

View file

@ -4,34 +4,30 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiBasicTable } from '@elastic/eui';
import {
FilterOptions,
PaginationOptions,
Rule,
} from '../../../../containers/detection_engine/rules';
import { TableData } from '../types';
import { formatRules } from './helpers';
type LoadingRuleAction = 'duplicate' | 'enable' | 'disable' | 'export' | 'delete' | null;
export interface State {
isLoading: boolean;
rules: Rule[];
selectedItems: TableData[];
pagination: PaginationOptions;
exportRuleIds: string[];
filterOptions: FilterOptions;
refreshToggle: boolean;
tableData: TableData[];
exportPayload?: Rule[];
loadingRuleIds: string[];
loadingRulesAction: LoadingRuleAction;
pagination: PaginationOptions;
rules: Rule[];
selectedRuleIds: string[];
}
export type Action =
| { type: 'refresh' }
| { type: 'loading'; isLoading: boolean }
| { type: 'deleteRules'; rules: Rule[] }
| { type: 'duplicate'; rule: Rule }
| { type: 'setExportPayload'; exportPayload?: Rule[] }
| { type: 'setSelected'; selectedItems: TableData[] }
| { type: 'updateLoading'; ids: string[]; isLoading: boolean }
| { type: 'updateRules'; rules: Rule[]; pagination?: PaginationOptions }
| { type: 'exportRuleIds'; ids: string[] }
| { type: 'loadingRuleIds'; ids: string[]; actionType: LoadingRuleAction }
| { type: 'selectedRuleIds'; ids: string[] }
| { type: 'setRules'; rules: Rule[] }
| { type: 'updateRules'; rules: Rule[] }
| { type: 'updatePagination'; pagination: Partial<PaginationOptions> }
| {
type: 'updateFilterOptions';
@ -40,53 +36,70 @@ export type Action =
}
| { type: 'failure' };
export const allRulesReducer = (state: State, action: Action): State => {
export const allRulesReducer = (
tableRef: React.MutableRefObject<EuiBasicTable<unknown> | undefined>
) => (state: State, action: Action): State => {
switch (action.type) {
case 'refresh': {
case 'exportRuleIds': {
return {
...state,
refreshToggle: !state.refreshToggle,
loadingRuleIds: action.ids,
loadingRulesAction: 'export',
exportRuleIds: action.ids,
};
}
case 'loadingRuleIds': {
return {
...state,
loadingRuleIds: action.actionType == null ? [] : [...state.loadingRuleIds, ...action.ids],
loadingRulesAction: action.actionType,
};
}
case 'selectedRuleIds': {
return {
...state,
selectedRuleIds: action.ids,
};
}
case 'setRules': {
if (
tableRef != null &&
tableRef.current != null &&
tableRef.current.changeSelection != null
) {
tableRef.current.changeSelection([]);
}
return {
...state,
rules: action.rules,
selectedRuleIds: [],
loadingRuleIds: [],
loadingRulesAction: null,
};
}
case 'updateRules': {
// If pagination included, this was a hard refresh
if (action.pagination) {
if (state.rules != null) {
const ruleIds = state.rules.map(r => r.id);
const updatedRules = action.rules.reduce((rules, updatedRule) => {
let newRules = rules;
if (ruleIds.includes(updatedRule.id)) {
newRules = newRules.map(r => (updatedRule.id === r.id ? updatedRule : r));
} else {
newRules = [...newRules, updatedRule];
}
return newRules;
}, state.rules);
const updatedRuleIds = action.rules.map(r => r.id);
const newLoadingRuleIds = state.loadingRuleIds.filter(id => !updatedRuleIds.includes(id));
return {
...state,
rules: action.rules,
pagination: action.pagination,
tableData: formatRules(action.rules),
rules: updatedRules,
loadingRuleIds: newLoadingRuleIds,
loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction,
};
}
const ruleIds = state.rules.map(r => r.rule_id);
const updatedRules = action.rules.reverse().reduce((rules, updatedRule) => {
let newRules = rules;
if (ruleIds.includes(updatedRule.rule_id)) {
newRules = newRules.map(r => (updatedRule.rule_id === r.rule_id ? updatedRule : r));
} else {
newRules = [...newRules, updatedRule];
}
return newRules;
}, state.rules);
// Update enabled on selectedItems so that batch actions show correct available actions
const updatedRuleIdToState = action.rules.reduce<Record<string, boolean>>(
(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,
};
return state;
}
case 'updatePagination': {
return {
@ -110,51 +123,12 @@ export const allRulesReducer = (state: State, action: Action): State => {
},
};
}
case 'deleteRules': {
const deletedRuleIds = action.rules.map(r => r.rule_id);
const updatedRules = state.rules.reduce<Rule[]>(
(rules, rule) => (deletedRuleIds.includes(rule.rule_id) ? rules : [...rules, rule]),
[]
);
return {
...state,
rules: updatedRules,
tableData: formatRules(updatedRules),
refreshToggle: !state.refreshToggle,
};
}
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;
}

View file

@ -41,7 +41,11 @@ const RulesTableFiltersComponent = ({
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showCustomRules, setShowCustomRules] = useState<boolean>(false);
const [showElasticRules, setShowElasticRules] = useState<boolean>(false);
const [isLoadingTags, tags] = useTags();
const [isLoadingTags, tags, reFetchTags] = useTags();
useEffect(() => {
reFetchTags();
}, [rulesCustomInstalled, rulesInstalled]);
// Propagate filter changes to parent
useEffect(() => {

View file

@ -58,6 +58,7 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = `
<RuleDownloader
filename="rules_export.ndjson"
onExportComplete={[Function]}
ruleIds={Array []}
/>
</Fragment>
`;

View file

@ -48,7 +48,7 @@ const RuleActionsOverflowComponent = ({
userHasNoPermissions,
}: RuleActionsOverflowComponentProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [rulesToExport, setRulesToExport] = useState<Rule[] | undefined>(undefined);
const [rulesToExport, setRulesToExport] = useState<string[]>([]);
const history = useHistory();
const [, dispatchToaster] = useStateToaster();
@ -66,7 +66,7 @@ const RuleActionsOverflowComponent = ({
disabled={userHasNoPermissions}
onClick={async () => {
setIsPopoverOpen(false);
await duplicateRulesAction([rule], noop, dispatchToaster);
await duplicateRulesAction([rule], [rule.id], noop, dispatchToaster);
}}
>
{i18nActions.DUPLICATE_RULE}
@ -75,9 +75,9 @@ const RuleActionsOverflowComponent = ({
key={i18nActions.EXPORT_RULE}
icon="indexEdit"
disabled={userHasNoPermissions || rule.immutable}
onClick={async () => {
onClick={() => {
setIsPopoverOpen(false);
setRulesToExport([rule]);
setRulesToExport([rule.id]);
}}
>
{i18nActions.EXPORT_RULE}
@ -131,7 +131,7 @@ const RuleActionsOverflowComponent = ({
</EuiPopover>
<RuleDownloader
filename={`${i18nActions.EXPORT_FILENAME}.ndjson`}
rules={rulesToExport}
ruleIds={rulesToExport}
onExportComplete={exportCount => {
displaySuccessToast(
i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount),

View file

@ -7,7 +7,7 @@
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 { exportRules } from '../../../../../containers/detection_engine/rules';
import { displayErrorToast, useStateToaster } from '../../../../../components/toasters';
import * as i18n from './translations';
@ -17,7 +17,7 @@ const InvisibleAnchor = styled.a`
export interface RuleDownloaderProps {
filename: string;
rules?: Rule[];
ruleIds?: string[];
onExportComplete: (exportCount: number) => void;
}
@ -30,7 +30,7 @@ export interface RuleDownloaderProps {
*/
export const RuleDownloaderComponent = ({
filename,
rules,
ruleIds,
onExportComplete,
}: RuleDownloaderProps) => {
const anchorRef = useRef<HTMLAnchorElement>(null);
@ -41,10 +41,10 @@ export const RuleDownloaderComponent = ({
const abortCtrl = new AbortController();
async function exportData() {
if (anchorRef && anchorRef.current && rules != null) {
if (anchorRef && anchorRef.current && ruleIds != null && ruleIds.length > 0) {
try {
const exportResponse = await exportRules({
ruleIds: rules.map(r => r.rule_id),
ruleIds,
signal: abortCtrl.signal,
});
@ -61,7 +61,7 @@ export const RuleDownloaderComponent = ({
window.URL.revokeObjectURL(objectURL);
}
onExportComplete(rules.length);
onExportComplete(ruleIds.length);
}
} catch (error) {
if (isSubscribed) {
@ -77,7 +77,7 @@ export const RuleDownloaderComponent = ({
isSubscribed = false;
abortCtrl.abort();
};
}, [rules]);
}, [ruleIds]);
return <InvisibleAnchor ref={anchorRef} />;
};

View file

@ -26,11 +26,10 @@ import { UpdatePrePackagedRulesCallOut } from './components/pre_packaged_rules/u
import { getPrePackagedRuleStatus, redirectToDetections } from './helpers';
import * as i18n from './translations';
type Func = () => void;
type Func = (refreshPrePackagedRule?: boolean) => void;
const RulesPageComponent: React.FC = () => {
const [showImportModal, setShowImportModal] = useState(false);
const [importCompleteToggle, setImportCompleteToggle] = useState(false);
const refreshRulesData = useRef<null | Func>(null);
const {
loading,
@ -67,14 +66,18 @@ const RulesPageComponent: React.FC = () => {
const userHasNoPermissions =
canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false;
const handleRefreshRules = useCallback(async () => {
if (refreshRulesData.current != null) {
refreshRulesData.current(true);
}
}, [refreshRulesData]);
const handleCreatePrePackagedRules = useCallback(async () => {
if (createPrePackagedRules != null) {
await createPrePackagedRules();
if (refreshRulesData.current != null) {
refreshRulesData.current();
}
handleRefreshRules();
}
}, [createPrePackagedRules, refreshRulesData]);
}, [createPrePackagedRules, handleRefreshRules]);
const handleRefetchPrePackagedRulesStatus = useCallback(() => {
if (refetchPrePackagedRulesStatus != null) {
@ -96,7 +99,7 @@ const RulesPageComponent: React.FC = () => {
<ImportRuleModal
showModal={showImportModal}
closeModal={() => setShowImportModal(false)}
importComplete={() => setImportCompleteToggle(!importCompleteToggle)}
importComplete={handleRefreshRules}
/>
<WrapperPage>
<DetectionEngineHeaderPage
@ -166,7 +169,6 @@ const RulesPageComponent: React.FC = () => {
loading={loading || prePackagedRuleLoading}
loadingCreatePrePackagedRules={loadingCreatePrePackagedRules}
hasNoPermissions={userHasNoPermissions}
importCompleteToggle={importCompleteToggle}
refetchPrePackagedRulesStatus={handleRefetchPrePackagedRulesStatus}
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}

View file

@ -5,7 +5,6 @@
*/
import { Filter } from '../../../../../../../../src/plugins/data/common';
import { Rule } from '../../../containers/detection_engine/rules';
import { FieldValueQueryBar } from './components/query_bar';
import { FormData, FormHook } from '../../shared_imports';
import { FieldValueTimeline } from './components/pick_timeline';
@ -23,24 +22,6 @@ export interface EuiBasicTableOnChange {
sort?: EuiBasicTableSortTypes;
}
export interface TableData {
id: string;
immutable: boolean;
rule_id: string;
rule: {
href: string;
name: string;
};
risk_score: number;
severity: string;
tags: string[];
activate: boolean;
isLoading: boolean;
sourceRule: Rule;
status?: string | null;
statusDate?: string | null;
}
export enum RuleStep {
defineRule = 'define-rule',
aboutRule = 'about-rule',

View file

@ -5,7 +5,6 @@
*/
import Hapi from 'hapi';
import { countBy } from 'lodash/fp';
import uuid from 'uuid';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
@ -45,8 +44,7 @@ export const createCreateRulesBulkRoute = (
}
const ruleDefinitions = request.payload;
const mappedDuplicates = countBy('rule_id', ruleDefinitions);
const dupes = getDuplicates(mappedDuplicates);
const dupes = getDuplicates(ruleDefinitions, 'rule_id');
const rules = await Promise.all(
ruleDefinitions

View file

@ -20,7 +20,7 @@ import {
} from './utils';
import { getResult } from '../__mocks__/request_responses';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
import { OutputRuleAlertRest, ImportRuleAlertRest } from '../../types';
import { OutputRuleAlertRest, ImportRuleAlertRest, RuleAlertParamsRest } from '../../types';
import { BulkError, ImportSuccessError } from '../utils';
import { sampleRule } from '../../signals/__mocks__/es_results';
import { getSimpleRule } from '../__mocks__/utils';
@ -1222,20 +1222,32 @@ describe('utils', () => {
describe('getDuplicates', () => {
test("returns array of ruleIds showing the duplicate keys of 'value2' and 'value3'", () => {
const output = getDuplicates({
value1: 1,
value2: 2,
value3: 2,
});
const output = getDuplicates(
[
{ rule_id: 'value1' },
{ rule_id: 'value2' },
{ rule_id: 'value2' },
{ rule_id: 'value3' },
{ rule_id: 'value3' },
{},
{},
] as RuleAlertParamsRest[],
'rule_id'
);
const expected = ['value2', 'value3'];
expect(output).toEqual(expected);
});
test('returns null when given a map of no duplicates', () => {
const output = getDuplicates({
value1: 1,
value2: 1,
value3: 1,
});
const output = getDuplicates(
[
{ rule_id: 'value1' },
{ rule_id: 'value2' },
{ rule_id: 'value3' },
{},
{},
] as RuleAlertParamsRest[],
'rule_id'
);
const expected: string[] = [];
expect(output).toEqual(expected);
});

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { pickBy } from 'lodash/fp';
import { Dictionary } from 'lodash';
import { pickBy, countBy } from 'lodash/fp';
import { SavedObject } from 'kibana/server';
import uuid from 'uuid';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
@ -18,7 +17,7 @@ import {
isRuleStatusFindTypes,
isRuleStatusSavedObjectType,
} from '../../rules/types';
import { OutputRuleAlertRest, ImportRuleAlertRest } from '../../types';
import { OutputRuleAlertRest, ImportRuleAlertRest, RuleAlertParamsRest } from '../../types';
import {
createBulkErrorObject,
BulkError,
@ -224,10 +223,14 @@ export const transformOrImportError = (
}
};
export const getDuplicates = (lodashDict: Dictionary<number>): string[] => {
const hasDuplicates = Object.values(lodashDict).some(i => i > 1);
export const getDuplicates = (ruleDefinitions: RuleAlertParamsRest[], by: 'rule_id'): string[] => {
const mappedDuplicates = countBy(
by,
ruleDefinitions.filter(r => r[by] != null)
);
const hasDuplicates = Object.values(mappedDuplicates).some(i => i > 1);
if (hasDuplicates) {
return Object.keys(lodashDict).filter(key => lodashDict[key] > 1);
return Object.keys(mappedDuplicates).filter(key => mappedDuplicates[key] > 1);
}
return [];
};