Add the ability to filter by index pattern to the rules management table (#128245)

This commit is contained in:
Dmitrii Shevchenko 2022-03-29 10:18:33 +02:00 committed by GitHub
parent 52f0bf0a6a
commit eb51ea64c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 220 additions and 302 deletions

View file

@ -13,7 +13,7 @@ import {
FOURTH_RULE,
RULES_TABLE,
pageSelector,
RULES_TABLE_REFRESH_INDICATOR,
RULES_ROW,
} from '../../screens/alerts_detection_rules';
import { goToManageAlertsDetectionRules, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts';
@ -90,14 +90,10 @@ describe('Alerts detection rules', () => {
.invoke('text')
.then((ruleNameFirstPage) => {
goToPage(2);
cy.get(RULES_TABLE_REFRESH_INDICATOR).should('not.exist');
cy.get(RULES_TABLE)
.find(RULE_NAME)
.first()
.invoke('text')
.should((ruleNameSecondPage) => {
expect(ruleNameFirstPage).not.to.eq(ruleNameSecondPage);
});
// Check that the rules table shows at least one row
cy.get(RULES_TABLE).find(RULES_ROW).should('have.length.gte', 1);
// Check that the rules table doesn't show the rule from the first page
cy.get(RULES_TABLE).should('not.contain', ruleNameFirstPage);
});
cy.get(RULES_TABLE)

View file

@ -154,7 +154,7 @@ export const goToRuleDetails = () => {
};
export const goToTheRuleDetailsOf = (ruleName: string) => {
cy.get(RULE_NAME).contains(ruleName).click();
cy.get(RULE_NAME).should('contain', ruleName).contains(ruleName).click();
};
export const loadPrebuiltDetectionRules = () => {

View file

@ -5,12 +5,10 @@
* 2.0.
*/
import { Dispatch, SetStateAction } from 'react';
export const toggleSelectedGroup = (
group: string,
selectedGroups: string[],
setSelectedGroups: Dispatch<SetStateAction<string[]>>
setSelectedGroups: (groups: string[]) => void
): void => {
const selectedGroupIndex = selectedGroups.indexOf(group);
const updatedSelectedGroups = [...selectedGroups];

View file

@ -178,7 +178,7 @@ describe('RuleActionsOverflow', () => {
).toEqual(false);
});
test('it calls duplicateRulesAction when rules-details-duplicate-rule is clicked', () => {
test('it calls duplicate action when rules-details-duplicate-rule is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow
rule={mockRule('id')}
@ -195,7 +195,7 @@ describe('RuleActionsOverflow', () => {
);
});
test('it calls duplicateRulesAction with the rule and rule.id when rules-details-duplicate-rule is clicked', () => {
test('it calls duplicate action with the rule and rule.id when rules-details-duplicate-rule is clicked', () => {
const rule = mockRule('id');
const wrapper = mount(
<RuleActionsOverflow rule={rule} userHasPermissions canDuplicateRuleWithActions={true} />
@ -210,7 +210,7 @@ describe('RuleActionsOverflow', () => {
});
});
test('it calls editRuleAction after the rule is duplicated', async () => {
test('it navigates to edit page after the rule is duplicated', async () => {
const rule = mockRule('id');
const ruleDuplicate = mockRule('newRule');
executeRulesBulkActionMock.mockImplementation(() =>

View file

@ -142,7 +142,37 @@ describe('Detections Rules API', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', {
method: 'GET',
query: {
filter: 'alert.attributes.name: hello world',
filter:
'(alert.attributes.name: "hello world" OR alert.attributes.params.index: "hello world" OR alert.attributes.params.threat.tactic.id: "hello world" OR alert.attributes.params.threat.tactic.name: "hello world" OR alert.attributes.params.threat.technique.id: "hello world" OR alert.attributes.params.threat.technique.name: "hello world")',
page: 1,
per_page: 20,
sort_field: 'enabled',
sort_order: 'desc',
},
signal: abortCtrl.signal,
});
});
test('check parameter url, query with a filter get escaped correctly', async () => {
await fetchRules({
filterOptions: {
filter: '" OR (foo:bar)',
showCustomRules: false,
showElasticRules: false,
tags: [],
},
sortingOptions: {
field: 'enabled',
order: 'desc',
},
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', {
method: 'GET',
query: {
filter:
'(alert.attributes.name: "\\" OR (foo:bar)" OR alert.attributes.params.index: "\\" OR (foo:bar)" OR alert.attributes.params.threat.tactic.id: "\\" OR (foo:bar)" OR alert.attributes.params.threat.tactic.name: "\\" OR (foo:bar)" OR alert.attributes.params.threat.technique.id: "\\" OR (foo:bar)" OR alert.attributes.params.threat.technique.name: "\\" OR (foo:bar)")',
page: 1,
per_page: 20,
sort_field: 'enabled',
@ -226,7 +256,7 @@ describe('Detections Rules API', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', {
method: 'GET',
query: {
filter: 'alert.attributes.tags: "hello" AND alert.attributes.tags: "world"',
filter: 'alert.attributes.tags:("hello" AND "world")',
page: 1,
per_page: 20,
sort_field: 'enabled',
@ -254,7 +284,7 @@ describe('Detections Rules API', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', {
method: 'GET',
query: {
filter: 'alert.attributes.tags: "hello" AND alert.attributes.tags: "world"',
filter: 'alert.attributes.tags:("hello" AND "world")',
page: 1,
per_page: 20,
sort_field: 'updatedAt',
@ -353,7 +383,7 @@ describe('Detections Rules API', () => {
method: 'GET',
query: {
filter:
'alert.attributes.name: ruleName AND alert.attributes.tags: "__internal_immutable:false" AND alert.attributes.tags: "__internal_immutable:true" AND (alert.attributes.tags: "hello" AND alert.attributes.tags: "world")',
'alert.attributes.tags: "__internal_immutable:false" AND alert.attributes.tags: "__internal_immutable:true" AND alert.attributes.tags:("hello" AND "world") AND (alert.attributes.name: "ruleName" OR alert.attributes.params.index: "ruleName" OR alert.attributes.params.threat.tactic.id: "ruleName" OR alert.attributes.params.threat.tactic.name: "ruleName" OR alert.attributes.params.threat.technique.id: "ruleName" OR alert.attributes.params.threat.technique.name: "ruleName")',
page: 1,
per_page: 20,
sort_field: 'enabled',

View file

@ -26,7 +26,17 @@ describe('convertRulesFilterToKQL', () => {
it('handles presence of "filter" properly', () => {
const kql = convertRulesFilterToKQL({ ...filterOptions, filter: 'foo' });
expect(kql).toBe('alert.attributes.name: foo');
expect(kql).toBe(
'(alert.attributes.name: "foo" OR alert.attributes.params.index: "foo" OR alert.attributes.params.threat.tactic.id: "foo" OR alert.attributes.params.threat.tactic.name: "foo" OR alert.attributes.params.threat.technique.id: "foo" OR alert.attributes.params.threat.technique.name: "foo")'
);
});
it('escapes "filter" value properly', () => {
const kql = convertRulesFilterToKQL({ ...filterOptions, filter: '" OR (foo: bar)' });
expect(kql).toBe(
'(alert.attributes.name: "\\" OR (foo: bar)" OR alert.attributes.params.index: "\\" OR (foo: bar)" OR alert.attributes.params.threat.tactic.id: "\\" OR (foo: bar)" OR alert.attributes.params.threat.tactic.name: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.id: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.name: "\\" OR (foo: bar)")'
);
});
it('handles presence of "showCustomRules" properly', () => {
@ -44,7 +54,7 @@ describe('convertRulesFilterToKQL', () => {
it('handles presence of "tags" properly', () => {
const kql = convertRulesFilterToKQL({ ...filterOptions, tags: ['tag1', 'tag2'] });
expect(kql).toBe('alert.attributes.tags: "tag1" AND alert.attributes.tags: "tag2"');
expect(kql).toBe('alert.attributes.tags:("tag1" AND "tag2")');
});
it('handles combination of different properties properly', () => {
@ -56,7 +66,7 @@ describe('convertRulesFilterToKQL', () => {
});
expect(kql).toBe(
`alert.attributes.name: foo AND alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true" AND (alert.attributes.tags: "tag1" AND alert.attributes.tags: "tag2")`
`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true" AND alert.attributes.tags:(\"tag1\" AND \"tag2\") AND (alert.attributes.name: \"foo\" OR alert.attributes.params.index: \"foo\" OR alert.attributes.params.threat.tactic.id: \"foo\" OR alert.attributes.params.threat.tactic.name: \"foo\" OR alert.attributes.params.threat.technique.id: \"foo\" OR alert.attributes.params.threat.technique.name: \"foo\")`
);
});
});

View file

@ -6,8 +6,18 @@
*/
import { INTERNAL_IMMUTABLE_KEY } from '../../../../../common/constants';
import { escapeKuery } from '../../../../common/lib/keury';
import { FilterOptions } from './types';
const SEARCHABLE_RULE_PARAMS = [
'alert.attributes.name',
'alert.attributes.params.index',
'alert.attributes.params.threat.tactic.id',
'alert.attributes.params.threat.tactic.name',
'alert.attributes.params.threat.technique.id',
'alert.attributes.params.threat.technique.name',
];
/**
* Convert rules filter options object to KQL query
*
@ -15,27 +25,35 @@ import { FilterOptions } from './types';
*
* @returns KQL string
*/
export const convertRulesFilterToKQL = (filterOptions: FilterOptions): string => {
const showCustomRuleFilter = filterOptions.showCustomRules
? [`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`]
: [];
const showElasticRuleFilter = filterOptions.showElasticRules
? [`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`]
: [];
const filtersWithoutTags = [
...(filterOptions.filter.length ? [`alert.attributes.name: ${filterOptions.filter}`] : []),
...showCustomRuleFilter,
...showElasticRuleFilter,
].join(' AND ');
export const convertRulesFilterToKQL = ({
showCustomRules,
showElasticRules,
filter,
tags,
}: FilterOptions): string => {
const filters: string[] = [];
const tags = filterOptions.tags
.map((t) => `alert.attributes.tags: "${t.replace(/"/g, '\\"')}"`)
.join(' AND ');
if (showCustomRules) {
filters.push(`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`);
}
const filterString =
filtersWithoutTags !== '' && tags !== ''
? `${filtersWithoutTags} AND (${tags})`
: filtersWithoutTags + tags;
if (showElasticRules) {
filters.push(`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`);
}
return filterString;
if (tags.length > 0) {
filters.push(
`alert.attributes.tags:(${tags.map((tag) => `"${escapeKuery(tag)}"`).join(' AND ')})`
);
}
if (filter.length) {
const searchQuery = SEARCHABLE_RULE_PARAMS.map(
(param) => `${param}: "${escapeKuery(filter)}"`
).join(' OR ');
filters.push(`(${searchQuery})`);
}
return filters.join(' AND ');
};

View file

@ -81,25 +81,6 @@ export const executeRulesBulkAction = async ({
}
};
export const initRulesBulkAction = (params: Omit<ExecuteRulesBulkActionArgs, 'search'>) => {
const byQuery = (query: string) =>
executeRulesBulkAction({
...params,
search: { query },
});
const byIds = (ids: string[]) =>
executeRulesBulkAction({
...params,
search: { ids },
});
return {
byQuery,
byIds,
};
};
function defaultErrorHandler(toasts: UseAppToasts, action: BulkAction, error: HTTPError) {
// if response doesn't have number of failed rules, it means the whole bulk action failed
// and general error toast will be shown. Otherwise - error toast for partial failure

View file

@ -30,7 +30,7 @@ import { canEditRuleWithActions } from '../../../../../../common/utils/privilege
import { useRulesTableContext } from '../rules_table/rules_table_context';
import * as detectionI18n from '../../../translations';
import * as i18n from '../../translations';
import { executeRulesBulkAction, initRulesBulkAction } from '../actions';
import { executeRulesBulkAction } from '../actions';
import { useHasActionsPrivileges } from '../use_has_actions_privileges';
import { useHasMlPermissions } from '../use_has_ml_permissions';
import { getCustomRulesCountFromCache } from './use_custom_rules_count';
@ -239,26 +239,23 @@ export const useBulkActions = ({
);
}, 5 * 1000);
const rulesBulkAction = initRulesBulkAction({
await executeRulesBulkAction({
visibleRuleIds: selectedRuleIds,
action: BulkAction.edit,
setLoadingRules,
toasts,
payload: { edit: [editPayload] },
onFinish: () => hideWarningToast(),
search: isAllSelected
? {
query: convertRulesFilterToKQL({
...filterOptions,
showCustomRules: true, // only edit custom rules, as elastic rule are immutable
}),
}
: { ids: customSelectedRuleIds },
});
// only edit custom rules, as elastic rule are immutable
if (isAllSelected) {
const customRulesOnlyFilterQuery = convertRulesFilterToKQL({
...filterOptions,
showCustomRules: true,
});
await rulesBulkAction.byQuery(customRulesOnlyFilterQuery);
} else {
await rulesBulkAction.byIds(customSelectedRuleIds);
}
isBulkEditFinished = true;
invalidateRules();
if (getIsMounted()) {

View file

@ -16,7 +16,7 @@ import {
SortingOptions,
} from '../../../../../containers/detection_engine/rules/types';
import { useFindRules } from './use_find_rules';
import { getRulesComparator, getRulesPredicate } from './utils';
import { getRulesComparator } from './utils';
export interface RulesTableState {
/**
@ -114,7 +114,7 @@ export interface LoadingRules {
export interface RulesTableActions {
reFetchRules: ReturnType<typeof useFindRules>['refetch'];
setFilterOptions: React.Dispatch<React.SetStateAction<FilterOptions>>;
setFilterOptions: (newFilter: Partial<FilterOptions>) => void;
setIsAllSelected: React.Dispatch<React.SetStateAction<boolean>>;
setIsInMemorySorting: (value: boolean) => void;
setIsRefreshOn: React.Dispatch<React.SetStateAction<boolean>>;
@ -186,6 +186,13 @@ export const RulesTableContextProvider = ({
const pagination = useMemo(() => ({ page, perPage }), [page, perPage]);
const handleFilterOptionsChange = useCallback((newFilter: Partial<FilterOptions>) => {
setFilterOptions((currentFilter) => ({ ...currentFilter, ...newFilter }));
setPage(1);
setSelectedRuleIds([]);
setIsAllSelected(false);
}, []);
// Fetch rules
const {
data: { rules, total } = { rules: [], total: 0 },
@ -210,15 +217,10 @@ export const RulesTableContextProvider = ({
}
}, [isFetched, isRefetching, refetchPrePackagedRulesStatus]);
// Filter rules
const filteredRules = isInMemorySorting ? rules.filter(getRulesPredicate(filterOptions)) : rules;
// Paginate and sort rules
const rulesToDisplay = isInMemorySorting
? filteredRules
.sort(getRulesComparator(sortingOptions))
.slice((page - 1) * perPage, page * perPage)
: filteredRules;
? rules.sort(getRulesComparator(sortingOptions)).slice((page - 1) * perPage, page * perPage)
: rules;
const providerValue = useMemo(
() => ({
@ -227,7 +229,7 @@ export const RulesTableContextProvider = ({
pagination: {
page,
perPage,
total: isInMemorySorting ? filteredRules.length : total,
total: isInMemorySorting ? rules.length : total,
},
filterOptions,
isActionInProgress,
@ -246,7 +248,7 @@ export const RulesTableContextProvider = ({
},
actions: {
reFetchRules: refetch,
setFilterOptions,
setFilterOptions: handleFilterOptionsChange,
setIsAllSelected,
setIsInMemorySorting: toggleInMemorySorting,
setIsRefreshOn,
@ -260,7 +262,7 @@ export const RulesTableContextProvider = ({
[
dataUpdatedAt,
filterOptions,
filteredRules.length,
handleFilterOptionsChange,
isActionInProgress,
isAllSelected,
isFetched,
@ -274,6 +276,7 @@ export const RulesTableContextProvider = ({
page,
perPage,
refetch,
rules.length,
rulesToDisplay,
selectedRuleIds,
sortingOptions,

View file

@ -31,8 +31,12 @@ export const useFindRules = (args: UseFindRulesArgs) => {
// Use this query result when isInMemorySorting = true
const allRules = useFindRulesQuery(
['all'],
{ pagination: { page: 1, perPage: MAX_RULES_PER_PAGE } },
{ refetchInterval, enabled: isInMemorySorting }
{ pagination: { page: 1, perPage: MAX_RULES_PER_PAGE }, filterOptions },
{
refetchInterval,
enabled: isInMemorySorting,
keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change
}
);
// Use this query result when isInMemorySorting = false

View file

@ -6,11 +6,7 @@
*/
import { get } from 'lodash';
import {
FilterOptions,
Rule,
SortingOptions,
} from '../../../../../containers/detection_engine/rules/types';
import { Rule, SortingOptions } from '../../../../../containers/detection_engine/rules/types';
/**
* Returns a comparator function to be used with .sort()
@ -79,29 +75,3 @@ const compareNumbers = (a: number, b: number, direction: number) => {
}
return 0;
};
/**
* Returns a predicate function to be used with .filter()
*
* @param filterOptions Current table filter
*/
export function getRulesPredicate(filterOptions: FilterOptions) {
return (rule: Rule) => {
if (
filterOptions.filter &&
!rule.name.toLowerCase().includes(filterOptions.filter.toLowerCase())
) {
return false;
}
if (filterOptions.showCustomRules && rule.immutable) {
return false;
}
if (filterOptions.showElasticRules && !rule.immutable) {
return false;
}
if (filterOptions.tags.length && !filterOptions.tags.every((tag) => rule.tags.includes(tag))) {
return false;
}
return true;
};
}

View file

@ -7,65 +7,38 @@
import React from 'react';
import { mount } from 'enzyme';
import { act } from '@testing-library/react';
import { RulesTableFilters } from './rules_table_filters';
import { useAppToastsMock } from '../../../../../../common/hooks/use_app_toasts.mock';
import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts';
jest.mock('../../../../../../common/hooks/use_app_toasts');
import { TestProviders } from '../../../../../../common/mock';
jest.mock('../rules_table/rules_table_context');
describe('RulesTableFilters', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.resetAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
});
it('renders no numbers next to rule type button filter if none exist', async () => {
await act(async () => {
const wrapper = mount(
<RulesTableFilters
onFilterChanged={jest.fn()}
rulesCustomInstalled={null}
rulesInstalled={null}
currentFilterTags={[]}
tags={[]}
isLoadingTags={false}
reFetchTags={() => ({})}
/>
);
const wrapper = mount(
<RulesTableFilters rulesCustomInstalled={null} rulesInstalled={null} allTags={[]} />,
{ wrappingComponent: TestProviders }
);
expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual(
'Elastic rules'
);
expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual(
'Custom rules'
);
});
expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual(
'Elastic rules'
);
expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual(
'Custom rules'
);
});
it('renders number of custom and prepackaged rules', async () => {
await act(async () => {
const wrapper = mount(
<RulesTableFilters
onFilterChanged={jest.fn()}
rulesCustomInstalled={10}
rulesInstalled={9}
currentFilterTags={[]}
tags={[]}
isLoadingTags={false}
reFetchTags={() => ({})}
/>
);
const wrapper = mount(
<RulesTableFilters rulesCustomInstalled={10} rulesInstalled={9} allTags={[]} />,
{ wrappingComponent: TestProviders }
);
expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual(
'Elastic rules (9)'
);
expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual(
'Custom rules (10)'
);
});
expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual(
'Elastic rules (9)'
);
expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual(
'Custom rules (10)'
);
});
});

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
EuiFieldSearch,
EuiFilterButton,
@ -15,76 +13,63 @@ import {
EuiFlexItem,
} from '@elastic/eui';
import { isEqual } from 'lodash/fp';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import * as i18n from '../../translations';
import { FilterOptions } from '../../../../../containers/detection_engine/rules';
import { useRulesTableContext } from '../rules_table/rules_table_context';
import { TagsFilterPopover } from './tags_filter_popover';
const FilterWrapper = styled(EuiFlexGroup)`
margin-bottom: ${({ theme }) => theme.eui.euiSizeXS};
`;
interface RulesTableFiltersProps {
onFilterChanged: (filterOptions: Partial<FilterOptions>) => void;
rulesCustomInstalled: number | null;
rulesInstalled: number | null;
currentFilterTags: string[];
tags: string[];
isLoadingTags: boolean;
reFetchTags: () => void;
allTags: string[];
}
/**
* Collection of filters for filtering data within the RulesTable. Contains search bar, Elastic/Custom
* Rules filter button toggle, and tag selection
*
* @param onFilterChanged change listener to be notified on filter changes
*/
const RulesTableFiltersComponent = ({
onFilterChanged,
rulesCustomInstalled,
rulesInstalled,
currentFilterTags,
tags,
isLoadingTags,
reFetchTags,
allTags,
}: RulesTableFiltersProps) => {
const [filter, setFilter] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showCustomRules, setShowCustomRules] = useState<boolean>(false);
const [showElasticRules, setShowElasticRules] = useState<boolean>(false);
const {
state: { filterOptions },
actions: { setFilterOptions },
} = useRulesTableContext();
useEffect(() => {
reFetchTags();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rulesCustomInstalled, rulesInstalled]);
const { showCustomRules, showElasticRules, tags: selectedTags } = filterOptions;
// Propagate filter changes to parent
useEffect(() => {
onFilterChanged({ filter, showCustomRules, showElasticRules, tags: selectedTags });
}, [filter, selectedTags, showCustomRules, showElasticRules, onFilterChanged]);
const handleOnSearch = useCallback((filterString) => setFilter(filterString.trim()), [setFilter]);
const handleOnSearch = useCallback(
(filterString) => setFilterOptions({ filter: filterString.trim() }),
[setFilterOptions]
);
const handleElasticRulesClick = useCallback(() => {
setShowElasticRules(!showElasticRules);
setShowCustomRules(false);
}, [setShowElasticRules, showElasticRules, setShowCustomRules]);
setFilterOptions({ showElasticRules: !showElasticRules, showCustomRules: false });
}, [setFilterOptions, showElasticRules]);
const handleCustomRulesClick = useCallback(() => {
setShowCustomRules(!showCustomRules);
setShowElasticRules(false);
}, [setShowElasticRules, showCustomRules, setShowCustomRules]);
setFilterOptions({ showCustomRules: !showCustomRules, showElasticRules: false });
}, [setFilterOptions, showCustomRules]);
const handleSelectedTags = useCallback(
(newTags) => {
(newTags: string[]) => {
if (!isEqual(newTags, selectedTags)) {
setSelectedTags(newTags);
setFilterOptions({ tags: newTags });
}
},
[selectedTags]
[selectedTags, setFilterOptions]
);
return (
<EuiFlexGroup gutterSize="m" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<FilterWrapper gutterSize="m" justifyContent="flexEnd">
<EuiFlexItem grow>
<EuiFieldSearch
aria-label={i18n.SEARCH_RULES}
fullWidth
@ -93,15 +78,12 @@ const RulesTableFiltersComponent = ({
onSearch={handleOnSearch}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<TagsFilterPopover
isLoading={isLoadingTags}
onSelectedTagsChanged={handleSelectedTags}
selectedTags={selectedTags}
tags={tags}
currentFilterTags={currentFilterTags}
tags={allTags}
data-test-subj="allRulesTagPopover"
/>
</EuiFilterGroup>
@ -123,14 +105,12 @@ const RulesTableFiltersComponent = ({
onClick={handleCustomRulesClick}
data-test-subj="showCustomRulesFilterButton"
>
<>
{i18n.CUSTOM_RULES}
{rulesCustomInstalled != null ? ` (${rulesCustomInstalled})` : ''}
</>
{i18n.CUSTOM_RULES}
{rulesCustomInstalled != null ? ` (${rulesCustomInstalled})` : ''}
</EuiFilterButton>
</EuiFilterGroup>
</EuiFlexItem>
</EuiFlexGroup>
</FilterWrapper>
);
};

View file

@ -13,13 +13,7 @@ import { TagsFilterPopover } from './tags_filter_popover';
describe('TagsFilterPopover', () => {
it('renders correctly', () => {
const wrapper = shallow(
<TagsFilterPopover
tags={[]}
selectedTags={[]}
onSelectedTagsChanged={jest.fn()}
currentFilterTags={[]}
isLoading={false}
/>
<TagsFilterPopover tags={[]} selectedTags={[]} onSelectedTagsChanged={jest.fn()} />
);
expect(wrapper.find('EuiPopover')).toHaveLength(1);

View file

@ -5,15 +5,7 @@
* 2.0.
*/
import React, {
ChangeEvent,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiFilterButton,
EuiFilterSelectItem,
@ -33,10 +25,7 @@ import { caseInsensitiveSort } from '../helpers';
interface TagsFilterPopoverProps {
selectedTags: string[];
tags: string[];
onSelectedTagsChanged: Dispatch<SetStateAction<string[]>>;
currentFilterTags: string[];
// eslint-disable-next-line react/no-unused-prop-types
isLoading: boolean; // TO DO reimplement?
onSelectedTagsChanged: (newTags: string[]) => void;
}
const PopoverContentWrapper = styled.div`
@ -64,11 +53,10 @@ const TagsFilterPopoverComponent = ({
tags,
selectedTags,
onSelectedTagsChanged,
currentFilterTags,
}: TagsFilterPopoverProps) => {
const sortedTags = useMemo(
() => caseInsensitiveSort(Array.from(new Set([...tags, ...currentFilterTags]))),
[tags, currentFilterTags]
() => caseInsensitiveSort(Array.from(new Set([...tags, ...selectedTags]))),
[selectedTags, tags]
);
const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');

View file

@ -14,19 +14,16 @@ import {
EuiLoadingContent,
EuiProgress,
} from '@elastic/eui';
import React, { useCallback, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { partition } from 'lodash/fp';
import { AllRulesTabs } from './rules_table_toolbar';
import { HeaderSection } from '../../../../../common/components/header_section';
import { Loader } from '../../../../../common/components/loader';
import { useBoolState } from '../../../../../common/hooks/use_bool_state';
import { useValueChanged } from '../../../../../common/hooks/use_value_changed';
import { useKibana } from '../../../../../common/lib/kibana';
import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt';
import {
CreatePreBuiltRules,
FilterOptions,
Rule,
RulesSortingFields,
} from '../../../../containers/detection_engine/rules';
@ -85,7 +82,6 @@ export const RulesTables = React.memo<RulesTableProps>(
rulesNotUpdated,
selectedTab,
}) => {
const { timelines } = useKibana().services;
const tableRef = useRef<EuiBasicTable>(null);
const rulesTableContext = useRulesTableContext();
@ -96,11 +92,9 @@ export const RulesTables = React.memo<RulesTableProps>(
isActionInProgress,
isAllSelected,
isFetched,
isFetching,
isLoading,
isRefetching,
isRefreshOn,
lastUpdated,
loadingRuleIds,
loadingRulesAction,
pagination,
@ -109,7 +103,6 @@ export const RulesTables = React.memo<RulesTableProps>(
},
actions: {
reFetchRules,
setFilterOptions,
setIsAllSelected,
setIsRefreshOn,
setPage,
@ -125,7 +118,12 @@ export const RulesTables = React.memo<RulesTableProps>(
rulesNotUpdated
);
const [isLoadingTags, tags, reFetchTags] = useTags();
const [, allTags, reFetchTags] = useTags();
useEffect(() => {
reFetchTags();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rulesCustomInstalled, rulesInstalled]);
const [isDeleteConfirmationVisible, showDeleteConfirmation, hideDeleteConfirmation] =
useBoolState();
@ -183,16 +181,6 @@ export const RulesTables = React.memo<RulesTableProps>(
[pagination]
);
const onFilterChangedCallback = useCallback(
(newFilter: Partial<FilterOptions>) => {
setFilterOptions((currentFilter) => ({ ...currentFilter, ...newFilter }));
setPage(1);
setSelectedRuleIds([]);
setIsAllSelected(false);
},
[setFilterOptions, setIsAllSelected, setPage, setSelectedRuleIds]
);
const tableOnChangeCallback = useCallback(
({ page, sort }: EuiBasicTableOnChange) => {
setSortingOptions({
@ -286,9 +274,11 @@ export const RulesTables = React.memo<RulesTableProps>(
}
: { 'data-test-subj': 'monitoring-table', columns: monitoringColumns };
const shouldShowLinearProgress = isFetched && isRefetching;
const shouldShowLoadingOverlay = (!isFetched && isRefetching) || isActionInProgress;
return (
<>
{isFetched && isRefetching && (
{shouldShowLinearProgress && (
<EuiProgress
data-test-subj="loadingRulesInfoProgress"
size="xs"
@ -296,30 +286,16 @@ export const RulesTables = React.memo<RulesTableProps>(
color="accent"
/>
)}
{((!isFetched && isRefetching) || isActionInProgress) && (
{shouldShowLoadingOverlay && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
<HeaderSection
split
growLeftSplit={false}
title={i18n.ALL_RULES}
subtitle={timelines.getLastUpdated({
showUpdating: loading || isFetching,
updatedAt: lastUpdated,
})}
>
{shouldShowRulesTable && (
<RulesTableFilters
onFilterChanged={onFilterChangedCallback}
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}
currentFilterTags={filterOptions.tags}
isLoadingTags={isLoadingTags}
tags={tags}
reFetchTags={reFetchTags}
/>
)}
</HeaderSection>
{shouldShowRulesTable && (
<RulesTableFilters
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}
allTags={allTags}
/>
)}
{shouldShowPrepackagedRulesPrompt && (
<PrePackagedRulesPrompt
createPrePackagedRules={handleCreatePrePackagedRules}
@ -362,7 +338,7 @@ export const RulesTables = React.memo<RulesTableProps>(
editAction={bulkEditActionType}
onClose={handleBulkEditFormCancel}
onConfirm={handleBulkEditFormConfirm}
tags={tags}
tags={allTags}
/>
)}
{shouldShowRulesTable && (

View file

@ -22,6 +22,8 @@ import {
UtilityBarText,
} from '../../../../../common/components/utility_bar';
import * as i18n from '../translations';
import { useKibana } from '../../../../../common/lib/kibana';
import { useRulesTableContextOptional } from './rules_table/rules_table_context';
interface AllRulesUtilityBarProps {
canBulkEdit: boolean;
@ -55,6 +57,9 @@ export const AllRulesUtilityBar = React.memo<AllRulesUtilityBarProps>(
isBulkActionInProgress,
hasDisabledActions,
}) => {
const { timelines } = useKibana().services;
const rulesTableContext = useRulesTableContextOptional();
const handleGetBulkItemsPopoverContent = useCallback(
(closePopover: () => void): JSX.Element | null => {
if (onGetBulkItemsPopoverContent != null) {
@ -100,7 +105,7 @@ export const AllRulesUtilityBar = React.memo<AllRulesUtilityBarProps>(
);
return (
<UtilityBar>
<UtilityBar border>
<UtilityBarSection>
<UtilityBarGroup>
{hasBulkActions ? (
@ -180,6 +185,14 @@ export const AllRulesUtilityBar = React.memo<AllRulesUtilityBarProps>(
</UtilityBarGroup>
)}
</UtilityBarSection>
{rulesTableContext && (
<UtilityBarSection>
{timelines.getLastUpdated({
showUpdating: rulesTableContext.state.isFetching,
updatedAt: rulesTableContext.state.lastUpdated,
})}
</UtilityBarSection>
)}
</UtilityBar>
);
}

View file

@ -391,13 +391,6 @@ export const EXPORT_FILENAME = i18n.translate(
}
);
export const ALL_RULES = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.tableTitle',
{
defaultMessage: 'All rules',
}
);
export const SEARCH_RULES = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel',
{
@ -408,7 +401,7 @@ export const SEARCH_RULES = i18n.translate(
export const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder',
{
defaultMessage: 'e.g. rule name',
defaultMessage: 'Search by rule name, index pattern, or MITRE ATT&CK tactic or technique',
}
);

View file

@ -20830,14 +20830,12 @@
"xpack.securitySolution.detectionEngine.rules.allRules.inactiveRuleDescription": "inactive",
"xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle": "Actualiser",
"xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel": "Rechercher les règles",
"xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder": "par ex. nom de règle",
"xpack.securitySolution.detectionEngine.rules.allRules.selectAllRulesTitle": "Sélection totale de {totalRules} {totalRules, plural, =1 {règle} other {règles}} effectuée",
"xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "Sélection de {selectedRules} {selectedRules, plural, =1 {règle} other {règles}} effectuée",
"xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "Affichage de {totalLists} {totalLists, plural, =1 {liste} other {listes}}",
"xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "Affichage de {totalRules} {totalRules, plural, =1 {règle} other {règles}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "Duplication réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "Exportation réussie de {exportedRules} sur {totalRules} {totalRules, plural, =1 {règle} other {règles}}. Les règles prédéfinies ont été exclues du fichier résultant.",
"xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "Toutes les règles",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "Listes d'exceptions",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "Monitoring des règles",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "Règles",

View file

@ -23836,14 +23836,12 @@
"xpack.securitySolution.detectionEngine.rules.allRules.inactiveRuleDescription": "非アクティブ",
"xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle": "更新",
"xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel": "ルールの検索",
"xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder": "例:ルール名",
"xpack.securitySolution.detectionEngine.rules.allRules.selectAllRulesTitle": "すべての{totalRules} {totalRules, plural, other {個のルール}}を選択",
"xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "{selectedRules} {selectedRules, plural, other {ルール}}を選択しました",
"xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "{totalLists} {totalLists, plural, other {件のリスト}}を表示しています。",
"xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "{totalRules} {totalRules, plural, other {ルール}}を表示中",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "{totalRules, plural, other {{totalRules}ルール}}を正常に複製しました",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "{exportedRules}/{totalRules} {totalRules, plural, other {件のルール}}を正常にエクスポートしました事前構築済みルールは結果のファイルから除外されました。",
"xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "すべてのルール",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外リスト",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "ルール監視",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "ルール",

View file

@ -23863,14 +23863,12 @@
"xpack.securitySolution.detectionEngine.rules.allRules.inactiveRuleDescription": "非活动",
"xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle": "刷新",
"xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel": "搜索规则",
"xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder": "例如,规则名",
"xpack.securitySolution.detectionEngine.rules.allRules.selectAllRulesTitle": "选择所有 {totalRules} 个{totalRules, plural, other {规则}}",
"xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "已选择 {selectedRules} 个{selectedRules, plural, other {规则}}",
"xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "正在显示 {totalLists} 个{totalLists, plural, other {列表}}",
"xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "正在显示 {totalRules} 个{totalRules, plural, other {规则}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "已成功导出 {exportedRules}/{totalRules} 个{totalRules, plural, other {规则}}。预置规则已从结果文件中排除。",
"xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "所有规则",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外列表",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "规则监测",
"xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "规则",