[Observability:RulesPage] [TriggerActions:RulesList] Allow filtering of Rules via Rule Param (#154258)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Coen Warmer 2023-04-21 15:08:42 +02:00 committed by GitHub
parent e41936662e
commit cd90b11e97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 214 additions and 95 deletions

View file

@ -62,14 +62,16 @@ export function RulesPage() {
useHashQuery: false,
});
const { lastResponse, search, status, type } = urlStateStorage.get<{
const { lastResponse, params, search, status, type } = urlStateStorage.get<{
lastResponse: string[];
params: Record<string, string | number | object>;
search: string;
status: RuleStatus[];
type: string[];
}>('_a') || { lastResponse: [], search: '', status: [], type: [] };
}>('_a') || { lastResponse: [], params: {}, search: '', status: [], type: [] };
const [stateLastResponse, setLastResponse] = useState<string[]>(lastResponse);
const [stateParams, setParams] = useState<Record<string, string | number | object>>(params);
const [stateSearch, setSearch] = useState<string>(search);
const [stateStatus, setStatus] = useState<RuleStatus[]>(status);
const [stateType, setType] = useState<string[]>(type);
@ -80,23 +82,28 @@ export function RulesPage() {
const handleStatusFilterChange = (newStatus: RuleStatus[]) => {
setStatus(newStatus);
urlStateStorage.set('_a', { lastResponse, search, status: newStatus, type });
urlStateStorage.set('_a', { lastResponse, params, search, status: newStatus, type });
};
const handleLastRunOutcomeFilterChange = (newLastResponse: string[]) => {
setRefresh(new Date());
setLastResponse(newLastResponse);
urlStateStorage.set('_a', { lastResponse: newLastResponse, search, status, type });
urlStateStorage.set('_a', { lastResponse: newLastResponse, params, search, status, type });
};
const handleTypeFilterChange = (newType: string[]) => {
setType(newType);
urlStateStorage.set('_a', { lastResponse, search, status, type: newType });
urlStateStorage.set('_a', { lastResponse, params, search, status, type: newType });
};
const handleSearchFilterChange = (newSearch: string) => {
setSearch(newSearch);
urlStateStorage.set('_a', { lastResponse, search: newSearch, status, type });
urlStateStorage.set('_a', { lastResponse, params, search: newSearch, status, type });
};
const handleRuleParamFilterChange = (newParams: Record<string, string | number | object>) => {
setParams(newParams);
urlStateStorage.set('_a', { lastResponse, params: newParams, search, status, type });
};
return (
@ -143,6 +150,7 @@ export function RulesPage() {
refresh={stateRefresh}
ruleDetailsRoute="alerts/rules/:ruleId"
rulesListKey="observability_rulesListColumns"
ruleParamFilter={stateParams}
showActionFilter={false}
statusFilter={stateStatus}
searchFilter={stateSearch}
@ -155,6 +163,7 @@ export function RulesPage() {
'ruleExecutionState',
]}
onLastRunOutcomeFilterChange={handleLastRunOutcomeFilterChange}
onRuleParamFilterChange={handleRuleParamFilterChange}
onSearchFilterChange={handleSearchFilterChange}
onStatusFilterChange={handleStatusFilterChange}
onTypeFilterChange={handleTypeFilterChange}

View file

@ -68,6 +68,7 @@ describe('useLoadRuleAggregations', () => {
ruleExecutionStatuses: [],
ruleLastRunOutcomes: [],
ruleStatuses: [],
ruleParams: {},
tags: [],
},
enabled: true,
@ -105,6 +106,7 @@ describe('useLoadRuleAggregations', () => {
types: ['type1', 'type2'],
actionTypes: ['action1', 'action2'],
ruleExecutionStatuses: ['status1', 'status2'],
ruleParams: {},
ruleStatuses: ['enabled', 'snoozed'] as RuleStatus[],
tags: ['tag1', 'tag2'],
ruleLastRunOutcomes: ['outcome1', 'outcome2'],
@ -145,6 +147,7 @@ describe('useLoadRuleAggregations', () => {
types: [],
actionTypes: [],
ruleExecutionStatuses: [],
ruleParams: {},
ruleLastRunOutcomes: [],
ruleStatuses: [],
tags: [],

View file

@ -272,6 +272,7 @@ describe('useLoadRules', () => {
types: [],
actionTypes: [],
ruleExecutionStatuses: [],
ruleParams: {},
ruleLastRunOutcomes: [],
ruleStatuses: [],
tags: [],
@ -328,6 +329,7 @@ describe('useLoadRules', () => {
types: ['type1', 'type2'],
actionTypes: ['action1', 'action2'],
ruleExecutionStatuses: ['status1', 'status2'],
ruleParams: {},
ruleLastRunOutcomes: ['outcome1', 'outcome2'],
ruleStatuses: ['enabled', 'snoozed'] as RuleStatus[],
tags: ['tag1', 'tag2'],
@ -355,6 +357,7 @@ describe('useLoadRules', () => {
actionTypesFilter: ['action1', 'action2'],
ruleExecutionStatusesFilter: ['status1', 'status2'],
ruleLastRunOutcomesFilter: ['outcome1', 'outcome2'],
ruleParamsFilter: {},
ruleStatusesFilter: ['enabled', 'snoozed'],
tagsFilter: ['tag1', 'tag2'],
sort: { field: 'name', direction: 'asc' },
@ -378,6 +381,7 @@ describe('useLoadRules', () => {
types: [],
actionTypes: [],
ruleExecutionStatuses: [],
ruleParams: {},
ruleLastRunOutcomes: [],
ruleStatuses: [],
tags: [],
@ -415,6 +419,7 @@ describe('useLoadRules', () => {
types: [],
actionTypes: [],
ruleExecutionStatuses: [],
ruleParams: {},
ruleLastRunOutcomes: [],
ruleStatuses: [],
tags: [],
@ -444,6 +449,7 @@ describe('useLoadRules', () => {
types: [],
actionTypes: [],
ruleExecutionStatuses: [],
ruleParams: {},
ruleLastRunOutcomes: [],
ruleStatuses: [],
tags: [],
@ -477,6 +483,7 @@ describe('useLoadRules', () => {
types: ['some-kind-of-filter'],
actionTypes: [],
ruleExecutionStatuses: [],
ruleParams: {},
ruleLastRunOutcomes: [],
ruleStatuses: [],
tags: [],

View file

@ -44,6 +44,7 @@ export const useLoadRulesQuery = (props: UseLoadRulesQueryProps) => {
filters.actionTypes,
filters.ruleStatuses,
filters.ruleLastRunOutcomes,
filters.ruleParams,
page,
sort,
{
@ -59,6 +60,7 @@ export const useLoadRulesQuery = (props: UseLoadRulesQueryProps) => {
actionTypesFilter: filters.actionTypes,
ruleExecutionStatusesFilter: filters.ruleExecutionStatuses,
ruleLastRunOutcomesFilter: filters.ruleLastRunOutcomes,
ruleParamsFilter: filters.ruleParams,
ruleStatusesFilter: filters.ruleStatuses,
tagsFilter: filters.tags,
sort,

View file

@ -13,6 +13,7 @@ export const mapFiltersToKueryNode = ({
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleParamsFilter,
ruleStatusesFilter,
tagsFilter,
searchText,
@ -22,6 +23,7 @@ export const mapFiltersToKueryNode = ({
tagsFilter?: string[];
ruleExecutionStatusesFilter?: string[];
ruleLastRunOutcomesFilter?: string[];
ruleParamsFilter?: Record<string, string | number | object>;
ruleStatusesFilter?: RuleStatus[];
searchText?: string;
}): KueryNode | null => {
@ -63,6 +65,19 @@ export const mapFiltersToKueryNode = ({
);
}
if (ruleParamsFilter && Object.keys(ruleParamsFilter).length) {
filterKueryNode.push(
nodeBuilder.and(
Object.keys(ruleParamsFilter).map((ruleParam) =>
nodeBuilder.is(
`alert.attributes.params.${ruleParam}`,
String(ruleParamsFilter[ruleParam])
)
)
)
);
}
if (ruleStatusesFilter && ruleStatusesFilter.length) {
const snoozedFilter = nodeBuilder.or([
fromKueryExpression('alert.attributes.muteAll: true'),

View file

@ -19,6 +19,7 @@ export interface LoadRulesProps {
tagsFilter?: string[];
ruleExecutionStatusesFilter?: string[];
ruleLastRunOutcomesFilter?: string[];
ruleParamsFilter?: Record<string, string | number | object>;
ruleStatusesFilter?: RuleStatus[];
sort?: Sorting;
}

View file

@ -19,6 +19,7 @@ export async function loadRulesWithKueryFilter({
actionTypesFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleParamsFilter,
ruleStatusesFilter,
tagsFilter,
sort = { field: 'name', direction: 'asc' },
@ -34,6 +35,7 @@ export async function loadRulesWithKueryFilter({
tagsFilter,
ruleExecutionStatusesFilter,
ruleLastRunOutcomesFilter,
ruleParamsFilter,
ruleStatusesFilter,
searchText,
});

View file

@ -8,7 +8,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { i18n } from '@kbn/i18n';
import { capitalize, isEmpty, sortBy } from 'lodash';
import { capitalize, isEmpty, isEqual, sortBy } from 'lodash';
import { KueryNode } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import React, {
@ -75,6 +75,7 @@ import './rules_list.scss';
import { CreateRuleButton } from './create_rule_button';
import { ManageLicenseModal } from './manage_license_modal';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { RulesListClearRuleFilterBanner } from './rules_list_clear_rule_filter_banner';
import { RulesListTable, convertRulesToTableItems } from './rules_list_table';
import { RulesListDocLink } from './rules_list_doc_link';
import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation';
@ -107,23 +108,26 @@ const RuleEdit = lazy(() => import('../../rule_form/rule_edit'));
export interface RulesListProps {
filteredRuleTypes?: string[];
showActionFilter?: boolean;
ruleDetailsRoute?: string;
showCreateRuleButtonInPrompt?: boolean;
setHeaderActions?: (components?: React.ReactNode[]) => void;
statusFilter?: RuleStatus[];
onStatusFilterChange?: (status: RuleStatus[]) => void;
lastResponseFilter?: string[];
onLastResponseFilterChange?: (lastResponse: string[]) => void;
lastRunOutcomeFilter?: string[];
onLastRunOutcomeFilterChange?: (lastRunOutcome: string[]) => void;
typeFilter?: string[];
onTypeFilterChange?: (type: string[]) => void;
searchFilter?: string;
onSearchFilterChange?: (search: string) => void;
refresh?: Date;
ruleDetailsRoute?: string;
ruleParamFilter?: Record<string, string | number | object>;
rulesListKey?: string;
searchFilter?: string;
showActionFilter?: boolean;
showCreateRuleButtonInPrompt?: boolean;
showSearchBar?: boolean;
statusFilter?: RuleStatus[];
typeFilter?: string[];
visibleColumns?: string[];
onLastResponseFilterChange?: (lastResponse: string[]) => void;
onLastRunOutcomeFilterChange?: (lastRunOutcome: string[]) => void;
onRuleParamFilterChange?: (ruleParams: Record<string, string | number | object>) => void;
onSearchFilterChange?: (search: string) => void;
onStatusFilterChange?: (status: RuleStatus[]) => void;
onTypeFilterChange?: (type: string[]) => void;
setHeaderActions?: (components?: React.ReactNode[]) => void;
}
export const percentileFields = {
@ -142,47 +146,51 @@ const EMPTY_ARRAY: string[] = [];
export const RulesList = ({
filteredRuleTypes = EMPTY_ARRAY,
showActionFilter = true,
ruleDetailsRoute,
showCreateRuleButtonInPrompt = false,
statusFilter,
onStatusFilterChange,
lastResponseFilter,
onLastResponseFilterChange,
lastRunOutcomeFilter,
onLastRunOutcomeFilterChange,
refresh,
ruleDetailsRoute,
ruleParamFilter,
rulesListKey,
searchFilter = '',
onSearchFilterChange,
showActionFilter = true,
showCreateRuleButtonInPrompt = false,
showSearchBar = true,
statusFilter,
typeFilter,
visibleColumns,
onLastResponseFilterChange,
onLastRunOutcomeFilterChange,
onRuleParamFilterChange,
onSearchFilterChange,
onStatusFilterChange,
onTypeFilterChange,
setHeaderActions,
refresh,
rulesListKey,
visibleColumns,
}: RulesListProps) => {
const history = useHistory();
const {
http,
notifications: { toasts },
application: { capabilities },
ruleTypeRegistry,
actionTypeRegistry,
application: { capabilities },
http,
kibanaFeatures,
notifications: { toasts },
ruleTypeRegistry,
} = useKibana().services;
const canExecuteActions = hasExecuteActionsCapability(capabilities);
const [isPerformingAction, setIsPerformingAction] = useState<boolean>(false);
const [page, setPage] = useState<Pagination>({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE });
const [inputText, setInputText] = useState<string>(searchFilter);
const [filters, setFilters] = useState<RulesListFilters>(() => ({
searchText: searchFilter || '',
types: typeFilter || [],
const [filters, setFilters] = useState<RulesListFilters>({
actionTypes: [],
ruleExecutionStatuses: lastResponseFilter || [],
ruleLastRunOutcomes: lastRunOutcomeFilter || [],
ruleParams: ruleParamFilter || {},
ruleStatuses: statusFilter || [],
searchText: searchFilter || '',
tags: [],
}));
types: typeFilter || [],
});
const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState<boolean>(false);
const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false);
@ -357,6 +365,9 @@ export const RulesList = ({
case 'ruleLastRunOutcomes':
onLastRunOutcomeFilterChange?.(value as string[]);
break;
case 'ruleParams':
onRuleParamFilterChange?.(value as Record<string, string | number | object>);
break;
case 'searchText':
onSearchFilterChange?.(value as string);
break;
@ -389,6 +400,8 @@ export const RulesList = ({
[setFilters, handleUpdateFiltersEffect]
);
const handleClearRuleParamFilter = () => updateFilters({ filter: 'ruleParams', value: {} });
useEffect(() => {
if (statusFilter) {
updateFilters({ filter: 'ruleStatuses', value: statusFilter });
@ -407,6 +420,12 @@ export const RulesList = ({
}
}, [lastRunOutcomeFilter]);
useEffect(() => {
if (ruleParamFilter && !isEqual(ruleParamFilter, filters.ruleParams)) {
updateFilters({ filter: 'ruleParams', value: ruleParamFilter });
}
}, [ruleParamFilter]);
useEffect(() => {
if (typeof searchFilter === 'string') {
updateFilters({ filter: 'searchText', value: searchFilter });
@ -783,26 +802,36 @@ export const RulesList = ({
/>
)}
<EuiSpacer size="xs" />
{showSearchBar && !isEmpty(filters.ruleParams) ? (
<RulesListClearRuleFilterBanner onClickClearFilter={handleClearRuleParamFilter} />
) : null}
{showRulesList && (
<>
<RulesListFiltersBar
inputText={inputText}
filters={filters}
showActionFilter={showActionFilter}
rulesStatusesTotal={rulesStatusesTotal}
rulesLastRunOutcomesTotal={rulesLastRunOutcomesTotal}
tags={tags}
filterOptions={filterOptions}
actionTypes={actionTypes}
lastUpdate={lastUpdate}
showErrors={showErrors}
updateFilters={updateFilters}
setInputText={setInputText}
onClearSelection={onClearSelection}
onRefreshRules={refreshRules}
onToggleRuleErrors={toggleRuleErrors}
/>
<EuiSpacer size="s" />
{showSearchBar ? (
<>
<RulesListFiltersBar
actionTypes={actionTypes}
filterOptions={filterOptions}
filters={filters}
inputText={inputText}
lastUpdate={lastUpdate}
rulesLastRunOutcomesTotal={rulesLastRunOutcomesTotal}
rulesStatusesTotal={rulesStatusesTotal}
setInputText={setInputText}
showActionFilter={showActionFilter}
showErrors={showErrors}
tags={tags}
updateFilters={updateFilters}
onClearSelection={onClearSelection}
onRefreshRules={refreshRules}
onToggleRuleErrors={toggleRuleErrors}
/>
<EuiSpacer size="s" />
</>
) : null}
<RulesListTable
items={tableItems}
isLoading={isRulesTableLoading}

View file

@ -0,0 +1,39 @@
/*
* 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 from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut, EuiIcon, EuiLink, EuiSpacer } from '@elastic/eui';
interface RulesListClearRuleFilterProps {
onClickClearFilter: () => void;
}
export const RulesListClearRuleFilterBanner = ({
onClickClearFilter,
}: RulesListClearRuleFilterProps) => {
return (
<>
<EuiCallOut color="primary" size="s" data-test-subj="rulesListClearRuleFilterBanner">
<p>
<EuiIcon color="primary" type="iInCircle" />{' '}
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.ruleParamBannerTitle"
defaultMessage="Rule list filtered by url parameters."
/>{' '}
<EuiLink color="primary" onClick={onClickClearFilter}>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.ruleParamBannerButton"
defaultMessage="Show all"
/>
</EuiLink>
</p>
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
};

View file

@ -6,16 +6,16 @@
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiFilterGroup,
EuiFieldSearch,
EuiSpacer,
EuiLink,
EuiFieldSearch,
} from '@elastic/eui';
import { ActionType, RulesListFilters, UpdateFiltersProps } from '../../../../types';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
@ -29,43 +29,42 @@ import { ActionTypeFilter } from './action_type_filter';
import { RuleTagFilter } from './rule_tag_filter';
import { RuleStatusFilter } from './rule_status_filter';
const ENTER_KEY = 13;
interface RulesListFiltersBarProps {
inputText: string;
filters: RulesListFilters;
showActionFilter: boolean;
rulesStatusesTotal: Record<string, number>;
rulesLastRunOutcomesTotal: Record<string, number>;
tags: string[];
filterOptions: TypeFilterProps['options'];
actionTypes: ActionType[];
filterOptions: TypeFilterProps['options'];
filters: RulesListFilters;
inputText: string;
lastUpdate: string;
rulesLastRunOutcomesTotal: Record<string, number>;
rulesStatusesTotal: Record<string, number>;
showActionFilter: boolean;
showErrors: boolean;
updateFilters: (updateFiltersProps: UpdateFiltersProps) => void;
setInputText: (text: string) => void;
tags: string[];
onClearSelection: () => void;
onRefreshRules: () => void;
onToggleRuleErrors: () => void;
setInputText: (text: string) => void;
updateFilters: (updateFiltersProps: UpdateFiltersProps) => void;
}
const ENTER_KEY = 13;
export const RulesListFiltersBar = React.memo((props: RulesListFiltersBarProps) => {
const {
filters,
inputText,
showActionFilter = true,
rulesStatusesTotal,
rulesLastRunOutcomesTotal,
tags,
actionTypes,
filterOptions,
filters,
inputText,
lastUpdate,
showErrors,
updateFilters,
setInputText,
onClearSelection,
onRefreshRules,
onToggleRuleErrors,
rulesLastRunOutcomesTotal,
rulesStatusesTotal,
setInputText,
showActionFilter = true,
showErrors,
tags,
updateFilters,
} = props;
const isRuleTagFilterEnabled = getIsExperimentalFeatureEnabled('ruleTagFilter');
@ -136,6 +135,19 @@ export const RulesListFiltersBar = React.memo((props: RulesListFiltersBarProps)
...getRuleTagFilter(),
];
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
if (e.target.value === '') {
updateFilters({ filter: 'searchText', value: e.target.value });
}
};
const handleKeyup = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.keyCode === ENTER_KEY) {
updateFilters({ filter: 'searchText', value: inputText });
}
};
return (
<>
<RulesListErrorBanner
@ -150,25 +162,16 @@ export const RulesListFiltersBar = React.memo((props: RulesListFiltersBarProps)
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldSearch
data-test-subj="ruleSearchField"
fullWidth
isClearable
data-test-subj="ruleSearchField"
value={inputText}
onChange={(e) => {
setInputText(e.target.value);
if (e.target.value === '') {
updateFilters({ filter: 'searchText', value: e.target.value });
}
}}
onKeyUp={(e) => {
if (e.keyCode === ENTER_KEY) {
updateFilters({ filter: 'searchText', value: inputText });
}
}}
placeholder={i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.searchPlaceholderTitle',
{ defaultMessage: 'Search' }
)}
value={inputText}
onChange={handleChange}
onKeyUp={handleKeyup}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{renderRuleStatusFilter()}</EuiFlexItem>

View file

@ -374,6 +374,11 @@ export interface RuleTypeParamsExpressionProps<
unifiedSearch: UnifiedSearchPublicPluginStart;
}
export type RuleParamsForRules = Record<
string,
Array<{ label: string; value: string | number | object }>
>;
export interface RuleTypeModel<Params extends RuleTypeParams = RuleTypeParams> {
id: string;
description: string;
@ -688,13 +693,14 @@ export interface ConnectorServices {
}
export interface RulesListFilters {
searchText: string;
types: string[];
actionTypes: string[];
ruleExecutionStatuses: string[];
ruleLastRunOutcomes: string[];
ruleParams: Record<string, string | number | object>;
ruleStatuses: RuleStatus[];
searchText: string;
tags: string[];
types: string[];
}
export type UpdateFiltersProps =
@ -709,6 +715,10 @@ export type UpdateFiltersProps =
| {
filter: 'types' | 'actionTypes' | 'ruleExecutionStatuses' | 'ruleLastRunOutcomes' | 'tags';
value: string[];
}
| {
filter: 'ruleParams';
value: Record<string, string | number | object>;
};
export interface RulesPageContainerState {

View file

@ -44,7 +44,6 @@
"@kbn/ui-theme",
"@kbn/datemath",
"@kbn/core-capabilities-common",
"@kbn/safer-lodash-set",
"@kbn/shared-ux-router",
"@kbn/alerts-ui-shared",
"@kbn/safer-lodash-set",
@ -52,7 +51,7 @@
"@kbn/field-types",
"@kbn/ecs",
"@kbn/alerts-as-data-utils",
"@kbn/core-ui-settings-common"
"@kbn/core-ui-settings-common",
],
"exclude": ["target/**/*"]
}