Skip to content

Commit

Permalink
[Security Solution] Filter by rule execution status (last response) o…
Browse files Browse the repository at this point in the history
…n Rule Management page (#159865)

**Resolves**: #138903

## Summary

Adds a dropdown that allows you to filter rules by their rule execution
status to the Rule Management page.

<img width="1583" alt="Screenshot 2023-06-16 at 16 34 23"
src="https://github.com/elastic/kibana/assets/15949146/abc8234a-4c05-4195-bc15-86b76a108663">



### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
  • Loading branch information
nikitaindik authored Jun 20, 2023
1 parent 2312705 commit 6ca5e59
Show file tree
Hide file tree
Showing 11 changed files with 220 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { RuleSnoozeSettings } from '@kbn/triggers-actions-ui-plugin/public/
import { PositiveInteger } from '@kbn/securitysolution-io-ts-types';
import type { WarningSchema } from '../../../../common/detection_engine/schemas/response';
import { RuleExecutionSummary } from '../../../../common/detection_engine/rule_monitoring';
import type { RuleExecutionStatus } from '../../../../common/detection_engine/rule_monitoring';
import {
AlertSuppression,
AlertsIndex,
Expand Down Expand Up @@ -250,6 +251,7 @@ export interface FilterOptions {
tags: string[];
excludeRuleTypes?: Type[];
enabled?: boolean; // undefined is to display all the rules
ruleExecutionStatus?: RuleExecutionStatus; // undefined means "all"
}

export interface FetchRulesResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { escapeKuery } from '../../../common/lib/kuery';
import type { FilterOptions } from './types';
import { RuleExecutionStatus } from '../../../../common/detection_engine/rule_monitoring/model/execution_status';

const SEARCHABLE_RULE_PARAMS = [
'alert.attributes.name',
Expand All @@ -19,6 +20,12 @@ const SEARCHABLE_RULE_PARAMS = [
'alert.attributes.params.threat.technique.subtechnique.name',
];

const ENABLED_FIELD = 'alert.attributes.enabled';
const TAGS_FIELD = 'alert.attributes.tags';
const PARAMS_TYPE_FIELD = 'alert.attributes.params.type';
const PARAMS_IMMUTABLE_FIELD = 'alert.attributes.params.immutable';
const LAST_RUN_OUTCOME_FIELD = 'alert.attributes.lastRun.outcome';

/**
* Convert rules filter options object to KQL query
*
Expand All @@ -33,25 +40,24 @@ export const convertRulesFilterToKQL = ({
tags,
excludeRuleTypes = [],
enabled,
ruleExecutionStatus,
}: FilterOptions): string => {
const filters: string[] = [];

if (showCustomRules && showElasticRules) {
// if both showCustomRules && showElasticRules selected we omit filter, as it includes all existing rules
} else if (showElasticRules) {
filters.push('alert.attributes.params.immutable: true');
filters.push(`${PARAMS_IMMUTABLE_FIELD}: true`);
} else if (showCustomRules) {
filters.push('alert.attributes.params.immutable: false');
filters.push(`${PARAMS_IMMUTABLE_FIELD}: false`);
}

if (enabled !== undefined) {
filters.push(`alert.attributes.enabled: ${enabled ? 'true' : 'false'}`);
filters.push(`${ENABLED_FIELD}: ${enabled ? 'true' : 'false'}`);
}

if (tags.length > 0) {
filters.push(
`alert.attributes.tags:(${tags.map((tag) => `"${escapeKuery(tag)}"`).join(' AND ')})`
);
filters.push(`${TAGS_FIELD}:(${tags.map((tag) => `"${escapeKuery(tag)}"`).join(' AND ')})`);
}

if (filter.length) {
Expand All @@ -64,11 +70,19 @@ export const convertRulesFilterToKQL = ({

if (excludeRuleTypes.length) {
filters.push(
`NOT alert.attributes.params.type: (${excludeRuleTypes
`NOT ${PARAMS_TYPE_FIELD}: (${excludeRuleTypes
.map((ruleType) => `"${escapeKuery(ruleType)}"`)
.join(' OR ')})`
);
}

if (ruleExecutionStatus === RuleExecutionStatus.succeeded) {
filters.push(`${LAST_RUN_OUTCOME_FIELD}: "succeeded"`);
} else if (ruleExecutionStatus === RuleExecutionStatus['partial failure']) {
filters.push(`${LAST_RUN_OUTCOME_FIELD}: "warning"`);
} else if (ruleExecutionStatus === RuleExecutionStatus.failed) {
filters.push(`${LAST_RUN_OUTCOME_FIELD}: "failed"`);
}

return filters.join(' AND ');
};
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,10 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
showElasticRules:
savedFilter?.source === RuleSource.Prebuilt ?? DEFAULT_FILTER_OPTIONS.showElasticRules,
enabled: savedFilter?.enabled,
ruleExecutionStatus:
savedFilter?.ruleExecutionStatus ?? DEFAULT_FILTER_OPTIONS.ruleExecutionStatus,
});

const [sortingOptions, setSortingOptions] = useState<SortingOptions>({
field: savedSorting?.field ?? DEFAULT_SORTING_OPTIONS.field,
order: savedSorting?.order ?? DEFAULT_SORTING_OPTIONS.order,
Expand Down Expand Up @@ -248,6 +251,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
showCustomRules: DEFAULT_FILTER_OPTIONS.showCustomRules,
tags: DEFAULT_FILTER_OPTIONS.tags,
enabled: undefined,
ruleExecutionStatus: DEFAULT_FILTER_OPTIONS.ruleExecutionStatus,
});
setSortingOptions({
field: DEFAULT_SORTING_OPTIONS.field,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
showCustomRules: false,
showElasticRules: false,
enabled: undefined,
ruleExecutionStatus: undefined,
};
export const DEFAULT_SORTING_OPTIONS: SortingOptions = {
field: 'enabled',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import * as t from 'io-ts';
import { enumeration } from '@kbn/securitysolution-io-ts-types';
import { SortingOptions, PaginationOptions } from '../../../../rule_management/logic';
import { TRuleExecutionStatus } from '../../../../../../common/detection_engine/rule_monitoring/model/execution_status';

export enum RuleSource {
Prebuilt = 'prebuilt',
Expand All @@ -20,6 +21,7 @@ export const RulesTableSavedFilter = t.partial({
source: enumeration('RuleSource', RuleSource),
tags: t.array(t.string),
enabled: t.boolean,
ruleExecutionStatus: TRuleExecutionStatus,
});

export type RulesTableSavedSorting = t.TypeOf<typeof RulesTableSavedSorting>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ function validateState(
source: filterFromUrl?.source ?? filterFromStorage?.source,
tags: filterFromUrl?.tags ?? filterFromStorage?.tags,
enabled: filterFromUrl?.enabled ?? filterFromStorage?.enabled,
ruleExecutionStatus:
filterFromUrl?.ruleExecutionStatus ?? filterFromStorage?.ruleExecutionStatus,
};

const [sortingFromUrl] = validateNonExact(urlState, RulesTableSavedSorting);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export function useSyncRulesTableSavedState(): void {
storageStateToSave.perPage = state.pagination.perPage;
}

if (state.filterOptions.ruleExecutionStatus !== undefined) {
urlStateToSave.ruleExecutionStatus = state.filterOptions.ruleExecutionStatus;
storageStateToSave.ruleExecutionStatus = state.filterOptions.ruleExecutionStatus;
}

const hasUrlStateToSave = Object.keys(urlStateToSave).length > 0;
const hasStorageStateToSave = Object.keys(storageStateToSave).length > 0;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useState } from 'react';
import type { EuiSelectableOption } from '@elastic/eui';
import { EuiFilterButton, EuiPopover, EuiSelectable } from '@elastic/eui';
import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations';
import { RuleExecutionStatus } from '../../../../../../common/detection_engine/rule_monitoring/model/execution_status';
import { getCapitalizedStatusText } from '../../../../../detections/components/rules/rule_execution_status/utils';
import { RuleStatusBadge } from '../../../../../detections/components/rules/rule_execution_status/rule_status_badge';

interface OptionData {
status: RuleExecutionStatus;
}

interface RuleExecutionStatusSelectorProps {
selectedStatus?: RuleExecutionStatus;
onSelectedStatusChanged: (newStatus?: RuleExecutionStatus) => void;
}

/**
* Selector for selecting last rule execution status to filter on
*
* @param selectedStatus Selected rule execution status
* @param onSelectedStatusChanged change listener to be notified when rule execution status selection changes
*/
const RuleExecutionStatusSelectorComponent = ({
selectedStatus,
onSelectedStatusChanged,
}: RuleExecutionStatusSelectorProps) => {
const [isExecutionStatusPopoverOpen, setIsExecutionStatusPopoverOpen] = useState(false);

const selectableOptions: EuiSelectableOption[] = [
{
label: getCapitalizedStatusText(RuleExecutionStatus.succeeded) || '',
data: { status: RuleExecutionStatus.succeeded },
checked: selectedStatus === RuleExecutionStatus.succeeded ? 'on' : undefined,
},
{
label: getCapitalizedStatusText(RuleExecutionStatus['partial failure']) || '',
data: { status: RuleExecutionStatus['partial failure'] },
checked: selectedStatus === RuleExecutionStatus['partial failure'] ? 'on' : undefined,
},
{
label: getCapitalizedStatusText(RuleExecutionStatus.failed) || '',
data: { status: RuleExecutionStatus.failed },
checked: selectedStatus === RuleExecutionStatus.failed ? 'on' : undefined,
},
];

const handleSelectableOptionsChange = (
newOptions: EuiSelectableOption[],
_: unknown,
changedOption: EuiSelectableOption
) => {
setIsExecutionStatusPopoverOpen(false);

if (changedOption.checked && changedOption?.data?.status) {
onSelectedStatusChanged(changedOption.data.status as RuleExecutionStatus);
} else if (!changedOption.checked) {
onSelectedStatusChanged();
}
};

const triggerButton = (
<EuiFilterButton
grow
iconType="arrowDown"
onClick={() => {
setIsExecutionStatusPopoverOpen(!isExecutionStatusPopoverOpen);
}}
numFilters={selectableOptions.length}
isSelected={isExecutionStatusPopoverOpen}
hasActiveFilters={selectedStatus !== undefined}
numActiveFilters={selectedStatus !== undefined ? 1 : 0}
>
{i18n.COLUMN_LAST_RESPONSE}
</EuiFilterButton>
);

return (
<EuiPopover
ownFocus
button={triggerButton}
isOpen={isExecutionStatusPopoverOpen}
closePopover={() => {
setIsExecutionStatusPopoverOpen(!isExecutionStatusPopoverOpen);
}}
panelPaddingSize="none"
repositionOnScroll
>
<EuiSelectable
aria-label={i18n.RULE_EXECTION_STATUS_FILTER}
options={selectableOptions}
onChange={handleSelectableOptionsChange}
singleSelection
listProps={{
isVirtualized: false,
}}
renderOption={(option) => {
const status = (option as EuiSelectableOption<OptionData>).status;
return (
<div
css={`
margin-top: 4px; // aligns the badge within the option
`}
>
<RuleStatusBadge status={status} showTooltip={false} />
</div>
);
}}
>
{(list) => (
<div
css={`
width: 200px;
`}
>
{list}
</div>
)}
</EuiSelectable>
</EuiPopover>
);
};

RuleExecutionStatusSelectorComponent.displayName = 'RuleExecutionStatusSelectorComponent';

export const RuleExecutionStatusSelector = React.memo(RuleExecutionStatusSelectorComponent);

RuleExecutionStatusSelector.displayName = 'RuleExecutionStatusSelector';
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import { useRuleManagementFilters } from '../../../../rule_management/logic/use_
import { RULES_TABLE_ACTIONS } from '../../../../../common/lib/apm/user_actions';
import { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction';
import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations';
import type { RuleExecutionStatus } from '../../../../../../common/detection_engine/rule_monitoring/model/execution_status';
import { useRulesTableContext } from '../rules_table/rules_table_context';
import { TagsFilterPopover } from './tags_filter_popover';
import { RuleExecutionStatusSelector } from './rule_execution_status_selector';
import { RuleSearchField } from './rule_search_field';

const FilterWrapper = styled(EuiFlexGroup)`
Expand All @@ -36,7 +38,13 @@ const RulesTableFiltersComponent = () => {
const rulesCustomCount = ruleManagementFields?.rules_summary.custom_count;
const rulesPrebuiltInstalledCount = ruleManagementFields?.rules_summary.prebuilt_installed_count;

const { showCustomRules, showElasticRules, tags: selectedTags, enabled } = filterOptions;
const {
showCustomRules,
showElasticRules,
tags: selectedTags,
enabled,
ruleExecutionStatus: selectedRuleExecutionStatus,
} = filterOptions;

const handleOnSearch = useCallback(
(filterString) => {
Expand Down Expand Up @@ -76,6 +84,16 @@ const RulesTableFiltersComponent = () => {
[selectedTags, setFilterOptions, startTransaction]
);

const handleSelectedExecutionStatus = useCallback(
(newExecutionStatus?: RuleExecutionStatus) => {
if (newExecutionStatus !== selectedRuleExecutionStatus) {
startTransaction({ name: RULES_TABLE_ACTIONS.FILTER });
setFilterOptions({ ruleExecutionStatus: newExecutionStatus });
}
},
[selectedRuleExecutionStatus, setFilterOptions, startTransaction]
);

return (
<FilterWrapper gutterSize="m" justifyContent="flexEnd" wrap>
<RuleSearchField initialValue={filterOptions.filter} onSearch={handleOnSearch} />
Expand All @@ -90,6 +108,15 @@ const RulesTableFiltersComponent = () => {
</EuiFilterGroup>
</EuiFlexItem>

<EuiFlexItem grow={false}>
<EuiFilterGroup>
<RuleExecutionStatusSelector
onSelectedStatusChanged={handleSelectedExecutionStatus}
selectedStatus={selectedRuleExecutionStatus}
/>
</EuiFilterGroup>
</EuiFlexItem>

<EuiFlexItem grow={false}>
<EuiFilterGroup>
<EuiFilterButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,32 @@ import { RuleExecutionStatus } from '../../../../../common/detection_engine/rule
interface RuleStatusBadgeProps {
status: RuleExecutionStatus | null | undefined;
message?: string;
showTooltip?: boolean;
}

/**
* Shows rule execution status
* @param status - rule execution status
*/
const RuleStatusBadgeComponent = ({ status, message }: RuleStatusBadgeProps) => {
const RuleStatusBadgeComponent = ({
status,
message,
showTooltip = true,
}: RuleStatusBadgeProps) => {
const isFailedStatus =
status === RuleExecutionStatus.failed || status === RuleExecutionStatus['partial failure'];
const statusText = getCapitalizedStatusText(status);

const statusTooltip = isFailedStatus && message ? message : statusText;
const tooltipContent = showTooltip
? statusTooltip?.split('\n').map((line) => <p>{line}</p>)
: null;

const statusColor = getStatusColor(status);

return (
<HealthTruncateText
tooltipContent={statusTooltip?.split('\n').map((line) => (
<p>{line}</p>
))}
tooltipContent={tooltipContent}
healthColor={statusColor}
dataTestSubj="ruleExecutionStatus"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,13 @@ export const NO_TAGS_AVAILABLE = i18n.translate(
}
);

export const RULE_EXECTION_STATUS_FILTER = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.filters.ruleExecutionStatusFilter',
{
defaultMessage: 'Select rule execution status to filter by',
}
);

export const NO_RULES = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.filters.noRulesTitle',
{
Expand Down

0 comments on commit 6ca5e59

Please sign in to comment.