From 07985db97a4f926e798fa9f3ca59085856d10aaa Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Tue, 3 May 2022 15:57:25 -0700 Subject: [PATCH] [RAM] Add shareable rule status filter (#130705) * rule state filter * turn off experiment * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Status filter API call * Fix tests * rename state to status, added tests * Address comments and fix tests * Revert experiment flag * Remove unused translations * Addressed comments Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/hooks/use_fetch_rules.ts | 2 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../common/experimental_features.ts | 1 + .../rule_status_filter_sandbox.tsx | 26 ++++ .../shareable_components_sandbox.tsx | 2 + .../lib/rule_api/aggregate.test.ts | 80 +++++++++++ .../application/lib/rule_api/aggregate.ts | 13 +- .../lib/rule_api/map_filters_to_kql.test.ts | 56 +++++++- .../lib/rule_api/map_filters_to_kql.ts | 41 +++++- .../application/lib/rule_api/rules.test.ts | 97 +++++++++++++ .../public/application/lib/rule_api/rules.ts | 13 +- .../public/application/sections/index.tsx | 3 + .../sections/rule_details/components/rule.tsx | 2 +- .../rule_execution_status_filter.tsx | 107 +++++++++++++++ .../components/rule_status_filter.test.tsx | 64 +++++++++ .../components/rule_status_filter.tsx | 127 ++++++++---------- .../rules_list/components/rules_list.test.tsx | 41 ++++++ .../rules_list/components/rules_list.tsx | 32 ++++- .../common/get_experimental_features.test.tsx | 5 + .../public/common/get_rule_status_filter.tsx | 14 ++ .../public/common/types.ts | 2 + .../triggers_actions_ui/public/mocks.ts | 4 + .../triggers_actions_ui/public/plugin.ts | 6 + .../triggers_actions_ui/public/types.ts | 6 +- .../apps/triggers_actions_ui/alerts_list.ts | 63 ++++++++- .../apps/triggers_actions_ui/index.ts | 1 + .../triggers_actions_ui/rule_status_filter.ts | 54 ++++++++ x-pack/test/functional_with_es_ssl/config.ts | 1 + .../lib/alert_api_actions.ts | 13 ++ 31 files changed, 785 insertions(+), 94 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_filter_sandbox.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/get_rule_status_filter.tsx create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_status_filter.ts diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index 00cb58e504bdc..a09626654e6f8 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -42,7 +42,7 @@ export function useFetchRules({ page, searchText, typesFilter: typesFilter.length > 0 ? typesFilter : OBSERVABILITY_RULE_TYPES, - ruleStatusesFilter: ruleLastResponseFilter, + ruleExecutionStatusesFilter: ruleLastResponseFilter, sort, }); setRulesState((oldState) => ({ diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 66cae51a7b414..9bc497a66f473 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -29364,7 +29364,6 @@ "xpack.triggersActionsUI.sections.rulesList.ruleStatusActive": "Actif", "xpack.triggersActionsUI.sections.rulesList.ruleStatusDropdownMenuLabel": "Modifier le statut de la règle ou répéter", "xpack.triggersActionsUI.sections.rulesList.ruleStatusError": "Erreur", - "xpack.triggersActionsUI.sections.rulesList.ruleStatusFilterLabel": "Dernière réponse", "xpack.triggersActionsUI.sections.rulesList.ruleStatusLicenseError": "Erreur de licence", "xpack.triggersActionsUI.sections.rulesList.ruleStatusOk": "Ok", "xpack.triggersActionsUI.sections.rulesList.ruleStatusPending": "En attente", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 70b2f076d5f8c..9c22df3fdabb2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -29433,7 +29433,6 @@ "xpack.triggersActionsUI.sections.rulesList.ruleStatusActive": "アクティブ", "xpack.triggersActionsUI.sections.rulesList.ruleStatusDropdownMenuLabel": "ルールステータスまたはスヌーズを変更", "xpack.triggersActionsUI.sections.rulesList.ruleStatusError": "エラー", - "xpack.triggersActionsUI.sections.rulesList.ruleStatusFilterLabel": "前回の応答", "xpack.triggersActionsUI.sections.rulesList.ruleStatusLicenseError": "ライセンスエラー", "xpack.triggersActionsUI.sections.rulesList.ruleStatusOk": "OK", "xpack.triggersActionsUI.sections.rulesList.ruleStatusPending": "保留中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e249662ac511b..6c6a5d60ed320 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -29468,7 +29468,6 @@ "xpack.triggersActionsUI.sections.rulesList.ruleStatusActive": "活动", "xpack.triggersActionsUI.sections.rulesList.ruleStatusDropdownMenuLabel": "更改规则状态或暂停", "xpack.triggersActionsUI.sections.rulesList.ruleStatusError": "错误", - "xpack.triggersActionsUI.sections.rulesList.ruleStatusFilterLabel": "上次响应", "xpack.triggersActionsUI.sections.rulesList.ruleStatusLicenseError": "许可证错误", "xpack.triggersActionsUI.sections.rulesList.ruleStatusOk": "确定", "xpack.triggersActionsUI.sections.rulesList.ruleStatusPending": "待处理", diff --git a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts index 21835a5977216..0a0b8cdeab208 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -15,6 +15,7 @@ export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, internalAlertsTable: false, internalShareableComponentsSandbox: false, + ruleStatusFilter: false, rulesDetailLogs: true, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_filter_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_filter_sandbox.tsx new file mode 100644 index 0000000000000..99ddd8daf16ac --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_status_filter_sandbox.tsx @@ -0,0 +1,26 @@ +/* + * 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 { RuleStatusFilterProps } from '../../../types'; +import { getRuleStatusFilterLazy } from '../../../common/get_rule_status_filter'; + +export const RuleStatusFilterSandbox = () => { + const [selectedStatuses, setSelectedStatuses] = useState< + RuleStatusFilterProps['selectedStatuses'] + >([]); + + return ( +
+ {getRuleStatusFilterLazy({ + selectedStatuses, + onChange: setSelectedStatuses, + })} +
Selected states: {JSON.stringify(selectedStatuses)}
+
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx index d756804bbd406..bedcbb03045a5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx @@ -7,12 +7,14 @@ import React from 'react'; import { RuleStatusDropdownSandbox } from './rule_status_dropdown_sandbox'; +import { RuleStatusFilterSandbox } from './rule_status_filter_sandbox'; import { RuleTagBadgeSandbox } from './rule_tag_badge_sandbox'; export const InternalShareableComponentsSandbox: React.FC<{}> = () => { return ( <> + ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts index 46653e5bc3911..ab8f1b565c888 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts @@ -209,4 +209,84 @@ describe('loadRuleAggregations', () => { ] `); }); + + test('should call aggregate API with ruleStatusesFilter', async () => { + const resolvedValue = { + rule_execution_status: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }; + http.get.mockResolvedValue(resolvedValue); + + let result = await loadRuleAggregations({ + http, + ruleStatusesFilter: ['enabled'], + }); + + expect(result).toEqual({ + ruleExecutionStatus: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }); + + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.enabled:(true) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + + result = await loadRuleAggregations({ + http, + ruleStatusesFilter: ['enabled', 'snoozed'], + }); + + expect(http.get.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + + result = await loadRuleAggregations({ + http, + ruleStatusesFilter: ['enabled', 'disabled', 'snoozed'], + }); + + expect(http.get.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts index c7bcd438ef697..9548445d0df9c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts @@ -6,7 +6,7 @@ */ import { HttpSetup } from '@kbn/core/public'; import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common'; -import { RuleAggregations } from '../../../types'; +import { RuleAggregations, RuleStatus } from '../../../types'; import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; import { mapFiltersToKql } from './map_filters_to_kql'; @@ -29,15 +29,22 @@ export async function loadRuleAggregations({ searchText, typesFilter, actionTypesFilter, + ruleExecutionStatusesFilter, ruleStatusesFilter, }: { http: HttpSetup; searchText?: string; typesFilter?: string[]; actionTypesFilter?: string[]; - ruleStatusesFilter?: string[]; + ruleExecutionStatusesFilter?: string[]; + ruleStatusesFilter?: RuleStatus[]; }): Promise { - const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, ruleStatusesFilter }); + const filters = mapFiltersToKql({ + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + }); const res = await http.get>( `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate`, { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts index e1dd14a7a9fde..df762d05e0eff 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts @@ -32,14 +32,62 @@ describe('mapFiltersToKql', () => { ]); }); - test('should handle ruleStatusesFilter', () => { + test('should handle ruleExecutionStatusesFilter', () => { expect( mapFiltersToKql({ - ruleStatusesFilter: ['alert', 'statuses', 'filter'], + ruleExecutionStatusesFilter: ['alert', 'statuses', 'filter'], }) ).toEqual(['alert.attributes.executionStatus.status:(alert or statuses or filter)']); }); + test('should handle ruleStatusesFilter', () => { + expect( + mapFiltersToKql({ + ruleStatusesFilter: ['enabled'], + }) + ).toEqual([ + 'alert.attributes.enabled:(true) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + ]); + + expect( + mapFiltersToKql({ + ruleStatusesFilter: ['disabled'], + }) + ).toEqual([ + 'alert.attributes.enabled:(false) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + ]); + + expect( + mapFiltersToKql({ + ruleStatusesFilter: ['snoozed'], + }) + ).toEqual(['(alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)']); + + expect( + mapFiltersToKql({ + ruleStatusesFilter: ['enabled', 'snoozed'], + }) + ).toEqual([ + 'alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + ]); + + expect( + mapFiltersToKql({ + ruleStatusesFilter: ['disabled', 'snoozed'], + }) + ).toEqual([ + 'alert.attributes.enabled:(false) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + ]); + + expect( + mapFiltersToKql({ + ruleStatusesFilter: ['enabled', 'disabled', 'snoozed'], + }) + ).toEqual([ + 'alert.attributes.enabled:(true or false) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)', + ]); + }); + test('should handle typesFilter and actionTypesFilter', () => { expect( mapFiltersToKql({ @@ -52,12 +100,12 @@ describe('mapFiltersToKql', () => { ]); }); - test('should handle typesFilter, actionTypesFilter and ruleStatusesFilter', () => { + test('should handle typesFilter, actionTypesFilter and ruleExecutionStatusesFilter', () => { expect( mapFiltersToKql({ typesFilter: ['type', 'filter'], actionTypesFilter: ['action', 'types', 'filter'], - ruleStatusesFilter: ['alert', 'statuses', 'filter'], + ruleExecutionStatusesFilter: ['alert', 'statuses', 'filter'], }) ).toEqual([ 'alert.attributes.alertTypeId:(type or filter)', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts index d7b22a7a4aee4..0e64f5500454f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts @@ -5,16 +5,34 @@ * 2.0. */ +import { RuleStatus } from '../../../types'; + +const getEnablementFilter = (ruleStatusFilter: RuleStatus[] = []) => { + const enablementFilters = ruleStatusFilter.reduce((result, filter) => { + if (filter === 'enabled') { + return [...result, 'true']; + } + if (filter === 'disabled') { + return [...result, 'false']; + } + return result; + }, []); + return `alert.attributes.enabled:(${enablementFilters.join(' or ')})`; +}; + export const mapFiltersToKql = ({ typesFilter, actionTypesFilter, + ruleExecutionStatusesFilter, ruleStatusesFilter, }: { typesFilter?: string[]; actionTypesFilter?: string[]; - ruleStatusesFilter?: string[]; + ruleExecutionStatusesFilter?: string[]; + ruleStatusesFilter?: RuleStatus[]; }): string[] => { const filters = []; + if (typesFilter && typesFilter.length) { filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`); } @@ -29,8 +47,27 @@ export const mapFiltersToKql = ({ ].join('') ); } + if (ruleExecutionStatusesFilter && ruleExecutionStatusesFilter.length) { + filters.push( + `alert.attributes.executionStatus.status:(${ruleExecutionStatusesFilter.join(' or ')})` + ); + } + if (ruleStatusesFilter && ruleStatusesFilter.length) { - filters.push(`alert.attributes.executionStatus.status:(${ruleStatusesFilter.join(' or ')})`); + const enablementFilter = getEnablementFilter(ruleStatusesFilter); + const snoozedFilter = `(alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)`; + const hasEnablement = + ruleStatusesFilter.includes('enabled') || ruleStatusesFilter.includes('disabled'); + const hasSnoozed = ruleStatusesFilter.includes('snoozed'); + + if (hasEnablement && !hasSnoozed) { + filters.push(`${enablementFilter} and not ${snoozedFilter}`); + } else if (!hasEnablement && hasSnoozed) { + filters.push(snoozedFilter); + } else { + filters.push(`${enablementFilter} or ${snoozedFilter}`); + } } + return filters; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts index 5f6c6e938a0a7..8adc92738b7c6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts @@ -239,4 +239,101 @@ describe('loadRules', () => { ] `); }); + + test('should call find API with ruleStatusesilter', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValue(resolvedValue); + + let result = await loadRules({ + http, + ruleStatusesFilter: ['enabled', 'snoozed'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.enabled:(true) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + + result = await loadRules({ + http, + ruleStatusesFilter: ['disabled'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.enabled:(false) and not (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + + result = await loadRules({ + http, + ruleStatusesFilter: ['enabled', 'disabled', 'snoozed'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[2]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.enabled:(true or false) or (alert.attributes.muteAll:true OR alert.attributes.snoozeEndTime > now)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts index 52ba09a5c0adf..bdbdcf2f094b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts @@ -7,7 +7,7 @@ import { HttpSetup } from '@kbn/core/public'; import { AsApiContract } from '@kbn/actions-plugin/common'; import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; -import { Rule, Pagination, Sorting } from '../../../types'; +import { Rule, Pagination, Sorting, RuleStatus } from '../../../types'; import { mapFiltersToKql } from './map_filters_to_kql'; import { transformRule } from './common_transformations'; @@ -21,6 +21,7 @@ export async function loadRules({ searchText, typesFilter, actionTypesFilter, + ruleExecutionStatusesFilter, ruleStatusesFilter, sort = { field: 'name', direction: 'asc' }, }: { @@ -29,7 +30,8 @@ export async function loadRules({ searchText?: string; typesFilter?: string[]; actionTypesFilter?: string[]; - ruleStatusesFilter?: string[]; + ruleExecutionStatusesFilter?: string[]; + ruleStatusesFilter?: RuleStatus[]; sort?: Sorting; }): Promise<{ page: number; @@ -37,7 +39,12 @@ export async function loadRules({ total: number; data: Rule[]; }> { - const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, ruleStatusesFilter }); + const filters = mapFiltersToKql({ + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + }); const res = await http.get< AsApiContract<{ page: number; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx index 0e59e3c8ca38f..e41c2a73a5124 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx @@ -32,6 +32,9 @@ export const ActionForm = suspendedComponentWithProps( export const RuleStatusDropdown = suspendedComponentWithProps( lazy(() => import('./rules_list/components/rule_status_dropdown')) ); +export const RuleStatusFilter = suspendedComponentWithProps( + lazy(() => import('./rules_list/components/rule_status_filter')) +); export const RuleTagBadge = suspendedComponentWithProps( lazy(() => import('./rules_list/components/rule_tag_badge')) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx index ad6ef32ab82be..9d62fc2f8e37a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx @@ -33,7 +33,7 @@ import { withBulkRuleOperations, } from '../../common/components/with_bulk_rule_api_operations'; import './rule.scss'; -import { getHealthColor } from '../../rules_list/components/rule_status_filter'; +import { getHealthColor } from '../../rules_list/components/rule_execution_status_filter'; import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx new file mode 100644 index 0000000000000..9acb8489fa09a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx @@ -0,0 +1,107 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFilterGroup, + EuiPopover, + EuiFilterButton, + EuiFilterSelectItem, + EuiHealth, +} from '@elastic/eui'; +import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; +import { rulesStatusesTranslationsMapping } from '../translations'; + +interface RuleExecutionStatusFilterProps { + selectedStatuses: string[]; + onChange?: (selectedRuleStatusesIds: string[]) => void; +} + +export const RuleExecutionStatusFilter: React.FunctionComponent = ({ + selectedStatuses, + onChange, +}: RuleExecutionStatusFilterProps) => { + const [selectedValues, setSelectedValues] = useState(selectedStatuses); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedValues]); + + useEffect(() => { + setSelectedValues(selectedStatuses); + }, [selectedStatuses]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="ruleExecutionStatusFilterButton" + > + + + } + > +
+ {[...RuleExecutionStatusValues].sort().map((item: RuleExecutionStatuses) => { + const healthColor = getHealthColor(item); + return ( + { + const isPreviouslyChecked = selectedValues.includes(item); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item)); + } else { + setSelectedValues(selectedValues.concat(item)); + } + }} + checked={selectedValues.includes(item) ? 'on' : undefined} + data-test-subj={`ruleExecutionStatus${item}FilterOption`} + > + {rulesStatusesTranslationsMapping[item]} + + ); + })} +
+
+
+ ); +}; + +export function getHealthColor(status: RuleExecutionStatuses) { + switch (status) { + case 'active': + return 'success'; + case 'error': + return 'danger'; + case 'ok': + return 'primary'; + case 'pending': + return 'accent'; + case 'warning': + return 'warning'; + default: + return 'subdued'; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx new file mode 100644 index 0000000000000..f1f2957f9cada --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { RuleStatusFilter } from './rule_status_filter'; + +const onChangeMock = jest.fn(); + +describe('rule_state_filter', () => { + beforeEach(() => { + onChangeMock.mockReset(); + }); + + it('renders correctly', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiFilterSelectItem).exists()).toBeFalsy(); + expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy(); + + expect(wrapper.find('.euiNotificationBadge').text()).toEqual('0'); + }); + + it('can open the popover correctly', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="ruleStateFilterSelect"]').exists()).toBeFalsy(); + + wrapper.find(EuiFilterButton).simulate('click'); + + const statusItems = wrapper.find(EuiFilterSelectItem); + expect(statusItems.length).toEqual(3); + }); + + it('can select statuses', () => { + const wrapper = mountWithIntl( + + ); + + wrapper.find(EuiFilterButton).simulate('click'); + + wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + expect(onChangeMock).toHaveBeenCalledWith(['enabled']); + + wrapper.setProps({ + selectedStatuses: ['enabled'], + }); + + wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + expect(onChangeMock).toHaveBeenCalledWith([]); + + wrapper.find(EuiFilterSelectItem).at(1).simulate('click'); + expect(onChangeMock).toHaveBeenCalledWith(['enabled', 'disabled']); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx index cbb1a7f5455da..6d286ec6d09d7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx @@ -4,82 +4,87 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import React, { useEffect, useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiHealth, -} from '@elastic/eui'; -import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; -import { rulesStatusesTranslationsMapping } from '../translations'; +import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui'; +import { RuleStatus } from '../../../../types'; + +const statuses: RuleStatus[] = ['enabled', 'disabled', 'snoozed']; + +const optionStyles = { + textTransform: 'capitalize' as const, +}; -interface RuleStatusFilterProps { - selectedStatuses: string[]; - onChange?: (selectedRuleStatusesIds: string[]) => void; +const getOptionDataTestSubj = (status: RuleStatus) => `ruleStatusFilterOption-${status}`; + +export interface RuleStatusFilterProps { + selectedStatuses: RuleStatus[]; + dataTestSubj?: string; + selectDataTestSubj?: string; + buttonDataTestSubj?: string; + optionDataTestSubj?: (status: RuleStatus) => string; + onChange: (selectedStatuses: RuleStatus[]) => void; } -export const RuleStatusFilter: React.FunctionComponent = ({ - selectedStatuses, - onChange, -}: RuleStatusFilterProps) => { - const [selectedValues, setSelectedValues] = useState(selectedStatuses); +export const RuleStatusFilter = (props: RuleStatusFilterProps) => { + const { + selectedStatuses = [], + dataTestSubj = 'ruleStatusFilter', + selectDataTestSubj = 'ruleStatusFilterSelect', + buttonDataTestSubj = 'ruleStatusFilterButton', + optionDataTestSubj = getOptionDataTestSubj, + onChange = () => {}, + } = props; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); - useEffect(() => { - if (onChange) { - onChange(selectedValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedValues]); + const onFilterItemClick = useCallback( + (newOption: RuleStatus) => () => { + if (selectedStatuses.includes(newOption)) { + onChange(selectedStatuses.filter((option) => option !== newOption)); + return; + } + onChange([...selectedStatuses, newOption]); + }, + [selectedStatuses, onChange] + ); - useEffect(() => { - setSelectedValues(selectedStatuses); - }, [selectedStatuses]); + const onClick = useCallback(() => { + setIsPopoverOpen((prevIsOpen) => !prevIsOpen); + }, [setIsPopoverOpen]); return ( - + setIsPopoverOpen(false)} button={ 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleStatusFilterButton" + hasActiveFilters={selectedStatuses.length > 0} + numActiveFilters={selectedStatuses.length} + numFilters={selectedStatuses.length} + onClick={onClick} > } > -
- {[...RuleExecutionStatusValues].sort().map((item: RuleExecutionStatuses) => { - const healthColor = getHealthColor(item); +
+ {statuses.map((status) => { return ( { - const isPreviouslyChecked = selectedValues.includes(item); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item)); - } else { - setSelectedValues(selectedValues.concat(item)); - } - }} - checked={selectedValues.includes(item) ? 'on' : undefined} - data-test-subj={`ruleStatus${item}FilerOption`} + key={status} + style={optionStyles} + data-test-subj={optionDataTestSubj(status)} + onClick={onFilterItemClick(status)} + checked={selectedStatuses.includes(status) ? 'on' : undefined} > - {rulesStatusesTranslationsMapping[item]} + {status} ); })} @@ -89,19 +94,5 @@ export const RuleStatusFilter: React.FunctionComponent = ); }; -export function getHealthColor(status: RuleExecutionStatuses) { - switch (status) { - case 'active': - return 'success'; - case 'error': - return 'danger'; - case 'ok': - return 'primary'; - case 'pending': - return 'accent'; - case 'warning': - return 'warning'; - default: - return 'subdued'; - } -} +// eslint-disable-next-line import/no-default-export +export { RuleStatusFilter as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 727898d42a076..52c6e2d3ed149 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -20,6 +20,7 @@ import { parseDuration, } from '@kbn/alerting-plugin/common'; import { getFormattedDuration, getFormattedMilliseconds } from '../../../lib/monitoring_utils'; +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); @@ -59,6 +60,9 @@ jest.mock('../../../lib/capabilities', () => ({ hasShowActionsCapability: jest.fn(() => true), hasExecuteActionsCapability: jest.fn(() => true), })); +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn(), +})); const { loadRules, loadRuleTypes, loadRuleAggregations } = jest.requireMock('../../../lib/rule_api'); const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); @@ -95,6 +99,10 @@ ruleTypeRegistry.list.mockReturnValue([ruleType]); actionTypeRegistry.list.mockReturnValue([]); const useKibanaMock = useKibana as jest.Mocked; +beforeEach(() => { + (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => false); +}); + describe('rules_list component empty', () => { let wrapper: ReactWrapper; async function setup() { @@ -801,6 +809,39 @@ describe('rules_list component with items', () => { 'Warning: 6' ); }); + + it('does not render the status filter if the feature flag is off', async () => { + await setup(); + expect(wrapper.find('[data-test-subj="ruleStatusFilter"]').exists()).toBeFalsy(); + }); + + it('renders the status filter if the experiment is on', async () => { + (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => true); + await setup(); + expect(wrapper.find('[data-test-subj="ruleStatusFilter"]').exists()).toBeTruthy(); + }); + + it('can filter by rule states', async () => { + (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => true); + loadRules.mockReset(); + await setup(); + + expect(loadRules.mock.calls[0][0].ruleStatusesFilter).toEqual([]); + + wrapper.find('[data-test-subj="ruleStatusFilterButton"] button').simulate('click'); + + wrapper.find('[data-test-subj="ruleStatusFilterOption-enabled"]').first().simulate('click'); + + expect(loadRules.mock.calls[1][0].ruleStatusesFilter).toEqual(['enabled']); + + wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); + + expect(loadRules.mock.calls[2][0].ruleStatusesFilter).toEqual(['enabled', 'snoozed']); + + wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); + + expect(loadRules.mock.calls[3][0].ruleStatusesFilter).toEqual(['enabled']); + }); }); describe('rules_list component empty with show only capability', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 57c59f3f09782..b1255600b68de 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -58,6 +58,7 @@ import { RuleTableItem, RuleType, RuleTypeIndex, + RuleStatus, Pagination, Percentiles, TriggersActionsUiConfig, @@ -68,7 +69,7 @@ import { RuleQuickEditButtonsWithApi as RuleQuickEditButtons } from '../../commo import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions'; import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; -import { RuleStatusFilter, getHealthColor } from './rule_status_filter'; +import { RuleExecutionStatusFilter, getHealthColor } from './rule_execution_status_filter'; import { loadRules, loadRuleAggregations, @@ -98,6 +99,8 @@ import { RuleDurationFormat } from './rule_duration_format'; import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; +import { RuleStatusFilter } from './rule_status_filter'; +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; const ENTER_KEY = 13; @@ -153,7 +156,8 @@ export const RulesList: React.FunctionComponent = () => { const [inputText, setInputText] = useState(); const [typesFilter, setTypesFilter] = useState([]); const [actionTypesFilter, setActionTypesFilter] = useState([]); - const [ruleStatusesFilter, setRuleStatusesFilter] = useState([]); + const [ruleExecutionStatusesFilter, setRuleExecutionStatusesFilter] = useState([]); + const [ruleStatusesFilter, setRuleStatusesFilter] = useState([]); const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); @@ -163,6 +167,8 @@ export const RulesList: React.FunctionComponent = () => { ); const [showErrors, setShowErrors] = useState(false); + const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); + useEffect(() => { (async () => { setConfig(await triggersActionsUiConfig({ http })); @@ -225,6 +231,7 @@ export const RulesList: React.FunctionComponent = () => { percentileOptions, JSON.stringify(typesFilter), JSON.stringify(actionTypesFilter), + JSON.stringify(ruleExecutionStatusesFilter), JSON.stringify(ruleStatusesFilter), ]); @@ -284,6 +291,7 @@ export const RulesList: React.FunctionComponent = () => { searchText, typesFilter, actionTypesFilter, + ruleExecutionStatusesFilter, ruleStatusesFilter, sort, }); @@ -302,6 +310,7 @@ export const RulesList: React.FunctionComponent = () => { isEmpty(searchText) && isEmpty(typesFilter) && isEmpty(actionTypesFilter) && + isEmpty(ruleExecutionStatusesFilter) && isEmpty(ruleStatusesFilter) ); @@ -328,6 +337,7 @@ export const RulesList: React.FunctionComponent = () => { searchText, typesFilter, actionTypesFilter, + ruleExecutionStatusesFilter, ruleStatusesFilter, }); if (rulesAggs?.ruleExecutionStatus) { @@ -930,6 +940,15 @@ export const RulesList: React.FunctionComponent = () => { ); }; + const getRuleStatusFilter = () => { + if (isRuleStatusFilterEnabled) { + return [ + , + ]; + } + return []; + }; + const toolsRight = [ { }) )} />, + ...getRuleStatusFilter(), setActionTypesFilter(ids)} />, - setRuleStatusesFilter(ids)} + selectedStatuses={ruleExecutionStatusesFilter} + onChange={(ids: string[]) => setRuleExecutionStatusesFilter(ids)} />, { }} />   - setRuleStatusesFilter(['error'])}> + setRuleExecutionStatusesFilter(['error'])}> { rulesListDatagrid: true, internalAlertsTable: true, rulesDetailLogs: true, + ruleStatusFilter: true, internalShareableComponentsSandbox: true, }, }); @@ -38,6 +39,10 @@ describe('getIsExperimentalFeatureEnabled', () => { expect(result).toEqual(true); + result = getIsExperimentalFeatureEnabled('ruleStatusFilter'); + + expect(result).toEqual(true); + expect(() => getIsExperimentalFeatureEnabled('doesNotExist' as any)).toThrowError( `Invalid enable value doesNotExist. Allowed values are: ${allowedExperimentalValueKeys.join( ', ' diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rule_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_status_filter.tsx new file mode 100644 index 0000000000000..77ac3fc51d703 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_status_filter.tsx @@ -0,0 +1,14 @@ +/* + * 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 { RuleStatusFilter } from '../application/sections'; +import type { RuleStatusFilterProps } from '../application/sections/rules_list/components/rule_status_filter'; + +export const getRuleStatusFilterLazy = (props: RuleStatusFilterProps) => { + return ; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/types.ts b/x-pack/plugins/triggers_actions_ui/public/common/types.ts index 4aca07ad5482e..610962706661a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/types.ts @@ -24,3 +24,5 @@ export interface GroupByType { value: string; validNormalizedTypes: string[]; } + +export type { RuleStatus } from '../types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 959d959ef855a..cb79a1509a6c1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -26,6 +26,7 @@ import { } from './types'; import { getAlertsTableLazy } from './common/get_alerts_table'; import { getRuleStatusDropdownLazy } from './common/get_rule_status_dropdown'; +import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; function createStartMock(): TriggersAndActionsUIPublicPluginStart { @@ -65,6 +66,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getRuleStatusDropdown: (props) => { return getRuleStatusDropdownLazy(props); }, + getRuleStatusFilter: (props) => { + return getRuleStatusFilterLazy(props); + }, getRuleTagBadge: (props) => { return getRuleTagBadgeLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index e2c3be96271b9..1d9c3c07e44ca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -31,6 +31,7 @@ import { getAddAlertFlyoutLazy } from './common/get_add_alert_flyout'; import { getEditAlertFlyoutLazy } from './common/get_edit_alert_flyout'; import { getAlertsTableLazy } from './common/get_alerts_table'; import { getRuleStatusDropdownLazy } from './common/get_rule_status_dropdown'; +import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; import { @@ -47,6 +48,7 @@ import type { ConnectorEditFlyoutProps, AlertsTableProps, RuleStatusDropdownProps, + RuleStatusFilterProps, RuleTagBadgeProps, AlertsTableConfigurationRegistry, } from './types'; @@ -78,6 +80,7 @@ export interface TriggersAndActionsUIPublicPluginStart { ) => ReactElement; getAlertsTable: (props: AlertsTableProps) => ReactElement; getRuleStatusDropdown: (props: RuleStatusDropdownProps) => ReactElement; + getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; getRuleTagBadge: (props: RuleTagBadgeProps) => ReactElement; } @@ -252,6 +255,9 @@ export class Plugin getRuleStatusDropdown: (props: RuleStatusDropdownProps) => { return getRuleStatusDropdownLazy(props); }, + getRuleStatusFilter: (props: RuleStatusFilterProps) => { + return getRuleStatusFilterLazy(props); + }, getRuleTagBadge: (props: RuleTagBadgeProps) => { return getRuleTagBadgeLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index c59692ebad271..25efbfb6ecc38 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -48,7 +48,8 @@ import { import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; import { TypeRegistry } from './application/type_registry'; import type { ComponentOpts as RuleStatusDropdownProps } from './application/sections/rules_list/components/rule_status_dropdown'; -import { RuleTagBadgeProps } from './application/sections/rules_list/components/rule_tag_badge'; +import type { RuleStatusFilterProps } from './application/sections/rules_list/components/rule_status_filter'; +import type { RuleTagBadgeProps } from './application/sections/rules_list/components/rule_tag_badge'; // In Triggers and Actions we treat all `Alert`s as `SanitizedRule` // so the `Params` is a black-box of Record @@ -81,6 +82,7 @@ export type { ResolvedRule, SanitizedRule, RuleStatusDropdownProps, + RuleStatusFilterProps, RuleTagBadgeProps, }; export type { ActionType, AsApiContract }; @@ -429,3 +431,5 @@ export interface AlertsTableConfigurationRegistry { id: string; columns: EuiDataGridColumn[]; } + +export type RuleStatus = 'enabled' | 'disabled' | 'snoozed'; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 581edecc3d8bc..49a3fada3dbef 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -14,6 +14,7 @@ import { createFailingAlert, disableAlert, muteAlert, + snoozeAlert, } from '../../lib/alert_api_actions'; import { ObjectRemover } from '../../lib/object_remover'; import { generateUniqueKey } from '../../lib/get_test_data'; @@ -462,8 +463,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const refreshResults = await pageObjects.triggersActionsUI.getAlertsListWithStatus(); expect(refreshResults.map((item: any) => item.status).sort()).to.eql(['Error', 'Ok']); }); - await testSubjects.click('ruleStatusFilterButton'); - await testSubjects.click('ruleStatuserrorFilerOption'); // select Error status filter + await testSubjects.click('ruleExecutionStatusFilterButton'); + await testSubjects.click('ruleExecutionStatuserrorFilterOption'); // select Error status filter await retry.try(async () => { const filterErrorOnlyResults = await pageObjects.triggersActionsUI.getAlertsListWithStatus(); @@ -600,5 +601,63 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.missingOrFail('centerJustifiedSpinner'); }); + + it('should filter alerts by the rule status', async () => { + const assertRulesLength = async (length: number) => { + return await retry.try(async () => { + const rules = await pageObjects.triggersActionsUI.getAlertsList(); + expect(rules.length).to.equal(length); + }); + }; + + // Enabled alert + await createAlert({ + supertest, + objectRemover, + }); + const disabledAlert = await createAlert({ + supertest, + objectRemover, + }); + const snoozedAlert = await createAlert({ + supertest, + objectRemover, + }); + + await disableAlert({ + supertest, + alertId: disabledAlert.id, + }); + await snoozeAlert({ + supertest, + alertId: snoozedAlert.id, + }); + + await refreshAlertsList(); + await assertRulesLength(3); + + // Select enabled + await testSubjects.click('ruleStatusFilterButton'); + await testSubjects.click('ruleStatusFilterOption-enabled'); + await assertRulesLength(1); + + // Select disabled + await testSubjects.click('ruleStatusFilterOption-enabled'); + await testSubjects.click('ruleStatusFilterOption-disabled'); + await assertRulesLength(1); + + // Select snoozed + await testSubjects.click('ruleStatusFilterOption-disabled'); + await testSubjects.click('ruleStatusFilterOption-snoozed'); + await assertRulesLength(1); + + // Select disabled and snoozed + await testSubjects.click('ruleStatusFilterOption-disabled'); + await assertRulesLength(2); + + // Select all 3 + await testSubjects.click('ruleStatusFilterOption-enabled'); + await assertRulesLength(3); + }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index 270232d1aa0fd..9c57f29c6f707 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -17,6 +17,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./connectors')); loadTestFile(require.resolve('./alerts_table')); loadTestFile(require.resolve('./rule_status_dropdown')); + loadTestFile(require.resolve('./rule_status_filter')); loadTestFile(require.resolve('./rule_tag_badge')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_status_filter.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_status_filter.ts new file mode 100644 index 0000000000000..0afdc932b0289 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_status_filter.ts @@ -0,0 +1,54 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const esArchiver = getService('esArchiver'); + + describe('Rule status filter', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'triggersActions', + '/__components_sandbox' + ); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + }); + + it('should load from the shareable lazy loader', async () => { + await testSubjects.find('ruleStatusFilter'); + const exists = await testSubjects.exists('ruleStatusFilter'); + expect(exists).to.be(true); + }); + + it('should allow rule statuses to be filtered', async () => { + const ruleStatusFilter = await testSubjects.find('ruleStatusFilter'); + let badge = await ruleStatusFilter.findByCssSelector('.euiFilterButton__notification'); + expect(await badge.getVisibleText()).to.be('0'); + + await testSubjects.click('ruleStatusFilter'); + await testSubjects.click('ruleStatusFilterOption-enabled'); + + badge = await ruleStatusFilter.findByCssSelector('.euiFilterButton__notification'); + expect(await badge.getVisibleText()).to.be('1'); + + await testSubjects.click('ruleStatusFilterOption-disabled'); + + badge = await ruleStatusFilter.findByCssSelector('.euiFilterButton__notification'); + expect(await badge.getVisibleText()).to.be('2'); + + await testSubjects.click('ruleStatusFilterOption-enabled'); + expect(await badge.getVisibleText()).to.be('1'); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 5243b97898578..4783ad683c0cf 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -74,6 +74,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.trigger_actions_ui.enableExperimental=${JSON.stringify([ 'internalAlertsTable', 'internalShareableComponentsSandbox', + 'ruleStatusFilter', ])}`, `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, diff --git a/x-pack/test/functional_with_es_ssl/lib/alert_api_actions.ts b/x-pack/test/functional_with_es_ssl/lib/alert_api_actions.ts index 40e567c299826..ab15d4b2ec3f4 100644 --- a/x-pack/test/functional_with_es_ssl/lib/alert_api_actions.ts +++ b/x-pack/test/functional_with_es_ssl/lib/alert_api_actions.ts @@ -8,6 +8,8 @@ import type { ObjectRemover } from './object_remover'; import { getTestAlertData, getTestActionData } from './get_test_data'; +const FUTURE_SNOOZE_TIME = '9999-12-31T06:00:00.000Z'; + export async function createAlertManualCleanup({ supertest, overwrites = {}, @@ -85,3 +87,14 @@ export async function disableAlert({ supertest, alertId }: { supertest: any; ale .set('kbn-xsrf', 'foo'); return alert; } + +export async function snoozeAlert({ supertest, alertId }: { supertest: any; alertId: string }) { + const { body: alert } = await supertest + .post(`/internal/alerting/rule/${alertId}/_snooze`) + .set('kbn-xsrf', 'foo') + .set('content-type', 'application/json') + .send({ + snooze_end_time: FUTURE_SNOOZE_TIME, + }); + return alert; +}