Skip to content

Commit d683d01

Browse files
authored
[Security Solution] Updates rules table tooling (#76719) (#77238)
1 parent c9a5082 commit d683d01

File tree

10 files changed

+149
-54
lines changed

10 files changed

+149
-54
lines changed

x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ export const selectNumberOfRules = (numberOfRules: number) => {
8080
};
8181

8282
export const sortByActivatedRules = () => {
83-
cy.get(SORT_RULES_BTN).click({ force: true });
83+
cy.get(SORT_RULES_BTN).contains('Activated').click({ force: true });
8484
waitForRulesToBeLoaded();
85-
cy.get(SORT_RULES_BTN).click({ force: true });
85+
cy.get(SORT_RULES_BTN).contains('Activated').click({ force: true });
8686
waitForRulesToBeLoaded();
8787
};
8888

x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
RulesColumns,
2121
RuleStatusRowItemType,
2222
} from '../../../pages/detection_engine/rules/all/columns';
23-
import { Rule, Rules } from '../../../containers/detection_engine/rules/types';
23+
import { Rule, Rules, RulesSortingFields } from '../../../containers/detection_engine/rules/types';
2424
import { AllRulesTabs } from '../../../pages/detection_engine/rules/all';
2525

2626
// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way
@@ -30,7 +30,7 @@ const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any;
3030

