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..c49d055baa042 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -14,10 +14,11 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, internalAlertsTable: false, - internalShareableComponentsSandbox: false, - ruleTagFilter: false, - ruleStatusFilter: false, + internalShareableComponentsSandbox: true, + ruleTagFilter: true, + ruleStatusFilter: true, rulesDetailLogs: true, + rulesListNotify: false, }); type ExperimentalConfigKeys = Array; 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..ae5ad8eb225b1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts @@ -0,0 +1,81 @@ +/* + * 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 } 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, + } as Record), + {} + ) + ); + + 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 { + loadRuleAggregations: internalLoadRuleAggregations, + rulesStatusesTotal, + setRulesStatusesTotal, + }; +} 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..7c4916c7d2c39 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts @@ -0,0 +1,121 @@ +/* + * 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 } from 'react'; +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 & { + hasAnyAuthorizedRuleType: boolean; + onPage: (pagination: Pagination) => void; + onError: (message: string) => void; +}; + +export function useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + hasAnyAuthorizedRuleType, + onPage, + onError, +}: UseLoadRulesProps) { + const { http } = useKibana().services; + + const [rulesState, setRulesState] = useState({ + isLoading: false, + data: [], + totalItemCount: 0, + }); + + const [noData, setNoData] = useState(true); + const [initialLoad, setInitialLoad] = useState(true); + + const internalLoadRules = useCallback(async () => { + if (!hasAnyAuthorizedRuleType) { + return; + } + setRulesState((prevRuleState) => ({ ...prevRuleState, isLoading: true })); + try { + const rulesResponse = await loadRules({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + }); + setRulesState({ + 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) + ); + + setNoData(rulesResponse.data.length === 0 && !isFilterApplied); + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', { + defaultMessage: 'Unable to load rules', + }) + ); + setRulesState((prevRuleState) => ({ ...prevRuleState, isLoading: false })); + } + setInitialLoad(false); + }, [ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + hasAnyAuthorizedRuleType, + setRulesState, + setNoData, + setInitialLoad, + onPage, + onError, + ]); + + return { + rulesState, + setRulesState, + loadRules: internalLoadRules, + noData, + initialLoad, + }; +} 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..02f811a9d48a3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts @@ -0,0 +1,42 @@ +/* + * 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 } 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 { + loadTags: internalLoadTags, + tags, + 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 c9834dd140ea4..68b028ad226bf 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,7 +7,7 @@ export { alertingFrameworkHealth } from './health'; export { mapFiltersToKql } from './map_filters_to_kql'; -export { loadRuleAggregations, loadRuleTags } from './aggregate'; +export { loadRuleAggregations, loadRuleTags, LoadRuleAggregationsProps } from './aggregate'; export { createRule } from './create'; export { deleteRules } from './delete'; export { disableRule, disableRules } from './disable'; @@ -17,7 +17,7 @@ export { loadRuleSummary } from './rule_summary'; export { muteAlertInstance } from './mute_alert'; export { muteRule, muteRules } from './mute'; export { loadRuleTypes } from './rule_types'; -export { loadRules } from './rules'; +export { loadRules, LoadRulesProps } from './rules'; export { loadRuleState } from './state'; export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations'; export { loadExecutionLogAggregations } 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..0c97b5854ea83 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 @@ -7,7 +7,7 @@ import React, { useEffect, useState } 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 { @@ -30,46 +30,44 @@ export const ActionTypeFilter: React.FunctionComponent = }, [selectedValues]); 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) => ( + { + 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`} > - - - } - > -
- {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..7cfd833a2b191 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 @@ -7,13 +7,7 @@ import React, { 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'; @@ -41,51 +35,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]} - - ); - })} -
-
-
+ 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]} + + ); + })} +
+
); }; 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 40658ae282e16..2e6d8f5062ac4 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; @@ -58,9 +58,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; @@ -312,12 +312,14 @@ const RuleStatusMenu: React.FunctionComponent = ({ width: 360, title: SNOOZE, content: ( - + + + ), }, ]; @@ -332,7 +334,7 @@ interface SnoozePanelProps { previousSnoozeInterval: string | null; } -const SnoozePanel: React.FunctionComponent = ({ +export const SnoozePanel: React.FunctionComponent = ({ interval = '3d', applySnooze, showCancel, @@ -385,7 +387,7 @@ const SnoozePanel: React.FunctionComponent = ({ ); return ( - + <> @@ -470,11 +472,11 @@ const SnoozePanel: React.FunctionComponent = ({ )} - + ); }; -const isRuleSnoozed = (rule: DropdownRuleRecord) => { +export const isRuleSnoozed = (rule: DropdownRuleRecord) => { const { snoozeEndTime, muteAll } = rule; if (muteAll) return true; if (!snoozeEndTime) { @@ -483,7 +485,7 @@ const isRuleSnoozed = (rule: DropdownRuleRecord) => { return moment(Date.now()).isBefore(snoozeEndTime); }; -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.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 6aa8aa8c69213..b230fce41d9e8 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.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index a5b9661835131..8b7a4f3561959 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, @@ -86,23 +70,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'; const ENTER_KEY = 13; @@ -111,17 +93,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', @@ -147,8 +118,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({}); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -160,16 +129,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'); @@ -183,13 +151,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', @@ -198,27 +159,53 @@ 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, + hasAnyAuthorizedRuleType, + onPage: setPage, + onError, + }); + + const { tags, loadTags } = useLoadTags({ + onError, + }); + + const { loadRuleAggregations, rulesStatusesTotal } = useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + }); + const onRuleEdit = (ruleItem: RuleTableItem) => { setEditFlyoutVisibility(true); setCurrentRuleToEdit(ruleItem); @@ -227,19 +214,18 @@ export const RulesList: React.FunctionComponent = () => { const isRuleTypeEditableInContext = (ruleTypeId: string) => ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + const loadData = useCallback(async () => { + await loadRules(); + await loadRuleAggregations(); + if (isRuleStatusFilterEnabled) { + await loadTags(); + } + setLastUpdate(moment().format()); + }, [loadRules, loadTags, loadRuleAggregations, setLastUpdate, isRuleStatusFilterEnabled]); + useEffect(() => { - loadRulesData(); - }, [ - ruleTypesState, - page, - searchText, - percentileOptions, - JSON.stringify(typesFilter), - JSON.stringify(actionTypesFilter), - JSON.stringify(ruleExecutionStatusesFilter), - JSON.stringify(ruleStatusesFilter), - JSON.stringify(tagsFilter), - ]); + loadData(); + }, [loadData, percentileOptions]); useEffect(() => { (async () => { @@ -286,218 +272,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; @@ -560,382 +334,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)} - /> - - - ); - }, - }, - { - 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 @@ -975,13 +373,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 = [ @@ -995,8 +409,6 @@ export const RulesList: React.FunctionComponent = () => { }) )} />, - ...getRuleTagFilter(), - ...getRuleStatusFilter(), { selectedStatuses={ruleExecutionStatusesFilter} onChange={(ids: string[]) => setRuleExecutionStatusesFilter(ids)} />, - - - , + ...getRuleTagFilter(), ]; const authorizedToModifySelectedRules = selectedIds.length @@ -1070,7 +471,7 @@ export const RulesList: React.FunctionComponent = () => { })} onPerformingAction={() => setIsPerformingAction(true)} onActionPerformed={() => { - loadRulesData(); + loadData(); setIsPerformingAction(false); }} setRulesToDelete={setRulesToDelete} @@ -1115,20 +516,19 @@ export const RulesList: React.FunctionComponent = () => { )} /> + {renderRuleStatusFilter()} - + {toolsRight.map((tool, index: number) => ( - - {tool} - + {tool} ))} - + - + { /> + {rulesStatusesTotal.error > 0 && ( @@ -1231,64 +632,64 @@ 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)} + /> + )} + 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 beend deleted - await loadRulesData(); + await loadData(); setRulesToDelete([]); }} onCancel={() => { @@ -1360,7 +761,7 @@ export const RulesList: React.FunctionComponent = () => { actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} ruleTypeIndex={ruleTypesState.data} - onSave={loadRulesData} + onSave={loadData} /> )} {editFlyoutVisible && currentRuleToEdit && ( @@ -1374,7 +775,7 @@ export const RulesList: React.FunctionComponent = () => { ruleType={ ruleTypesState.data.get(currentRuleToEdit.ruleTypeId) as RuleType } - onSave={loadRulesData} + onSave={loadData} /> )} @@ -1409,30 +810,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.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..d05e35625ee30 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState, useRef } from 'react'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiAutoRefreshButton } from '@elastic/eui'; + +interface RulesListAutoRefreshProps { + lastUpdate: string; + onRefresh: () => void; +} + +export const RulesListAutoRefresh = (props: RulesListAutoRefreshProps) => { + const { lastUpdate, onRefresh } = props; + + const [isPaused, setIsPaused] = useState(false); + const [refreshInterval, setRefreshInterval] = useState(5 * 60 * 1000); + const cachedOnRefresh = useRef<() => void>(() => {}); + const timeout = useRef(undefined); + + useEffect(() => { + cachedOnRefresh.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + if (isPaused) { + return; + } + + const poll = () => { + timeout.current = window.setTimeout(() => { + cachedOnRefresh.current(); + poll(); + }, refreshInterval); + }; + + poll(); + + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, [isPaused, refreshInterval]); + + return ( + + + + {lastUpdate && `Updated ${moment(lastUpdate).fromNow()}`} + + + + { + setIsPaused(newIsPaused); + setRefreshInterval(newRefreshInterval); + }} + /> + + + ); +}; 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..d0d959e5c3ab7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx @@ -0,0 +1,138 @@ +/* + * 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 } from 'react'; +import moment from 'moment'; +import { EuiButton, EuiButtonIcon, EuiPopover, EuiText } from '@elastic/eui'; +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; +} + +export const RulesListNotifyBadge: React.FunctionComponent = (props) => { + const { + rule, + isOpen, + previousSnoozeInterval: propsPreviousSnoozeInterval, + onClick, + onClose, + onRuleChanged, + snoozeRule, + unsnoozeRule, + } = props; + + const { snoozeEndTime, muteAll } = rule; + + const [previousSnoozeInterval, setPreviousSnoozeInterval] = usePreviousSnoozeInterval( + propsPreviousSnoozeInterval + ); + + const isSnoozed = useMemo(() => { + return isRuleSnoozed(rule); + }, [rule]); + + const isScheduled = useMemo(() => { + // TODO: Implement scheduled check + return false; + }, []); + + const formattedSnooze = useMemo(() => { + if (muteAll) { + return 'Indefinite'; + } + if (!snoozeEndTime) { + return ''; + } + return moment(snoozeEndTime).format('MMM D'); + }, [snoozeEndTime, muteAll]); + + const button = useMemo(() => { + if (isSnoozed || isScheduled) { + return ( + + {formattedSnooze} + + ); + } + return ( + + ); + }, [isSnoozed, isScheduled, formattedSnooze, isOpen, onClick]); + + 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) => { + 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(); + } + }, + [onRuleChanged, onClose, snoozeRuleAndStoreInterval, unsnoozeRule] + ); + + 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..a99a7fcd0f0db --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx @@ -0,0 +1,732 @@ +/* + * 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 { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +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', +}; + +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 = {}, + onSort = () => {}, + onPage = () => {}, + onRuleClick = () => {}, + onRuleEditClick = () => {}, + onRuleDeleteClick = () => {}, + onManageLicenseClick = () => {}, + onSelectionChange = () => {}, + onPercentileOptionsChange = () => {}, + onRuleChanged = () => {}, + onEnableRule = () => {}, + onDisableRule = () => {}, + onSnoozeRule = () => {}, + onUnsnoozeRule = () => {}, + renderCollapsedItemActions = () => null, + renderRuleError = () => null, + config = {}, + } = props; + + const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + const [currentlyOpenNotify, setCurrentlyOpenNotify] = useState(); + + const isRulesListNotifyEnabled = getIsExperimentalFeatureEnabled('rulesListNotify'); + + 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 getRulesListNotifyColumn = () => { + if (isRulesListNotifyEnabled) { + return [ + { + 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)} + /> + ); + }, + }, + ]; + } + + return []; + }; + + 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()} + + + + + ); + } + }, + }, + ...getRulesListNotifyColumn(), + { + 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_experimental_features.test.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx index a59a25c62a8e9..66b33ceb60e3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx @@ -21,6 +21,7 @@ describe('getIsExperimentalFeatureEnabled', () => { ruleTagFilter: true, ruleStatusFilter: true, internalShareableComponentsSandbox: true, + rulesListNotify: true, }, }); @@ -48,6 +49,10 @@ describe('getIsExperimentalFeatureEnabled', () => { expect(result).toEqual(true); + result = getIsExperimentalFeatureEnabled('rulesListNotify'); + + expect(result).toEqual(true); + expect(() => getIsExperimentalFeatureEnabled('doesNotExist' as any)).toThrowError( `Invalid enable value doesNotExist. Allowed values are: ${allowedExperimentalValueKeys.join( ', ' diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_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 9092c097de9fc..d918fdf6d0a87 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'; function createStartMock(): TriggersAndActionsUIPublicPluginStart { const actionTypeRegistry = new TypeRegistry(); @@ -80,6 +81,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 522cef6865a74..aad57198b261c 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, @@ -88,6 +89,7 @@ export interface TriggersAndActionsUIPublicPluginStart { getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; getRuleTagBadge: (props: RuleTagBadgeProps) => ReactElement; getRuleEventLogList: (props: RuleEventLogListProps) => ReactElement; + getRulesList: () => ReactElement; } interface PluginsSetup { @@ -273,6 +275,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/rules_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts new file mode 100644 index 0000000000000..0bab1a864a2b7 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts @@ -0,0 +1,35 @@ +/* + * 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 find = getService('find'); + 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); + }); + }); +};