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 33f5fdc44afcd..3265469bea640 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -15,8 +15,8 @@ export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, internalAlertsTable: false, internalShareableComponentsSandbox: false, - ruleTagFilter: false, - ruleStatusFilter: false, + ruleTagFilter: true, + ruleStatusFilter: true, rulesDetailLogs: true, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts new file mode 100644 index 0000000000000..b00101da6be83 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRuleAggregations } from './use_load_rule_aggregations'; +import { RuleStatus } from '../../types'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +const MOCK_AGGS = { + ruleEnabledStatus: { enabled: 2, disabled: 0 }, + ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }, + ruleMutedStatus: { muted: 0, unmuted: 2 }, + ruleTags: MOCK_TAGS, +}; + +jest.mock('../lib/rule_api', () => ({ + loadRuleAggregations: jest.fn(), +})); + +const { loadRuleAggregations } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadRuleAggregations', () => { + beforeEach(() => { + loadRuleAggregations.mockResolvedValue(MOCK_AGGS); + jest.clearAllMocks(); + }); + + it('should call loadRuleAggregations API and handle result', async () => { + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call loadRuleAggregation API with params and handle result', async () => { + const params = { + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call onError if API fails', async () => { + loadRuleAggregations.mockRejectedValue(''); + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts new file mode 100644 index 0000000000000..75f9e18ec2328 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts @@ -0,0 +1,83 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useState, useCallback, useMemo } from 'react'; +import { RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; +import { loadRuleAggregations, LoadRuleAggregationsProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +type UseLoadRuleAggregationsProps = Omit & { + onError: (message: string) => void; +}; + +export function useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, +}: UseLoadRuleAggregationsProps) { + const { http } = useKibana().services; + + const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( + RuleExecutionStatusValues.reduce>( + (prev: Record, status: string) => ({ + ...prev, + [status]: 0, + }), + {} + ) + ); + + const internalLoadRuleAggregations = useCallback(async () => { + try { + const rulesAggs = await loadRuleAggregations({ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + }); + if (rulesAggs?.ruleExecutionStatus) { + setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); + } + } catch (e) { + onError( + i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', + { + defaultMessage: 'Unable to load rule status info', + } + ) + ); + } + }, [ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + setRulesStatusesTotal, + ]); + + return useMemo( + () => ({ + loadRuleAggregations: internalLoadRuleAggregations, + rulesStatusesTotal, + setRulesStatusesTotal, + }), + [internalLoadRuleAggregations, rulesStatusesTotal, setRulesStatusesTotal] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts new file mode 100644 index 0000000000000..a309beeca58aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts @@ -0,0 +1,378 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRules } from './use_load_rules'; +import { + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '@kbn/alerting-plugin/common'; +import { RuleStatus } from '../../types'; + +jest.mock('../lib/rule_api', () => ({ + loadRules: jest.fn(), +})); + +const { loadRules } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); +const onPage = jest.fn(); + +const mockedRulesData = [ + { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + }, + { + success: true, + duration: 200000, + }, + { + success: false, + duration: 300000, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }, + { + id: '2', + name: 'test rule ok', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastDuration: 61000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 100000, + }, + { + success: true, + duration: 500000, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 100000, + p99: 500000, + }, + }, + }, + }, + { + id: '3', + name: 'test rule pending', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastDuration: 30234, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [{ success: false, duration: 100 }], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + }, + { + id: '4', + name: 'test rule error', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 122000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test rule license error', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, +]; + +const MOCK_RULE_DATA = { + page: 1, + perPage: 10000, + total: 4, + data: mockedRulesData, +}; + +describe('useLoadRules', () => { + beforeEach(() => { + loadRules.mockResolvedValue(MOCK_RULE_DATA); + jest.clearAllMocks(); + }); + + it('should call loadRules API and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + expect(result.current.initialLoad).toBeTruthy(); + expect(result.current.noData).toBeTruthy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(result.current.initialLoad).toBeFalsy(); + expect(result.current.noData).toBeFalsy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + expect(onPage).toBeCalledTimes(0); + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesState.data).toEqual(expect.arrayContaining(MOCK_RULE_DATA.data)); + expect(result.current.rulesState.totalItemCount).toEqual(MOCK_RULE_DATA.total); + }); + + it('should call loadRules API with params and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + }); + + it('should reset the page if the data is fetched while paged', async () => { + loadRules.mockResolvedValue({ + ...MOCK_RULE_DATA, + data: [], + }); + + const params = { + page: { + index: 1, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(onPage).toHaveBeenCalledWith({ + index: 0, + size: 25, + }); + }); + + it('should call onError if API fails', async () => { + loadRules.mockRejectedValue(''); + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts new file mode 100644 index 0000000000000..4afdfd4f26a72 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts @@ -0,0 +1,185 @@ +/* + * 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 { useMemo, useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { Rule, Pagination } from '../../types'; +import { loadRules, LoadRulesProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +type UseLoadRulesProps = Omit & { + onPage: (pagination: Pagination) => void; + onError: (message: string) => void; +}; + +interface UseLoadRulesState { + rulesState: RuleState; + noData: boolean; + initialLoad: boolean; +} + +enum ActionTypes { + SET_RULE_STATE = 'SET_RULE_STATE', + SET_LOADING = 'SET_LOADING', + SET_INITIAL_LOAD = 'SET_INITIAL_LOAD', + SET_NO_DATA = 'SET_NO_DATA', +} + +interface Action { + type: ActionTypes; + payload: boolean | RuleState; +} + +const initialState: UseLoadRulesState = { + rulesState: { + isLoading: false, + data: [], + totalItemCount: 0, + }, + noData: true, + initialLoad: true, +}; + +const reducer = (state: UseLoadRulesState, action: Action) => { + const { type, payload } = action; + switch (type) { + case ActionTypes.SET_RULE_STATE: + return { + ...state, + rulesState: payload as RuleState, + }; + case ActionTypes.SET_LOADING: + return { + ...state, + rulesState: { + ...state.rulesState, + isLoading: payload as boolean, + }, + }; + case ActionTypes.SET_INITIAL_LOAD: + return { + ...state, + initialLoad: payload as boolean, + }; + case ActionTypes.SET_NO_DATA: + return { + ...state, + noData: payload as boolean, + }; + default: + return state; + } +}; + +export function useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage, + onError, +}: UseLoadRulesProps) { + const { http } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const setRulesState = useCallback( + (rulesState: RuleState) => { + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: rulesState, + }); + }, + [dispatch] + ); + + const internalLoadRules = useCallback(async () => { + dispatch({ type: ActionTypes.SET_LOADING, payload: true }); + + try { + const rulesResponse = await loadRules({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + }); + + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: { + isLoading: false, + data: rulesResponse.data, + totalItemCount: rulesResponse.total, + }, + }); + + if (!rulesResponse.data?.length && page.index > 0) { + onPage({ ...page, index: 0 }); + } + + const isFilterApplied = !( + isEmpty(searchText) && + isEmpty(typesFilter) && + isEmpty(actionTypesFilter) && + isEmpty(ruleExecutionStatusesFilter) && + isEmpty(ruleStatusesFilter) && + isEmpty(tagsFilter) + ); + + dispatch({ + type: ActionTypes.SET_NO_DATA, + payload: rulesResponse.data.length === 0 && !isFilterApplied, + }); + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', { + defaultMessage: 'Unable to load rules', + }) + ); + dispatch({ type: ActionTypes.SET_LOADING, payload: false }); + } + dispatch({ type: ActionTypes.SET_INITIAL_LOAD, payload: false }); + }, [ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + dispatch, + onPage, + onError, + ]); + + return useMemo( + () => ({ + rulesState: state.rulesState, + noData: state.noData, + initialLoad: state.initialLoad, + loadRules: internalLoadRules, + setRulesState, + }), + [state, setRulesState, internalLoadRules] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts new file mode 100644 index 0000000000000..8973d869e0724 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.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 { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadTags } from './use_load_tags'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +jest.mock('../lib/rule_api', () => ({ + loadRuleTags: jest.fn(), +})); + +const { loadRuleTags } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadTags', () => { + beforeEach(() => { + loadRuleTags.mockResolvedValue({ + ruleTags: MOCK_TAGS, + }); + jest.clearAllMocks(); + }); + + it('should call loadRuleTags API and handle result', async () => { + const { result, waitForNextUpdate } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + await waitForNextUpdate(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(result.current.tags).toEqual(MOCK_TAGS); + }); + + it('should call onError if API fails', async () => { + loadRuleTags.mockRejectedValue(''); + + const { result } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(onError).toBeCalled(); + expect(result.current.tags).toEqual([]); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts new file mode 100644 index 0000000000000..3357f43a012f1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts @@ -0,0 +1,45 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useState, useCallback, useMemo } from 'react'; +import { loadRuleTags } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface UseLoadTagsProps { + onError: (message: string) => void; +} + +export function useLoadTags(props: UseLoadTagsProps) { + const { onError } = props; + const { http } = useKibana().services; + const [tags, setTags] = useState([]); + + const internalLoadTags = useCallback(async () => { + try { + const ruleTagsAggs = await loadRuleTags({ http }); + if (ruleTagsAggs?.ruleTags) { + setTags(ruleTagsAggs.ruleTags); + } + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { + defaultMessage: 'Unable to load rule tags', + }) + ); + } + }, [http, setTags, onError]); + + return useMemo( + () => ({ + tags, + loadTags: internalLoadTags, + setTags, + }), + [tags, internalLoadTags, setTags] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx index 4af95523dce29..ba45800e49bcb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { getRuleEventLogListLazy } from '../../../common/get_rule_event_log_list'; export const RuleEventLogListSandbox = () => { @@ -39,5 +40,5 @@ export const RuleEventLogListSandbox = () => { }), }; - return getRuleEventLogListLazy(props); + return
{getRuleEventLogListLazy(props)}
; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx new file mode 100644 index 0000000000000..7702b914cfd36 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx @@ -0,0 +1,16 @@ +/* + * 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 { getRulesListLazy } from '../../../common/get_rules_list'; + +const style = { + flex: 1, +}; + +export const RulesListSandbox = () => { + return
{getRulesListLazy()}
; +}; 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 af5a05acdf19a..018f0a8794c33 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 @@ -11,6 +11,7 @@ import { RuleTagFilterSandbox } from './rule_tag_filter_sandbox'; import { RuleStatusFilterSandbox } from './rule_status_filter_sandbox'; import { RuleTagBadgeSandbox } from './rule_tag_badge_sandbox'; import { RuleEventLogListSandbox } from './rule_event_log_list_sandbox'; +import { RulesListSandbox } from './rules_list_sandbox'; export const InternalShareableComponentsSandbox: React.FC<{}> = () => { return ( @@ -19,6 +20,7 @@ export const InternalShareableComponentsSandbox: React.FC<{}> = () => { + ); 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 1df6177443657..5df7cfc374f89 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 @@ -44,6 +44,16 @@ export async function loadRuleTags({ http }: { http: HttpSetup }): Promise { +}: LoadRuleAggregationsProps): Promise { const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index d0e7728498c5b..64d6b18b7ca5c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -7,6 +7,7 @@ export { alertingFrameworkHealth } from './health'; export { mapFiltersToKql } from './map_filters_to_kql'; +export type { LoadRuleAggregationsProps } from './aggregate'; export { loadRuleAggregations, loadRuleTags } from './aggregate'; export { createRule } from './create'; export { deleteRules } from './delete'; @@ -17,6 +18,7 @@ export { loadRuleSummary } from './rule_summary'; export { muteAlertInstance } from './mute_alert'; export { muteRule, muteRules } from './mute'; export { loadRuleTypes } from './rule_types'; +export type { LoadRulesProps } from './rules'; export { loadRules } from './rules'; export { loadRuleState } from './state'; export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations'; 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 6e527989cc91f..3db1cb8b0214d 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 @@ -11,6 +11,18 @@ import { Rule, Pagination, Sorting, RuleStatus } from '../../../types'; import { mapFiltersToKql } from './map_filters_to_kql'; import { transformRule } from './common_transformations'; +export interface LoadRulesProps { + http: HttpSetup; + page: Pagination; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; + tagsFilter?: string[]; + ruleExecutionStatusesFilter?: string[]; + ruleStatusesFilter?: RuleStatus[]; + sort?: Sorting; +} + const rewriteResponseRes = (results: Array>): Rule[] => { return results.map((item) => transformRule(item)); }; @@ -25,17 +37,7 @@ export async function loadRules({ ruleStatusesFilter, tagsFilter, sort = { field: 'name', direction: 'asc' }, -}: { - http: HttpSetup; - page: Pagination; - searchText?: string; - typesFilter?: string[]; - actionTypesFilter?: string[]; - tagsFilter?: string[]; - ruleExecutionStatusesFilter?: string[]; - ruleStatusesFilter?: RuleStatus[]; - sort?: Sorting; -}): Promise<{ +}: LoadRulesProps): Promise<{ page: number; perPage: number; total: 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 979630d2a5a99..bd2ef041535f3 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 @@ -44,3 +44,6 @@ export const RuleTagBadge = suspendedComponentWithProps( export const RuleEventLogList = suspendedComponentWithProps( lazy(() => import('./rule_details/components/rule_event_log_list')) ); +export const RulesList = suspendedComponentWithProps( + lazy(() => import('./rules_list/components/rules_list')) +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx index a136413d53e42..38d1a62de699a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { ActionType } from '../../../../types'; interface ActionTypeFilterProps { @@ -29,47 +29,52 @@ export const ActionTypeFilter: React.FunctionComponent = // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedValues]); + const onClick = useCallback( + (item: ActionType) => { + return () => { + const isPreviouslyChecked = selectedValues.includes(item.id); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.id)); + } else { + setSelectedValues(selectedValues.concat(item.id)); + } + }; + }, + [selectedValues, setSelectedValues] + ); + return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="actionTypeFilterButton" + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="actionTypeFilterButton" + > + + + } + > +
+ {actionTypes.map((item) => ( + - - - } - > -
- {actionTypes.map((item) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.id); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.id)); - } else { - setSelectedValues(selectedValues.concat(item.id)); - } - }} - checked={selectedValues.includes(item.id) ? 'on' : undefined} - data-test-subj={`actionType${item.id}FilterOption`} - > - {item.name} - - ))} -
- - + {item.name} +
+ ))} +
+
); }; 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 index 9acb8489fa09a..e5bb7ffd1b0e4 100644 --- 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 @@ -5,15 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiHealth, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiHealth } from '@elastic/eui'; import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; import { rulesStatusesTranslationsMapping } from '../translations'; @@ -22,6 +16,8 @@ interface RuleExecutionStatusFilterProps { onChange?: (selectedRuleStatusesIds: string[]) => void; } +const sortedRuleExecutionStatusValues = [...RuleExecutionStatusValues].sort(); + export const RuleExecutionStatusFilter: React.FunctionComponent = ({ selectedStatuses, onChange, @@ -29,6 +25,14 @@ export const RuleExecutionStatusFilter: React.FunctionComponent(selectedStatuses); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onTogglePopover = useCallback(() => { + setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen); + }, [setIsPopoverOpen]); + + const onClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, [setIsPopoverOpen]); + useEffect(() => { if (onChange) { onChange(selectedValues); @@ -41,51 +45,49 @@ export const RuleExecutionStatusFilter: React.FunctionComponent - 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]} - - ); - })} -
-
-
+ 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={onTogglePopover} + data-test-subj="ruleExecutionStatusFilterButton" + > + + + } + > +
+ {sortedRuleExecutionStatusValues.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]} + + ); + })} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx index 7c6a71e893f96..194bf86030e56 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx @@ -33,7 +33,7 @@ import { parseInterval } from '../../../../../common'; import { Rule } from '../../../../types'; -type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; +export type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; const SNOOZE_END_TIME_FORMAT = 'LL @ LT'; type DropdownRuleRecord = Pick; @@ -48,6 +48,7 @@ export interface ComponentOpts { isEditable: boolean; previousSnoozeInterval?: string | null; direction?: 'column' | 'row'; + hideSnoozeOption?: boolean; } const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ @@ -58,9 +59,9 @@ const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ ]; const PREV_SNOOZE_INTERVAL_KEY = 'triggersActionsUi_previousSnoozeInterval'; -const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: string) => void] = ( - propsInterval -) => { +export const usePreviousSnoozeInterval: ( + p?: string | null +) => [string | null, (n: string) => void] = (propsInterval) => { const intervalFromStorage = localStorage.getItem(PREV_SNOOZE_INTERVAL_KEY); const usePropsInterval = typeof propsInterval !== 'undefined'; const interval = usePropsInterval ? propsInterval : intervalFromStorage; @@ -74,7 +75,7 @@ const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: stri return [previousSnoozeInterval, storeAndSetPreviousSnoozeInterval]; }; -const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => +export const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => Boolean( (rule.isSnoozedUntil && new Date(rule.isSnoozedUntil).getTime() > Date.now()) || rule.muteAll ); @@ -88,6 +89,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ unsnoozeRule, isEditable, previousSnoozeInterval: propsPreviousSnoozeInterval, + hideSnoozeOption = false, direction = 'column', }: ComponentOpts) => { const [isEnabled, setIsEnabled] = useState(rule.enabled); @@ -224,6 +226,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ isSnoozed={isSnoozed} snoozeEndTime={rule.isSnoozedUntil} previousSnoozeInterval={previousSnoozeInterval} + hideSnoozeOption={hideSnoozeOption} /> ) : ( @@ -245,6 +248,7 @@ interface RuleStatusMenuProps { isSnoozed: boolean; snoozeEndTime?: Date | null; previousSnoozeInterval: string | null; + hideSnoozeOption?: boolean; } const RuleStatusMenu: React.FunctionComponent = ({ @@ -255,6 +259,7 @@ const RuleStatusMenu: React.FunctionComponent = ({ isSnoozed, snoozeEndTime, previousSnoozeInterval, + hideSnoozeOption = false, }) => { const enableRule = useCallback(() => { if (isSnoozed) { @@ -290,6 +295,44 @@ const RuleStatusMenu: React.FunctionComponent = ({ ); } + const getSnoozeMenuItem = () => { + if (!hideSnoozeOption) { + return [ + { + name: snoozeButtonTitle, + icon: isEnabled && isSnoozed ? 'check' : 'empty', + panel: 1, + disabled: !isEnabled, + 'data-test-subj': 'statusDropdownSnoozeItem', + }, + ]; + } + return []; + }; + + const getSnoozePanel = () => { + if (!hideSnoozeOption) { + return [ + { + id: 1, + width: 360, + title: SNOOZE, + content: ( + + + + ), + }, + ]; + } + return []; + }; + const panels = [ { id: 0, @@ -307,28 +350,10 @@ const RuleStatusMenu: React.FunctionComponent = ({ onClick: disableRule, 'data-test-subj': 'statusDropdownDisabledItem', }, - { - name: snoozeButtonTitle, - icon: isEnabled && isSnoozed ? 'check' : 'empty', - panel: 1, - disabled: !isEnabled, - 'data-test-subj': 'statusDropdownSnoozeItem', - }, + ...getSnoozeMenuItem(), ], }, - { - id: 1, - width: 360, - title: SNOOZE, - content: ( - - ), - }, + ...getSnoozePanel(), ]; return ; @@ -336,13 +361,15 @@ const RuleStatusMenu: React.FunctionComponent = ({ interface SnoozePanelProps { interval?: string; + isLoading?: boolean; applySnooze: (value: number | -1, unit?: SnoozeUnit) => void; showCancel: boolean; previousSnoozeInterval: string | null; } -const SnoozePanel: React.FunctionComponent = ({ +export const SnoozePanel: React.FunctionComponent = ({ interval = '3d', + isLoading = false, applySnooze, showCancel, previousSnoozeInterval, @@ -394,9 +421,9 @@ const SnoozePanel: React.FunctionComponent = ({ ); return ( - + <> - + = ({ /> - + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.applySnooze', { defaultMessage: 'Apply', })} @@ -471,7 +502,12 @@ const SnoozePanel: React.FunctionComponent = ({ - + Cancel snooze @@ -479,11 +515,11 @@ const SnoozePanel: React.FunctionComponent = ({ )} - + ); }; -const futureTimeToInterval = (time?: Date | null) => { +export const futureTimeToInterval = (time?: Date | null) => { if (!time) return; const relativeTime = moment(time).locale('en').fromNow(true); const [valueStr, unitStr] = relativeTime.split(' '); 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 index f1f2957f9cada..a7d3bdfb8e2e0 100644 --- 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 @@ -7,12 +7,12 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiSelectableListItem } from '@elastic/eui'; import { RuleStatusFilter } from './rule_status_filter'; const onChangeMock = jest.fn(); -describe('rule_state_filter', () => { +describe('RuleStatusFilter', () => { beforeEach(() => { onChangeMock.mockReset(); }); @@ -22,7 +22,7 @@ describe('rule_state_filter', () => { ); - expect(wrapper.find(EuiFilterSelectItem).exists()).toBeFalsy(); + expect(wrapper.find(EuiSelectableListItem).exists()).toBeFalsy(); expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy(); expect(wrapper.find('.euiNotificationBadge').text()).toEqual('0'); @@ -37,7 +37,7 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - const statusItems = wrapper.find(EuiFilterSelectItem); + const statusItems = wrapper.find(EuiSelectableListItem); expect(statusItems.length).toEqual(3); }); @@ -48,17 +48,17 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith(['enabled']); wrapper.setProps({ selectedStatuses: ['enabled'], }); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith([]); - wrapper.find(EuiFilterSelectItem).at(1).simulate('click'); + wrapper.find(EuiSelectableListItem).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 6d286ec6d09d7..f26b3f54c587e 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 @@ -6,7 +6,13 @@ */ import React, { useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui'; +import { + EuiFilterButton, + EuiPopover, + EuiFilterGroup, + EuiSelectableListItem, + EuiButtonEmpty, +} from '@elastic/eui'; import { RuleStatus } from '../../../../types'; const statuses: RuleStatus[] = ['enabled', 'disabled', 'snoozed']; @@ -53,6 +59,24 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => { setIsPopoverOpen((prevIsOpen) => !prevIsOpen); }, [setIsPopoverOpen]); + const renderClearAll = () => { + return ( +
+ onChange([])} + > + Clear all + +
+ ); + }; + return ( { > } @@ -77,7 +101,7 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => {
{statuses.map((status) => { return ( - { checked={selectedStatuses.includes(status) ? 'on' : undefined} > {status} - + ); })} + {renderClearAll()}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx index 636bcaf1acb22..47b93ff19c6ea 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx @@ -9,7 +9,6 @@ import React, { useMemo, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSelectable, - EuiFilterGroup, EuiFilterButton, EuiPopover, EuiSelectableProps, @@ -103,29 +102,32 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => { }; return ( - - - - {(list, search) => ( - <> - {search} - - {list} - - )} - - - + + + {(list, search) => ( + <> + {search} + + {list} + + )} + + ); }; 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 7827033138fbb..893d6cf7bc5ad 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 @@ -365,7 +365,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect(screen.queryByText("You can't recover the old API key")).not.toBeInTheDocument(); expect(addSuccess).toHaveBeenCalledWith('API key has been updated'); }); @@ -390,7 +390,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect( screen.queryByText('You will not be able to recover the old API key') ).not.toBeInTheDocument(); @@ -514,7 +514,6 @@ describe('rules_list component with items', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; wrapper = mountWithIntl(); - await act(async () => { await nextTick(); wrapper.update(); @@ -561,7 +560,7 @@ describe('rules_list component with items', () => { .simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe('Start time of the last run.'); @@ -580,7 +579,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -605,7 +604,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="rulesTableCell-durationTooltip"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -627,7 +626,7 @@ describe('rules_list component with items', () => { wrapper.find('EuiButtonEmpty[data-test-subj="ruleStatus-error-license-fix"]').length ).toEqual(1); - expect(wrapper.find('[data-test-subj="refreshRulesButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="rulesListAutoRefresh"]').exists()).toBeTruthy(); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').first().text()).toEqual( 'Error' @@ -724,7 +723,7 @@ describe('rules_list component with items', () => { .first() .simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); // Percentile Selection @@ -740,7 +739,7 @@ describe('rules_list component with items', () => { // Select P95 percentileOptions.at(1).simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect( @@ -795,18 +794,6 @@ describe('rules_list component with items', () => { jest.clearAllMocks(); }); - it('loads rules when refresh button is clicked', async () => { - await setup(); - wrapper.find('[data-test-subj="refreshRulesButton"]').first().simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(loadRules).toHaveBeenCalled(); - }); - it('renders license errors and manage license modal on click', async () => { global.open = jest.fn(); await setup(); @@ -854,7 +841,7 @@ describe('rules_list component with items', () => { it('sorts rules when clicking the status control column', async () => { await setup(); wrapper - .find('[data-test-subj="tableHeaderCell_enabled_8"] .euiTableHeaderButton') + .find('[data-test-subj="tableHeaderCell_enabled_9"] .euiTableHeaderButton') .first() .simulate('click'); @@ -923,21 +910,37 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].ruleStatusesFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: [], + }) + ); 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']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[2][0].ruleStatusesFilter).toEqual(['enabled', 'snoozed']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled', 'snoozed'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[3][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); }); it('does not render the tag filter is the feature flag is off', async () => { @@ -956,7 +959,11 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].tagsFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleTagFilterButton"] button').simulate('click'); @@ -967,11 +974,19 @@ describe('rules_list component with items', () => { tagFilterListItems.at(0).simulate('click'); - expect(loadRules.mock.calls[1][0].tagsFilter).toEqual(['a']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a'], + }) + ); tagFilterListItems.at(1).simulate('click'); - expect(loadRules.mock.calls[2][0].tagsFilter).toEqual(['a', 'b']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a', 'b'], + }) + ); }); }); @@ -1255,4 +1270,21 @@ describe('rules_list with disabled items', () => { wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').props().content ).toEqual('This rule type requires a Platinum license.'); }); + + it('clicking the notify badge shows the snooze panel', async () => { + await setup(); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeFalsy(); + + wrapper + .find('[data-test-subj="rulesTableCell-rulesListNotify"]') + .first() + .simulate('mouseenter'); + + expect(wrapper.find('[data-test-subj="rulesListNotifyBadge"]').exists()).toBeTruthy(); + + wrapper.find('[data-test-subj="rulesListNotifyBadge"]').first().simulate('click'); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeTruthy(); + }); }); 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 9c3f1415e6641..b8afb2d3124ef 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 @@ -8,49 +8,36 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { i18n } from '@kbn/i18n'; -import { capitalize, sortBy } from 'lodash'; import moment from 'moment'; +import { capitalize, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useEffect, useState, useMemo, ReactNode, useCallback } from 'react'; +import React, { useEffect, useState, ReactNode, useCallback, useMemo } from 'react'; import { - EuiBasicTable, EuiButton, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, - EuiIconTip, + EuiFilterGroup, EuiSpacer, EuiLink, EuiEmptyPrompt, - EuiButtonEmpty, EuiHealth, EuiText, - EuiToolTip, EuiTableSortingType, EuiButtonIcon, EuiHorizontalRule, EuiSelectableOption, EuiIcon, - EuiScreenReaderOnly, - RIGHT_ALIGNMENT, EuiDescriptionList, - EuiTableFieldDataColumnType, - EuiTableComputedColumnType, - EuiTableActionsColumnType, EuiCallOut, } from '@elastic/eui'; import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; import { useHistory } from 'react-router-dom'; -import { isEmpty } from 'lodash'; import { RuleExecutionStatus, - RuleExecutionStatusValues, ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons, - formatDuration, - parseDuration, - MONITORING_HISTORY_LIMIT, } from '@kbn/alerting-plugin/common'; import { ActionType, @@ -69,11 +56,8 @@ 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 { RuleExecutionStatusFilter, getHealthColor } from './rule_execution_status_filter'; +import { RuleExecutionStatusFilter } from './rule_execution_status_filter'; import { - loadRules, - loadRuleAggregations, - loadRuleTags, loadRuleTypes, disableRule, enableRule, @@ -87,23 +71,21 @@ import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capab import { routeToRuleDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; -import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { ALERT_STATUS_LICENSE_ERROR } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; import './rules_list.scss'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import { ManageLicenseModal } from './manage_license_modal'; -import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; -import { RuleStatusDropdown } from './rule_status_dropdown'; -import { RuleTagBadge } from './rule_tag_badge'; -import { PercentileSelectablePopover } from './percentile_selectable_popover'; -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 { RuleTagFilter } from './rule_tag_filter'; import { RuleStatusFilter } from './rule_status_filter'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +import { useLoadRules } from '../../../hooks/use_load_rules'; +import { useLoadTags } from '../../../hooks/use_load_tags'; +import { useLoadRuleAggregations } from '../../../hooks/use_load_rule_aggregations'; +import { RulesListTable, convertRulesToTableItems } from './rules_list_table'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; const ENTER_KEY = 13; @@ -113,17 +95,6 @@ interface RuleTypeState { isInitialized: boolean; data: RuleTypeIndex; } -interface RuleState { - isLoading: boolean; - data: Rule[]; - totalItemCount: number; -} - -const percentileOrdinals = { - [Percentiles.P50]: '50th', - [Percentiles.P95]: '95th', - [Percentiles.P99]: '99th', -}; export const percentileFields = { [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', @@ -149,8 +120,6 @@ export const RulesList: React.FunctionComponent = () => { } = useKibana().services; const canExecuteActions = hasExecuteActionsCapability(capabilities); - const [initialLoad, setInitialLoad] = useState(true); - const [noData, setNoData] = useState(true); const [config, setConfig] = useState({ isUsingSecurity: false }); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -162,16 +131,15 @@ export const RulesList: React.FunctionComponent = () => { const [actionTypesFilter, setActionTypesFilter] = useState([]); const [ruleExecutionStatusesFilter, setRuleExecutionStatusesFilter] = useState([]); const [ruleStatusesFilter, setRuleStatusesFilter] = useState([]); - const [tags, setTags] = useState([]); const [tagsFilter, setTagsFilter] = useState([]); const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); - const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>( {} ); const [showErrors, setShowErrors] = useState(false); + const [lastUpdate, setLastUpdate] = useState(''); const isRuleTagFilterEnabled = getIsExperimentalFeatureEnabled('ruleTagFilter'); const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); @@ -185,13 +153,6 @@ export const RulesList: React.FunctionComponent = () => { const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); - const selectedPercentile = useMemo(() => { - const selectedOption = percentileOptions.find((option) => option.checked === 'on'); - if (selectedOption) { - return Percentiles[selectedOption.key as Percentiles]; - } - }, [percentileOptions]); - const [sort, setSort] = useState['sort']>({ field: 'name', direction: 'asc', @@ -200,27 +161,52 @@ export const RulesList: React.FunctionComponent = () => { licenseType: string; ruleTypeId: string; } | null>(null); - const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( - RuleExecutionStatusValues.reduce( - (prev: Record, status: string) => - ({ - ...prev, - [status]: 0, - } as Record), - {} - ) - ); const [ruleTypesState, setRuleTypesState] = useState({ isLoading: false, isInitialized: false, data: new Map(), }); - const [rulesState, setRulesState] = useState({ - isLoading: false, - data: [], - totalItemCount: 0, - }); + const [rulesToDelete, setRulesToDelete] = useState([]); + + const hasAnyAuthorizedRuleType = useMemo(() => { + return ruleTypesState.isInitialized && ruleTypesState.data.size > 0; + }, [ruleTypesState]); + + const onError = useCallback( + (message: string) => { + toasts.addDanger(message); + }, + [toasts] + ); + + const { rulesState, setRulesState, loadRules, noData, initialLoad } = useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage: setPage, + onError, + }); + + const { tags, loadTags } = useLoadTags({ + onError, + }); + + const { loadRuleAggregations, rulesStatusesTotal } = useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + }); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); const onRuleEdit = (ruleItem: RuleTableItem) => { setEditFlyoutVisibility(true); @@ -230,20 +216,30 @@ export const RulesList: React.FunctionComponent = () => { const isRuleTypeEditableInContext = (ruleTypeId: string) => ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; - useEffect(() => { - loadRulesData(); + const loadData = useCallback(async () => { + if (!ruleTypesState || !hasAnyAuthorizedRuleType) { + return; + } + await loadRules(); + await loadRuleAggregations(); + if (isRuleStatusFilterEnabled) { + await loadTags(); + } + setLastUpdate(moment().format()); }, [ + loadRules, + loadTags, + loadRuleAggregations, + setLastUpdate, + isRuleStatusFilterEnabled, + hasAnyAuthorizedRuleType, ruleTypesState, - page, - searchText, - percentileOptions, - JSON.stringify(typesFilter), - JSON.stringify(actionTypesFilter), - JSON.stringify(ruleExecutionStatusesFilter), - JSON.stringify(ruleStatusesFilter), - JSON.stringify(tagsFilter), ]); + useEffect(() => { + loadData(); + }, [loadData, percentileOptions]); + useEffect(() => { (async () => { try { @@ -289,218 +285,6 @@ export const RulesList: React.FunctionComponent = () => { })(); }, []); - async function loadRulesData() { - const hasAnyAuthorizedRuleType = ruleTypesState.isInitialized && ruleTypesState.data.size > 0; - if (hasAnyAuthorizedRuleType) { - setRulesState({ ...rulesState, isLoading: true }); - try { - const rulesResponse = await loadRules({ - http, - page, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - sort, - }); - await loadRuleTagsAggs(); - await loadRuleAggs(); - setRulesState({ - isLoading: false, - data: rulesResponse.data, - totalItemCount: rulesResponse.total, - }); - - if (!rulesResponse.data?.length && page.index > 0) { - setPage({ ...page, index: 0 }); - } - - const isFilterApplied = !( - isEmpty(searchText) && - isEmpty(typesFilter) && - isEmpty(actionTypesFilter) && - isEmpty(ruleExecutionStatusesFilter) && - isEmpty(ruleStatusesFilter) && - isEmpty(tagsFilter) - ); - - setNoData(rulesResponse.data.length === 0 && !isFilterApplied); - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', - { - defaultMessage: 'Unable to load rules', - } - ), - }); - setRulesState({ ...rulesState, isLoading: false }); - } - setInitialLoad(false); - } - } - - async function loadRuleAggs() { - try { - const rulesAggs = await loadRuleAggregations({ - http, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - }); - if (rulesAggs?.ruleExecutionStatus) { - setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', - { - defaultMessage: 'Unable to load rule status info', - } - ), - }); - } - } - - async function loadRuleTagsAggs() { - if (!isRuleTagFilterEnabled) { - return; - } - try { - const ruleTagsAggs = await loadRuleTags({ http }); - if (ruleTagsAggs?.ruleTags) { - setTags(ruleTagsAggs.ruleTags); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { - defaultMessage: 'Unable to load rule tags', - }), - }); - } - } - - const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, item: RuleTableItem) => { - return ( - await disableRule({ http, id: item.id })} - enableRule={async () => await enableRule({ http, id: item.id })} - snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { - await snoozeRule({ http, id: item.id, snoozeEndTime }); - }} - unsnoozeRule={async () => await unsnoozeRule({ http, id: item.id })} - rule={item} - onRuleChanged={() => loadRulesData()} - isEditable={item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)} - /> - ); - }; - - const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - const healthColor = getHealthColor(executionStatus.status); - const tooltipMessage = - executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; - const isLicenseError = - executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - const statusMessage = isLicenseError - ? ALERT_STATUS_LICENSE_ERROR - : rulesStatusesTranslationsMapping[executionStatus.status]; - - const health = ( - - {statusMessage} - - ); - - const healthWithTooltip = tooltipMessage ? ( - - {health} - - ) : ( - health - ); - - return ( - - {healthWithTooltip} - {isLicenseError && ( - - - setManageLicenseModalOpts({ - licenseType: ruleTypesState.data.get(item.ruleTypeId)?.minimumLicenseRequired!, - ruleTypeId: item.ruleTypeId, - }) - } - > - - - - )} - - ); - }; - - const renderPercentileColumnName = () => { - return ( - - - - {selectedPercentile}  - - - - - - ); - }; - - const renderPercentileCellValue = (value: number) => { - return ( - - - - ); - }; - - const getPercentileColumn = () => { - return { - mobileOptions: { header: false }, - field: percentileFields[selectedPercentile!], - width: '16%', - name: renderPercentileColumnName(), - 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', - sortable: true, - truncateText: false, - render: renderPercentileCellValue, - }; - }; - const buildErrorListItems = (_executionStatus: RuleExecutionStatus) => { const hasErrorMessage = _executionStatus.status === 'error'; const errorMessage = _executionStatus?.error?.message; @@ -563,383 +347,6 @@ export const RulesList: React.FunctionComponent = () => { }); }, [showErrors, rulesState]); - const getRulesTableColumns = (): Array< - | EuiTableFieldDataColumnType - | EuiTableComputedColumnType - | EuiTableActionsColumnType - > => { - return [ - { - field: 'name', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', - { defaultMessage: 'Name' } - ), - sortable: true, - truncateText: true, - width: '30%', - 'data-test-subj': 'rulesTableCell-name', - render: (name: string, rule: RuleTableItem) => { - const ruleType = ruleTypesState.data.get(rule.ruleTypeId); - const checkEnabledResult = checkRuleTypeEnabled(ruleType); - const link = ( - <> - - - - - { - history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); - }} - > - {name} - - - - {!checkEnabledResult.isEnabled && ( - - )} - - - - - - {rule.ruleType} - - - - - ); - return <>{link}; - }, - }, - { - field: 'tags', - name: '', - sortable: false, - width: '50px', - 'data-test-subj': 'rulesTableCell-tagsPopover', - render: (ruleTags: string[], item: RuleTableItem) => { - return ruleTags.length > 0 ? ( - setTagPopoverOpenIndex(item.index)} - onClose={() => setTagPopoverOpenIndex(-1)} - /> - ) : null; - }, - }, - { - field: 'executionStatus.lastExecutionDate', - name: ( - - - Last run{' '} - - - - ), - sortable: true, - width: '15%', - 'data-test-subj': 'rulesTableCell-lastExecutionDate', - render: (date: Date) => { - if (date) { - return ( - <> - - - {moment(date).format('MMM D, YYYY HH:mm:ssa')} - - - - {moment(date).fromNow()} - - - - - ); - } - }, - }, - { - field: 'schedule.interval', - width: '6%', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', - { defaultMessage: 'Interval' } - ), - sortable: false, - truncateText: false, - 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string, item: RuleTableItem) => { - const durationString = formatDuration(interval); - return ( - <> - - {durationString} - - {item.showIntervalWarning && ( - - { - if (item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)) { - onRuleEdit(item); - } - }} - iconType="flag" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', - { defaultMessage: 'Below configured minimum interval' } - )} - /> - - )} - - - - ); - }, - }, - { - field: 'executionStatus.lastDuration', - width: '12%', - name: ( - - - Duration{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-duration', - render: (value: number, item: RuleTableItem) => { - const showDurationWarning = shouldShowDurationWarning( - ruleTypesState.data.get(item.ruleTypeId), - value - ); - - return ( - <> - {} - {showDurationWarning && ( - - )} - - ); - }, - }, - getPercentileColumn(), - { - field: 'monitoring.execution.calculated_metrics.success_ratio', - width: '12%', - name: ( - - - Success ratio{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-successRatio', - render: (value: number) => { - return ( - - {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} - - ); - }, - }, - { - field: 'executionStatus.status', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', - { defaultMessage: 'Last response' } - ), - sortable: true, - truncateText: false, - width: '120px', - 'data-test-subj': 'rulesTableCell-lastResponse', - render: (_executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - return renderRuleExecutionStatus(item.executionStatus, item); - }, - }, - { - field: 'enabled', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', - { defaultMessage: 'State' } - ), - sortable: true, - truncateText: false, - width: '10%', - 'data-test-subj': 'rulesTableCell-status', - render: (_enabled: boolean | undefined, item: RuleTableItem) => { - return renderRuleStatusDropdown(item.enabled, item); - }, - }, - { - name: '', - width: '90px', - render(item: RuleTableItem) { - return ( - - - - {item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId) ? ( - - onRuleEdit(item)} - iconType={'pencil'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', - { defaultMessage: 'Edit' } - )} - /> - - ) : null} - {item.isEditable ? ( - - setRulesToDelete([item.id])} - iconType={'trash'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', - { defaultMessage: 'Delete' } - )} - /> - - ) : null} - - - - loadRulesData()} - setRulesToDelete={setRulesToDelete} - onEditRule={() => onRuleEdit(item)} - onUpdateAPIKey={setRulesToUpdateAPIKey} - /> - - - ); - }, - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - name: ( - - Expand rows - - ), - render: (item: RuleTableItem) => { - const _executionStatus = item.executionStatus; - const hasErrorMessage = _executionStatus.status === 'error'; - const isLicenseError = - _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - - return isLicenseError || hasErrorMessage ? ( - toggleErrorMessage(_executionStatus, item)} - aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'} - iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} - /> - ) : null; - }, - }, - ]; - }; - const authorizedRuleTypes = [...ruleTypesState.data.values()]; const authorizedToCreateAnyRules = authorizedRuleTypes.some( (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all @@ -979,13 +386,29 @@ export const RulesList: React.FunctionComponent = () => { return []; }; - const getRuleStatusFilter = () => { + const renderRuleStatusFilter = () => { if (isRuleStatusFilterEnabled) { - return [ - , - ]; + return ( + + ); } - return []; + return null; + }; + + const onDisableRule = (rule: RuleTableItem) => { + return disableRule({ http, id: rule.id }); + }; + + const onEnableRule = (rule: RuleTableItem) => { + return enableRule({ http, id: rule.id }); + }; + + const onSnoozeRule = (rule: RuleTableItem, snoozeEndTime: string | -1) => { + return snoozeRule({ http, id: rule.id, snoozeEndTime }); + }; + + const onUnsnoozeRule = (rule: RuleTableItem) => { + return unsnoozeRule({ http, id: rule.id }); }; const toolsRight = [ @@ -999,8 +422,6 @@ export const RulesList: React.FunctionComponent = () => { }) )} />, - ...getRuleTagFilter(), - ...getRuleStatusFilter(), { selectedStatuses={ruleExecutionStatusesFilter} onChange={(ids: string[]) => setRuleExecutionStatusesFilter(ids)} />, - - - , + ...getRuleTagFilter(), ]; const authorizedToModifySelectedRules = selectedIds.length @@ -1074,7 +484,7 @@ export const RulesList: React.FunctionComponent = () => { })} onPerformingAction={() => setIsPerformingAction(true)} onActionPerformed={() => { - loadRulesData(); + loadData(); setIsPerformingAction(false); }} setRulesToDelete={setRulesToDelete} @@ -1119,20 +529,19 @@ export const RulesList: React.FunctionComponent = () => { )} /> + {renderRuleStatusFilter()} - + {toolsRight.map((tool, index: number) => ( - - {tool} - + {tool} ))} - + - + { /> + {rulesStatusesTotal.error > 0 && ( @@ -1235,64 +645,66 @@ export const RulesList: React.FunctionComponent = () => { )} - - ({ - 'data-test-subj': 'rule-row', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableRowDisabled' - : '', - })} - cellProps={(item: RuleTableItem) => ({ - 'data-test-subj': 'cell', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableCellDisabled' - : '', - })} - data-test-subj="rulesList" - pagination={{ - pageIndex: page.index, - pageSize: page.size, - /* Don't display rule count until we have the rule types initialized */ - totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, - }} - selection={{ - selectable: (rule: RuleTableItem) => rule.isEditable, - onSelectionChange(updatedSelectedItemsList: RuleTableItem[]) { - setSelectedIds(updatedSelectedItemsList.map((item) => item.id)); - }, + loadData()} + onRuleClick={(rule) => { + history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }} - onChange={({ - page: changedPage, - sort: changedSort, - }: { - page?: Pagination; - sort?: EuiTableSortingType['sort']; - }) => { - if (changedPage) { - setPage(changedPage); - } - if (changedSort) { - setSort(changedSort); + onRuleEditClick={(rule) => { + if (rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)) { + onRuleEdit(rule); } }} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isExpandable={true} + onRuleDeleteClick={(rule) => setRulesToDelete([rule.id])} + onManageLicenseClick={(rule) => + setManageLicenseModalOpts({ + licenseType: ruleTypesState.data.get(rule.ruleTypeId)?.minimumLicenseRequired!, + ruleTypeId: rule.ruleTypeId, + }) + } + onSelectionChange={(updatedSelectedItemsList) => + setSelectedIds(updatedSelectedItemsList.map((item) => item.id)) + } + onPercentileOptionsChange={setPercentileOptions} + onDisableRule={onDisableRule} + onEnableRule={onEnableRule} + onSnoozeRule={onSnoozeRule} + onUnsnoozeRule={onUnsnoozeRule} + renderCollapsedItemActions={(rule) => ( + loadData()} + setRulesToDelete={setRulesToDelete} + onEditRule={() => onRuleEdit(rule)} + onUpdateAPIKey={setRulesToUpdateAPIKey} + /> + )} + renderRuleError={(rule) => { + const _executionStatus = rule.executionStatus; + const hasErrorMessage = _executionStatus.status === 'error'; + const isLicenseError = + _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + + return isLicenseError || hasErrorMessage ? ( + toggleErrorMessage(_executionStatus, rule)} + aria-label={itemIdToExpandedRowMap[rule.id] ? 'Collapse' : 'Expand'} + iconType={itemIdToExpandedRowMap[rule.id] ? 'arrowUp' : 'arrowDown'} + /> + ) : null; + }} + config={config} /> {manageLicenseModalOpts && ( { onDeleted={async () => { setRulesToDelete([]); setSelectedIds([]); - await loadRulesData(); + await loadData(); }} onErrors={async () => { - // Refresh the rules from the server, some rules may have been deleted - await loadRulesData(); + // Refresh the rules from the server, some rules may have beend deleted + await loadData(); setRulesToDelete([]); }} onCancel={() => { @@ -1364,7 +776,7 @@ export const RulesList: React.FunctionComponent = () => { }} onUpdated={async () => { setRulesToUpdateAPIKey([]); - await loadRulesData(); + await loadData(); }} /> @@ -1378,7 +790,7 @@ export const RulesList: React.FunctionComponent = () => { actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} ruleTypeIndex={ruleTypesState.data} - onSave={loadRulesData} + onSave={loadData} /> )} {editFlyoutVisible && currentRuleToEdit && ( @@ -1392,7 +804,7 @@ export const RulesList: React.FunctionComponent = () => { ruleType={ ruleTypesState.data.get(currentRuleToEdit.ruleTypeId) as RuleType } - onSave={loadRulesData} + onSave={loadData} /> )} @@ -1427,30 +839,3 @@ const noPermissionPrompt = ( function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } - -interface ConvertRulesToTableItemsOpts { - rules: Rule[]; - ruleTypeIndex: RuleTypeIndex; - canExecuteActions: boolean; - config: TriggersActionsUiConfig; -} - -function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { - const { rules, ruleTypeIndex, canExecuteActions, config } = opts; - const minimumDuration = config.minimumScheduleInterval - ? parseDuration(config.minimumScheduleInterval.value) - : 0; - return rules.map((rule, index: number) => { - return { - ...rule, - index, - actionsCount: rule.actions.length, - ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, - isEditable: - hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && - (canExecuteActions || (!canExecuteActions && !rule.actions.length)), - enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, - showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, - }; - }); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx new file mode 100644 index 0000000000000..9e17561ce652b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 moment from 'moment'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { act } from 'react-dom/test-utils'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; + +const onRefresh = jest.fn(); + +describe('RulesListAutoRefresh', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the update text correctly', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a few seconds ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a minute ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated 2 minutes ago'); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); + + it('calls onRefresh when it auto refreshes', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + mountWithIntl( + + ); + + expect(onRefresh).toHaveBeenCalledTimes(0); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(2); + + await act(async () => { + jest.advanceTimersByTime(10 * 1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(12); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx new file mode 100644 index 0000000000000..eea8d8e5f1bbe --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx @@ -0,0 +1,122 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiAutoRefreshButton } from '@elastic/eui'; + +interface RulesListAutoRefreshProps { + lastUpdate: string; + initialUpdateInterval?: number; + onRefresh: () => void; +} + +const flexGroupStyle = { + marginLeft: 'auto', +}; + +const getLastUpdateText = (lastUpdate: string) => { + if (!moment(lastUpdate).isValid()) { + return ''; + } + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListAutoRefresh.lastUpdateText', + { + defaultMessage: 'Updated {lastUpdateText}', + values: { + lastUpdateText: moment(lastUpdate).fromNow(), + }, + } + ); +}; + +const TEXT_UPDATE_INTERVAL = 60 * 1000; +const DEFAULT_REFRESH_INTERVAL = 5 * 60 * 1000; +const MIN_REFRESH_INTERVAL = 1000; + +export const RulesListAutoRefresh = (props: RulesListAutoRefreshProps) => { + const { lastUpdate, initialUpdateInterval = DEFAULT_REFRESH_INTERVAL, onRefresh } = props; + + const [isPaused, setIsPaused] = useState(false); + const [refreshInterval, setRefreshInterval] = useState( + Math.max(initialUpdateInterval, MIN_REFRESH_INTERVAL) + ); + const [lastUpdateText, setLastUpdateText] = useState(''); + + const cachedOnRefresh = useRef<() => void>(() => {}); + const textUpdateTimeout = useRef(); + const refreshTimeout = useRef(); + + useEffect(() => { + cachedOnRefresh.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + + const poll = () => { + textUpdateTimeout.current = window.setTimeout(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + poll(); + }, TEXT_UPDATE_INTERVAL); + }; + poll(); + + return () => { + if (textUpdateTimeout.current) { + clearTimeout(textUpdateTimeout.current); + } + }; + }, [lastUpdate, setLastUpdateText]); + + useEffect(() => { + if (isPaused) { + return; + } + + const poll = () => { + refreshTimeout.current = window.setTimeout(() => { + cachedOnRefresh.current(); + poll(); + }, refreshInterval); + }; + poll(); + + return () => { + if (refreshTimeout.current) { + clearTimeout(refreshTimeout.current); + } + }; + }, [isPaused, refreshInterval]); + + const onRefreshChange = useCallback( + ({ isPaused: newIsPaused, refreshInterval: newRefreshInterval }) => { + setIsPaused(newIsPaused); + setRefreshInterval(newRefreshInterval); + }, + [setIsPaused, setRefreshInterval] + ); + + return ( + + + + {lastUpdateText} + + + + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx new file mode 100644 index 0000000000000..1f03c76a7de0b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx @@ -0,0 +1,224 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import moment from 'moment'; +import { EuiButton, EuiButtonIcon, EuiPopover, EuiText, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isRuleSnoozed } from './rule_status_dropdown'; +import { RuleTableItem } from '../../../../types'; +import { + SnoozePanel, + futureTimeToInterval, + usePreviousSnoozeInterval, + SnoozeUnit, +} from './rule_status_dropdown'; + +export interface RulesListNotifyBadgeProps { + rule: RuleTableItem; + isOpen: boolean; + previousSnoozeInterval?: string | null; + onClick: React.MouseEventHandler; + onClose: () => void; + onRuleChanged: () => void; + snoozeRule: (snoozeEndTime: string | -1, interval: string | null) => Promise; + unsnoozeRule: () => Promise; +} + +const openSnoozePanelAriaLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.openSnoozePanel', + { defaultMessage: 'Open snooze panel' } +); + +export const RulesListNotifyBadge: React.FunctionComponent = (props) => { + const { + rule, + isOpen, + previousSnoozeInterval: propsPreviousSnoozeInterval, + onClick, + onClose, + onRuleChanged, + snoozeRule, + unsnoozeRule, + } = props; + + const { isSnoozedUntil, muteAll } = rule; + + const [previousSnoozeInterval, setPreviousSnoozeInterval] = usePreviousSnoozeInterval( + propsPreviousSnoozeInterval + ); + + const [isLoading, setIsLoading] = useState(false); + + const isSnoozedIndefinitely = muteAll; + + const isSnoozed = useMemo(() => { + return isRuleSnoozed(rule); + }, [rule]); + + const isScheduled = useMemo(() => { + // TODO: Implement scheduled check + return false; + }, []); + + const formattedSnoozeText = useMemo(() => { + if (!isSnoozedUntil) { + return ''; + } + return moment(isSnoozedUntil).format('MMM D'); + }, [isSnoozedUntil]); + + const snoozeTooltipText = useMemo(() => { + if (isSnoozedIndefinitely) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedIndefinitelyTooltip', + { defaultMessage: 'Notifications snoozed indefinitely' } + ); + } + if (isScheduled) { + return ''; + // TODO: Implement scheduled tooltip + } + if (isSnoozed) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedTooltip', + { + defaultMessage: 'Notifications snoozed for {snoozeTime}', + values: { + snoozeTime: moment(isSnoozedUntil).fromNow(true), + }, + } + ); + } + return ''; + }, [isSnoozedIndefinitely, isScheduled, isSnoozed, isSnoozedUntil]); + + const snoozedButton = useMemo(() => { + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const scheduledSnoozeButton = useMemo(() => { + // TODO: Implement scheduled snooze button + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const unsnoozedButton = useMemo(() => { + return ( + + ); + }, [isOpen, onClick]); + + const indefiniteSnoozeButton = useMemo(() => { + return ( + + ); + }, [onClick]); + + const button = useMemo(() => { + if (isScheduled) { + return scheduledSnoozeButton; + } + if (isSnoozedIndefinitely) { + return indefiniteSnoozeButton; + } + if (isSnoozed) { + return snoozedButton; + } + return unsnoozedButton; + }, [ + isSnoozed, + isScheduled, + isSnoozedIndefinitely, + scheduledSnoozeButton, + snoozedButton, + indefiniteSnoozeButton, + unsnoozedButton, + ]); + + const buttonWithToolTip = useMemo(() => { + if (isOpen) { + return button; + } + return {button}; + }, [isOpen, button, snoozeTooltipText]); + + const snoozeRuleAndStoreInterval = useCallback( + (newSnoozeEndTime: string | -1, interval: string | null) => { + if (interval) { + setPreviousSnoozeInterval(interval); + } + return snoozeRule(newSnoozeEndTime, interval); + }, + [setPreviousSnoozeInterval, snoozeRule] + ); + + const onChangeSnooze = useCallback( + async (value: number, unit?: SnoozeUnit) => { + setIsLoading(true); + try { + if (value === -1) { + await snoozeRuleAndStoreInterval(-1, null); + } else if (value !== 0) { + const newSnoozeEndTime = moment().add(value, unit).toISOString(); + await snoozeRuleAndStoreInterval(newSnoozeEndTime, `${value}${unit}`); + } else await unsnoozeRule(); + onRuleChanged(); + } finally { + onClose(); + setIsLoading(false); + } + }, + [onRuleChanged, onClose, snoozeRuleAndStoreInterval, unsnoozeRule, setIsLoading] + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx new file mode 100644 index 0000000000000..53a3b4b69f8c0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx @@ -0,0 +1,724 @@ +/* + * 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, { useMemo, useState } from 'react'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiLink, + EuiButtonEmpty, + EuiHealth, + EuiText, + EuiToolTip, + EuiTableSortingType, + EuiButtonIcon, + EuiSelectableOption, + EuiIcon, + EuiScreenReaderOnly, + RIGHT_ALIGNMENT, + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { + RuleExecutionStatus, + RuleExecutionStatusErrorReasons, + formatDuration, + parseDuration, + MONITORING_HISTORY_LIMIT, +} from '@kbn/alerting-plugin/common'; +import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { getHealthColor } from './rule_execution_status_filter'; +import { + Rule, + RuleTableItem, + RuleTypeIndex, + Pagination, + Percentiles, + TriggersActionsUiConfig, + RuleTypeRegistryContract, +} from '../../../../types'; +import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; +import { PercentileSelectablePopover } from './percentile_selectable_popover'; +import { RuleDurationFormat } from './rule_duration_format'; +import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; +import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; +import { hasAllPrivilege } from '../../../lib/capabilities'; +import { RuleTagBadge } from './rule_tag_badge'; +import { RuleStatusDropdown } from './rule_status_dropdown'; +import { RulesListNotifyBadge } from './rules_list_notify_badge'; + +interface RuleTypeState { + isLoading: boolean; + isInitialized: boolean; + data: RuleTypeIndex; +} + +export interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +const percentileOrdinals = { + [Percentiles.P50]: '50th', + [Percentiles.P95]: '95th', + [Percentiles.P99]: '99th', +}; + +export const percentileFields = { + [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', + [Percentiles.P95]: 'monitoring.execution.calculated_metrics.p95', + [Percentiles.P99]: 'monitoring.execution.calculated_metrics.p99', +}; + +const EMPTY_OBJECT = {}; +const EMPTY_HANDLER = () => {}; +const EMPTY_RENDER = () => null; + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export interface RulesListTableProps { + rulesState: RuleState; + ruleTypesState: RuleTypeState; + ruleTypeRegistry: RuleTypeRegistryContract; + isLoading?: boolean; + sort: EuiTableSortingType['sort']; + page: Pagination; + percentileOptions: EuiSelectableOption[]; + canExecuteActions?: boolean; + itemIdToExpandedRowMap?: Record; + config: TriggersActionsUiConfig; + onSort?: (sort: EuiTableSortingType['sort']) => void; + onPage?: (page: Pagination) => void; + onRuleClick?: (rule: RuleTableItem) => void; + onRuleEditClick?: (rule: RuleTableItem) => void; + onRuleDeleteClick?: (rule: RuleTableItem) => void; + onManageLicenseClick?: (rule: RuleTableItem) => void; + onTagClick?: (rule: RuleTableItem) => void; + onTagClose?: (rule: RuleTableItem) => void; + onSelectionChange?: (updatedSelectedItemsList: RuleTableItem[]) => void; + onPercentileOptionsChange?: (options: EuiSelectableOption[]) => void; + onRuleChanged: () => void; + onEnableRule: (rule: RuleTableItem) => Promise; + onDisableRule: (rule: RuleTableItem) => Promise; + onSnoozeRule: (rule: RuleTableItem, snoozeEndTime: string | -1) => Promise; + onUnsnoozeRule: (rule: RuleTableItem) => Promise; + renderCollapsedItemActions?: (rule: RuleTableItem) => React.ReactNode; + renderRuleError?: (rule: RuleTableItem) => React.ReactNode; +} + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { + const { rules, ruleTypeIndex, canExecuteActions, config } = opts; + const minimumDuration = config.minimumScheduleInterval + ? parseDuration(config.minimumScheduleInterval.value) + : 0; + return rules.map((rule, index: number) => { + return { + ...rule, + index, + actionsCount: rule.actions.length, + ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, + isEditable: + hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && + (canExecuteActions || (!canExecuteActions && !rule.actions.length)), + enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, + showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, + }; + }); +} + +export const RulesListTable = (props: RulesListTableProps) => { + const { + rulesState, + ruleTypesState, + ruleTypeRegistry, + isLoading = false, + canExecuteActions = false, + sort, + page, + percentileOptions, + itemIdToExpandedRowMap = EMPTY_OBJECT, + config = EMPTY_OBJECT as TriggersActionsUiConfig, + onSort = EMPTY_HANDLER, + onPage = EMPTY_HANDLER, + onRuleClick = EMPTY_HANDLER, + onRuleEditClick = EMPTY_HANDLER, + onRuleDeleteClick = EMPTY_HANDLER, + onManageLicenseClick = EMPTY_HANDLER, + onSelectionChange = EMPTY_HANDLER, + onPercentileOptionsChange = EMPTY_HANDLER, + onRuleChanged = EMPTY_HANDLER, + onEnableRule = EMPTY_HANDLER, + onDisableRule = EMPTY_HANDLER, + onSnoozeRule = EMPTY_HANDLER, + onUnsnoozeRule = EMPTY_HANDLER, + renderCollapsedItemActions = EMPTY_RENDER, + renderRuleError = EMPTY_RENDER, + } = props; + + const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + const [currentlyOpenNotify, setCurrentlyOpenNotify] = useState(); + + const selectedPercentile = useMemo(() => { + const selectedOption = percentileOptions.find((option) => option.checked === 'on'); + if (selectedOption) { + return Percentiles[selectedOption.key as Percentiles]; + } + }, [percentileOptions]); + + const renderPercentileColumnName = () => { + return ( + + + + {selectedPercentile}  + + + + + + ); + }; + + const renderPercentileCellValue = (value: number) => { + return ( + + + + ); + }; + + const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, rule: RuleTableItem) => { + return ( + await onDisableRule(rule)} + enableRule={async () => await onEnableRule(rule)} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + rule={rule} + onRuleChanged={onRuleChanged} + isEditable={rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)} + /> + ); + }; + + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + + const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + const healthColor = getHealthColor(executionStatus.status); + const tooltipMessage = + executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; + const isLicenseError = + executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + const statusMessage = isLicenseError + ? ALERT_STATUS_LICENSE_ERROR + : rulesStatusesTranslationsMapping[executionStatus.status]; + + const health = ( + + {statusMessage} + + ); + + const healthWithTooltip = tooltipMessage ? ( + + {health} + + ) : ( + health + ); + + return ( + + {healthWithTooltip} + {isLicenseError && ( + + onManageLicenseClick(rule)} + > + + + + )} + + ); + }; + + const getRulesTableColumns = (): Array< + | EuiTableFieldDataColumnType + | EuiTableComputedColumnType + | EuiTableActionsColumnType + > => { + return [ + { + field: 'name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', + { defaultMessage: 'Name' } + ), + sortable: true, + truncateText: true, + width: '30%', + 'data-test-subj': 'rulesTableCell-name', + render: (name: string, rule: RuleTableItem) => { + const ruleType = ruleTypesState.data.get(rule.ruleTypeId); + const checkEnabledResult = checkRuleTypeEnabled(ruleType); + const link = ( + <> + + + + + onRuleClick(rule)}> + {name} + + + + {!checkEnabledResult.isEnabled && ( + + )} + + + + + + {rule.ruleType} + + + + + ); + return <>{link}; + }, + }, + { + field: 'tags', + name: '', + sortable: false, + width: '50px', + 'data-test-subj': 'rulesTableCell-tagsPopover', + render: (ruleTags: string[], rule: RuleTableItem) => { + return ruleTags.length > 0 ? ( + setTagPopoverOpenIndex(rule.index)} + onClose={() => setTagPopoverOpenIndex(-1)} + /> + ) : null; + }, + }, + { + field: 'executionStatus.lastExecutionDate', + name: ( + + + Last run{' '} + + + + ), + sortable: true, + width: '15%', + 'data-test-subj': 'rulesTableCell-lastExecutionDate', + render: (date: Date) => { + if (date) { + return ( + <> + + + {moment(date).format('MMM D, YYYY HH:mm:ssa')} + + + + {moment(date).fromNow()} + + + + + ); + } + }, + }, + { + name: 'Notify', + width: '16%', + 'data-test-subj': 'rulesTableCell-rulesListNotify', + render: (rule: RuleTableItem) => { + return ( + setCurrentlyOpenNotify(rule.id)} + onClose={() => setCurrentlyOpenNotify('')} + onRuleChanged={onRuleChanged} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + /> + ); + }, + }, + { + field: 'schedule.interval', + width: '6%', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', + { defaultMessage: 'Interval' } + ), + sortable: false, + truncateText: false, + 'data-test-subj': 'rulesTableCell-interval', + render: (interval: string, rule: RuleTableItem) => { + const durationString = formatDuration(interval); + return ( + <> + + {durationString} + + {rule.showIntervalWarning && ( + + onRuleEditClick(rule)} + iconType="flag" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', + { defaultMessage: 'Below configured minimum interval' } + )} + /> + + )} + + + + ); + }, + }, + { + field: 'executionStatus.lastDuration', + width: '12%', + name: ( + + + Duration{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-duration', + render: (value: number, rule: RuleTableItem) => { + const showDurationWarning = shouldShowDurationWarning( + ruleTypesState.data.get(rule.ruleTypeId), + value + ); + + return ( + <> + {} + {showDurationWarning && ( + + )} + + ); + }, + }, + { + mobileOptions: { header: false }, + field: percentileFields[selectedPercentile!], + width: '16%', + name: renderPercentileColumnName(), + 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', + sortable: true, + truncateText: false, + render: renderPercentileCellValue, + }, + { + field: 'monitoring.execution.calculated_metrics.success_ratio', + width: '12%', + name: ( + + + Success ratio{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-successRatio', + render: (value: number) => { + return ( + + {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} + + ); + }, + }, + { + field: 'executionStatus.status', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', + { defaultMessage: 'Last response' } + ), + sortable: true, + truncateText: false, + width: '120px', + 'data-test-subj': 'rulesTableCell-lastResponse', + render: (_executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + return renderRuleExecutionStatus(rule.executionStatus, rule); + }, + }, + { + field: 'enabled', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', + { defaultMessage: 'State' } + ), + sortable: true, + truncateText: false, + width: '10%', + 'data-test-subj': 'rulesTableCell-status', + render: (_enabled: boolean | undefined, rule: RuleTableItem) => { + return renderRuleStatusDropdown(rule.enabled, rule); + }, + }, + { + name: '', + width: '90px', + render(rule: RuleTableItem) { + return ( + + + + {rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId) ? ( + + onRuleEditClick(rule)} + iconType={'pencil'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', + { defaultMessage: 'Edit' } + )} + /> + + ) : null} + {rule.isEditable ? ( + + onRuleDeleteClick(rule)} + iconType={'trash'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', + { defaultMessage: 'Delete' } + )} + /> + + ) : null} + + + {renderCollapsedItemActions(rule)} + + ); + }, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + name: ( + + Expand rows + + ), + render: renderRuleError, + }, + ]; + }; + + return ( + ({ + 'data-test-subj': 'rule-row', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableRowDisabled' + : '', + })} + cellProps={(rule: RuleTableItem) => ({ + 'data-test-subj': 'cell', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableCellDisabled' + : '', + })} + data-test-subj="rulesList" + pagination={{ + pageIndex: page.index, + pageSize: page.size, + /* Don't display rule count until we have the rule types initialized */ + totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, + }} + selection={{ + selectable: (rule: RuleTableItem) => rule.isEditable, + onSelectionChange, + }} + onChange={({ + page: changedPage, + sort: changedSort, + }: { + page?: Pagination; + sort?: EuiTableSortingType['sort']; + }) => { + if (changedPage) { + onPage(changedPage); + } + if (changedSort) { + onSort(changedSort); + } + }} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isExpandable={true} + /> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx index 6ce697f65f898..f8cb70745911c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx @@ -7,13 +7,7 @@ import React, { Fragment, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiTitle, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiTitle } from '@elastic/eui'; interface TypeFilterProps { options: Array<{ @@ -41,53 +35,51 @@ export const TypeFilter: React.FunctionComponent = ({ }, [selectedValues]); return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleTypeFilterButton" - > - - - } - > -
- {options.map((groupItem, groupIndex) => ( - - -

{groupItem.groupName}

-
- {groupItem.subOptions.map((item, index) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.value); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.value)); - } else { - setSelectedValues(selectedValues.concat(item.value)); - } - }} - checked={selectedValues.includes(item.value) ? 'on' : undefined} - data-test-subj={`ruleType${item.value}FilterOption`} - > - {item.name} - - ))} -
- ))} -
-
-
+ setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="ruleTypeFilterButton" + > + + + } + > +
+ {options.map((groupItem, groupIndex) => ( + + +

{groupItem.groupName}

+
+ {groupItem.subOptions.map((item, index) => ( + { + const isPreviouslyChecked = selectedValues.includes(item.value); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.value)); + } else { + setSelectedValues(selectedValues.concat(item.value)); + } + }} + checked={selectedValues.includes(item.value) ? 'on' : undefined} + data-test-subj={`ruleType${item.value}FilterOption`} + > + {item.name} + + ))} +
+ ))} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx new file mode 100644 index 0000000000000..b315668c4fab9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx @@ -0,0 +1,13 @@ +/* + * 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 { RulesList } from '../application/sections'; + +export const getRulesListLazy = () => { + return ; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 75ca6d8fd2987..605d83a8eb32e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -30,6 +30,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { getAlertsTableStateLazy } from './common/get_alerts_table_state'; import { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state'; @@ -85,6 +86,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getRuleEventLogList: (props) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index f9df34a5e4abb..f2237ff22f4ae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -35,6 +35,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; import { ExperimentalFeatures, @@ -91,6 +92,7 @@ export interface TriggersAndActionsUIPublicPluginStart { getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; getRuleTagBadge: (props: RuleTagBadgeProps) => ReactElement; getRuleEventLogList: (props: RuleEventLogListProps) => ReactElement; + getRulesList: () => ReactElement; } interface PluginsSetup { @@ -279,6 +281,9 @@ export class Plugin getRuleEventLogList: (props: RuleEventLogListProps) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } 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 c5ed118c105bb..832cf6c7a9078 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 @@ -20,5 +20,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./rule_status_filter')); loadTestFile(require.resolve('./rule_tag_badge')); loadTestFile(require.resolve('./rule_event_log_list')); + loadTestFile(require.resolve('./rules_list')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts index 77d57e2819db5..15ea8fc302622 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); - const find = getService('find'); const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const esArchiver = getService('esArchiver'); @@ -31,24 +30,5 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const exists = await testSubjects.exists('ruleTagFilter'); expect(exists).to.be(true); }); - - it('should allow tag filters to be selected', async () => { - let badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('0'); - - await testSubjects.click('ruleTagFilter'); - await testSubjects.click('ruleTagFilterOption-tag1'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('1'); - - await testSubjects.click('ruleTagFilterOption-tag2'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('2'); - - await testSubjects.click('ruleTagFilterOption-tag1'); - expect(await badge.getVisibleText()).to.be('1'); - }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts new file mode 100644 index 0000000000000..30baba0caaa08 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts @@ -0,0 +1,34 @@ +/* + * 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('Rules list', () => { + 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('shoud load from shareable lazy loader', async () => { + await testSubjects.find('rulesList'); + const exists = await testSubjects.exists('rulesList'); + expect(exists).to.be(true); + }); + }); +};