3131
export interface SortingType {
3232
sort: {
33-
field: 'enabled';
33+
field: RulesSortingFields;
3434
direction: Direction;
3535
};
3636
}
@@ -48,12 +48,7 @@ interface AllRulesTablesProps {
4848
rules: Rules;
4949
rulesColumns: RulesColumns[];
5050
rulesStatuses: RuleStatusRowItemType[];
51-
sorting: {
52-
sort: {
53-
field: 'enabled';
54-
direction: Direction;
55-
};
56-
};
51+
sorting: SortingType;
5752
tableOnChangeCallback: ({ page, sort }: EuiBasicTableOnChange) => void;
5853
tableRef?: React.MutableRefObject<EuiBasicTable | undefined>;
5954
selectedTab: AllRulesTabs;

x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/__snapshots__/index.test.tsx.snap

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ describe('Detections Rules API', () => {
202202
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', {
203203
method: 'GET',
204204
query: {
205-
filter: 'alert.attributes.tags: "hello" AND alert.attributes.tags: "world"',
205+
filter: 'alert.attributes.tags: "hello" OR alert.attributes.tags: "world"',
206206
page: 1,
207207
per_page: 20,
208208
sort_field: 'enabled',
@@ -297,7 +297,7 @@ describe('Detections Rules API', () => {
297297
method: 'GET',
298298
query: {
299299
filter:
300-
'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"',
300+
'alert.attributes.name: ruleName AND alert.attributes.tags: "__internal_immutable:false" AND alert.attributes.tags: "__internal_immutable:true" AND (alert.attributes.tags: "hello" OR alert.attributes.tags: "world")',
301301
page: 1,
302302
per_page: 20,
303303
sort_field: 'enabled',

x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,23 +107,35 @@ export const fetchRules = async ({
107107
},
108108
signal,
109109
}: FetchRulesProps): Promise<FetchRulesResponse> => {
110-
const filters = [
110+
const filtersWithoutTags = [
111111
...(filterOptions.filter.length ? [`alert.attributes.name: ${filterOptions.filter}`] : []),
112112
...(filterOptions.showCustomRules
113113
? [`alert.attributes.tags: "__internal_immutable:false"`]
114114
: []),
115115
...(filterOptions.showElasticRules
116116
? [`alert.attributes.tags: "__internal_immutable:true"`]
117117
: []),
118+
].join(' AND ');
119+
120+
const tags = [
118121
...(filterOptions.tags?.map((t) => `alert.attributes.tags: "${t.replace(/"/g, '\\"')}"`) ?? []),
119-
];
122+
].join(' OR ');
123+
124+
const filterString =
125+
filtersWithoutTags !== '' && tags !== ''
126+
? `${filtersWithoutTags} AND (${tags})`
127+
: filtersWithoutTags + tags;
128+
129+
const getFieldNameForSortField = (field: string) => {
130+
return field === 'name' ? `${field}.keyword` : field;
131+
};
120132

121133
const query = {
122134
page: pagination.page,
123135
per_page: pagination.perPage,
124-
sort_field: filterOptions.sortField,
136+
sort_field: getFieldNameForSortField(filterOptions.sortField),
125137
sort_order: filterOptions.sortOrder,
126-
...(filters.length ? { filter: filters.join(' AND ') } : {}),
138+
...(filterString !== '' ? { filter: filterString } : {}),
127139
};
128140

129141
return KibanaServices.get().http.fetch<FetchRulesResponse>(

x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,10 @@ export interface FetchRulesProps {
149149
signal: AbortSignal;
150150
}
151151

152+
export type RulesSortingFields = 'enabled' | 'updated_at' | 'name' | 'created_at';
152153
export interface FilterOptions {
153154
filter: string;
154-
sortField: string;
155+
sortField: RulesSortingFields;
155156
sortOrder: SortOrder;
156157
showCustomRules?: boolean;
157158
showElasticRules?: boolean;

x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ interface GetColumns {
9999
reFetchRules: (refreshPrePackagedRule?: boolean) => void;
100100
}
101101

102-
// Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes?
103102
export const getColumns = ({
104103
dispatch,
105104
dispatchToaster,
@@ -127,7 +126,8 @@ export const getColumns = ({
127126
</LinkAnchor>
128127
),
129128
truncateText: true,
130-
width: '24%',
129+
width: '20%',
130+
sortable: true,
131131
},
132132
{
133133
field: 'risk_score',
@@ -138,14 +138,14 @@ export const getColumns = ({
138138
</EuiText>
139139
),
140140
truncateText: true,
141-
width: '14%',
141+
width: '10%',
142142
},
143143
{
144144
field: 'severity',
145145
name: i18n.COLUMN_SEVERITY,
146146
render: (value: Rule['severity']) => <SeverityBadge value={value} />,
147147
truncateText: true,
148-
width: '16%',
148+
width: '12%',
149149
},
150150
{
151151
field: 'status_date',
@@ -160,7 +160,7 @@ export const getColumns = ({
160160
);
161161
},
162162
truncateText: true,
163-
width: '20%',
163+
width: '14%',
164164
},
165165
{
166166
field: 'status',
@@ -174,9 +174,40 @@ export const getColumns = ({
174174
</>
175175
);
176176
},
177-
width: '16%',
177+
width: '12%',
178178
truncateText: true,
179179
},
180+
{
181+
field: 'updated_at',
182+
name: i18n.COLUMN_LAST_UPDATE,
183+
render: (value: Rule['updated_at']) => {
184+
return value == null ? (
185+
getEmptyTagValue()
186+
) : (
187+
<LocalizedDateTooltip fieldName={i18n.COLUMN_LAST_UPDATE} date={new Date(value)}>
188+
<FormattedRelative value={value} />
189+
</LocalizedDateTooltip>
190+
);
191+
},
192+
sortable: true,
193+
truncateText: true,
194+
width: '14%',
195+
},
196+
{
197+
field: 'version',
198+
name: i18n.COLUMN_VERSION,
199+
render: (value: Rule['version']) => {
200+
return value == null ? (
201+
getEmptyTagValue()
202+
) : (
203+
<EuiText data-test-subj="version" size="s">
204+
{value}
205+
</EuiText>
206+
);
207+
},
208+
truncateText: true,
209+
width: '10%',
210+
},
180211
{
181212
field: 'tags',
182213
name: i18n.COLUMN_TAGS,
@@ -190,7 +221,7 @@ export const getColumns = ({
190221
</TruncatableText>
191222
),
192223
truncateText: true,
193-
width: '20%',
224+
width: '14%',
194225
},
195226
{
196227
align: 'center',

x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Rule,
2525
PaginationOptions,
2626
exportRules,
27+
RulesSortingFields,
2728
} from '../../../../containers/detection_engine/rules';
2829
import { HeaderSection } from '../../../../../common/components/header_section';
2930
import {
@@ -53,12 +54,12 @@ import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_l
5354
import { SecurityPageName } from '../../../../../app/types';
5455
import { useFormatUrl } from '../../../../../common/components/link_to';
5556

56-
const SORT_FIELD = 'enabled';
57+
const INITIAL_SORT_FIELD = 'enabled';
5758
const initialState: State = {
5859
exportRuleIds: [],
5960
filterOptions: {
6061
filter: '',
61-
sortField: SORT_FIELD,
62+
sortField: INITIAL_SORT_FIELD,
6263
sortOrder: 'desc',
6364
},
6465
loadingRuleIds: [],
@@ -164,8 +165,13 @@ export const AllRules = React.memo<AllRulesProps>(
164165
});
165166

166167
const sorting = useMemo(
167-
(): SortingType => ({ sort: { field: 'enabled', direction: filterOptions.sortOrder } }),
168-
[filterOptions.sortOrder]
168+
(): SortingType => ({
169+
sort: {
170+
field: filterOptions.sortField,
171+
direction: filterOptions.sortOrder,
172+
},
173+
}),
174+
[filterOptions]
169175
);
170176

171177
const prePackagedRuleStatus = getPrePackagedRuleStatus(
@@ -215,7 +221,7 @@ export const AllRules = React.memo<AllRulesProps>(
215221
dispatch({
216222
type: 'updateFilterOptions',
217223
filterOptions: {
218-
sortField: SORT_FIELD, // Only enabled is supported for sorting currently
224+
sortField: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types
219225
sortOrder: sort?.direction ?? 'desc',
220226
},
221227
pagination: { page: page.index + 1, perPage: page.size },

x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import React, { Dispatch, SetStateAction, useState } from 'react';
7+
import React, {
8+
ChangeEvent,
9+
Dispatch,
10+
SetStateAction,
11+
useCallback,
12+
useEffect,
13+
useMemo,
14+
useState,
15+
} from 'react';
816
import {
917
EuiFilterButton,
1018
EuiFilterSelectItem,
@@ -13,6 +21,8 @@ import {
1321
EuiPanel,
1422
EuiPopover,
1523
EuiText,
24+
EuiFieldSearch,
25+
EuiPopoverTitle,
1626
} from '@elastic/eui';
1727
import styled from 'styled-components';
1828
import * as i18n from '../../translations';
@@ -37,12 +47,39 @@ const ScrollableDiv = styled.div`
3747
* @param tags to display for filtering
3848
* @param onSelectedTagsChanged change listener to be notified when tag selection changes
3949
*/
40-
export const TagsFilterPopoverComponent = ({
50+
const TagsFilterPopoverComponent = ({
4151
tags,
4252
selectedTags,
4353
onSelectedTagsChanged,
4454
}: TagsFilterPopoverProps) => {
55+
const sortedTags = useMemo(() => {
56+
return tags.sort((a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase())); // Case insensitive
57+
}, [tags]);
4558
const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false);
59+
const [searchInput, setSearchInput] = useState('');
60+
const [filterTags, setFilterTags] = useState(sortedTags);
61+
62+
const tagsComponent = useMemo(() => {
63+
return filterTags.map((tag, index) => (
64+
<EuiFilterSelectItem
65+
checked={selectedTags.includes(tag) ? 'on' : undefined}
66+
key={`${index}-${tag}`}
67+
onClick={() => toggleSelectedGroup(tag, selectedTags, onSelectedTagsChanged)}
68+
>
69+
{`${tag}`}
70+
</EuiFilterSelectItem>
71+
));
72+
}, [onSelectedTagsChanged, selectedTags, filterTags]);
73+
74+
const onSearchInputChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
75+
setSearchInput(event.target.value);
76+
}, []);
77+
78+
useEffect(() => {
79+
setFilterTags(
80+
sortedTags.filter((tag) => tag.toLowerCase().includes(searchInput.toLowerCase()))
81+
);
82+
}, [sortedTags, searchInput]);
4683

4784
return (
4885
<EuiPopover
@@ -64,18 +101,17 @@ export const TagsFilterPopoverComponent = ({
64101
panelPaddingSize="none"
65102
repositionOnScroll
66103
>
67-
<ScrollableDiv>
68-
{tags.map((tag, index) => (
69-
<EuiFilterSelectItem
70-
checked={selectedTags.includes(tag) ? 'on' : undefined}
71-
key={`${index}-${tag}`}
72-
onClick={() => toggleSelectedGroup(tag, selectedTags, onSelectedTagsChanged)}
73-
>
74-
{`${tag}`}
75-
</EuiFilterSelectItem>
76-
))}
77-
</ScrollableDiv>
78-
{tags.length === 0 && (
104+
<EuiPopoverTitle>
105+
<EuiFieldSearch
106+
placeholder="Search tags"
107+
value={searchInput}
108+
onChange={onSearchInputChange}
109+
isClearable
110+
aria-label="Rules tag search"
111+
/>
112+
</EuiPopoverTitle>
113+
<ScrollableDiv>{tagsComponent}</ScrollableDiv>
114+
{filterTags.length === 0 && (
79115
<EuiFlexGroup gutterSize="m" justifyContent="spaceAround">
80116
<EuiFlexItem grow={true}>
81117
<EuiPanel>

0 commit comments

Comments
 (0)