diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts new file mode 100644 index 00000000000000..86b445dd9a1e62 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -0,0 +1,62 @@ +/* + * 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 { useEffect, useState, useCallback } from 'react'; +import { loadRules, Rule } from '../../../triggers_actions_ui/public'; +import { RULES_LOAD_ERROR } from '../pages/rules/translations'; +import { FetchRulesProps } from '../pages/rules/types'; +import { OBSERVABILITY_RULE_TYPES } from '../pages/rules/config'; +import { useKibana } from '../utils/kibana_react'; + +interface RuleState { + isLoading: boolean; + data: Rule[]; + error: string | null; + totalItemCount: number; +} + +export function useFetchRules({ ruleLastResponseFilter, page, sort }: FetchRulesProps) { + const { http } = useKibana().services; + + const [rulesState, setRulesState] = useState({ + isLoading: false, + data: [], + error: null, + totalItemCount: 0, + }); + + const fetchRules = useCallback(async () => { + setRulesState((oldState) => ({ ...oldState, isLoading: true })); + + try { + const response = await loadRules({ + http, + page, + typesFilter: OBSERVABILITY_RULE_TYPES, + ruleStatusesFilter: ruleLastResponseFilter, + sort, + }); + setRulesState((oldState) => ({ + ...oldState, + isLoading: false, + data: response.data, + totalItemCount: response.total, + })); + } catch (_e) { + setRulesState((oldState) => ({ ...oldState, isLoading: false, error: RULES_LOAD_ERROR })); + } + }, [http, page, ruleLastResponseFilter, sort]); + useEffect(() => { + fetchRules(); + }, [fetchRules]); + + return { + rulesState, + reload: fetchRules, + setRulesState, + }; +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/delete_modal_confirmation.tsx b/x-pack/plugins/observability/public/pages/rules/components/delete_modal_confirmation.tsx new file mode 100644 index 00000000000000..b09d31ba59c787 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/delete_modal_confirmation.tsx @@ -0,0 +1,93 @@ +/* + * 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 { EuiConfirmModal } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { HttpSetup } from 'kibana/public'; +import { useKibana } from '../../../utils/kibana_react'; +import { + confirmModalText, + confirmButtonText, + cancelButtonText, + deleteSuccessText, + deleteErrorText, +} from '../translations'; + +export function DeleteModalConfirmation({ + idsToDelete, + apiDeleteCall, + onDeleted, + onCancel, + onErrors, + singleTitle, + multipleTitle, + setIsLoadingState, +}: { + idsToDelete: string[]; + apiDeleteCall: ({ + ids, + http, + }: { + ids: string[]; + http: HttpSetup; + }) => Promise<{ successes: string[]; errors: string[] }>; + onDeleted: (deleted: string[]) => void; + onCancel: () => void; + onErrors: () => void; + singleTitle: string; + multipleTitle: string; + setIsLoadingState: (isLoading: boolean) => void; +}) { + const [deleteModalFlyoutVisible, setDeleteModalVisibility] = useState(false); + + useEffect(() => { + setDeleteModalVisibility(idsToDelete.length > 0); + }, [idsToDelete]); + + const { + http, + notifications: { toasts }, + } = useKibana().services; + const numIdsToDelete = idsToDelete.length; + if (!deleteModalFlyoutVisible) { + return null; + } + + return ( + { + setDeleteModalVisibility(false); + onCancel(); + }} + onConfirm={async () => { + setDeleteModalVisibility(false); + setIsLoadingState(true); + const { successes, errors } = await apiDeleteCall({ ids: idsToDelete, http }); + setIsLoadingState(false); + + const numSuccesses = successes.length; + const numErrors = errors.length; + if (numSuccesses > 0) { + toasts.addSuccess(deleteSuccessText(numSuccesses, singleTitle, multipleTitle)); + } + + if (numErrors > 0) { + toasts.addDanger(deleteErrorText(numErrors, singleTitle, multipleTitle)); + await onErrors(); + } + await onDeleted(successes); + }} + cancelButtonText={cancelButtonText} + confirmButtonText={confirmButtonText(numIdsToDelete, singleTitle, multipleTitle)} + > + {confirmModalText(numIdsToDelete, singleTitle, multipleTitle)} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/edit_rule_flyout.tsx b/x-pack/plugins/observability/public/pages/rules/components/edit_rule_flyout.tsx new file mode 100644 index 00000000000000..89dce3e1975fd6 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/edit_rule_flyout.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useMemo, useEffect } from 'react'; +import { useKibana } from '../../../utils/kibana_react'; +import { EditFlyoutProps } from '../types'; + +export function EditRuleFlyout({ currentRule, onSave }: EditFlyoutProps) { + const { triggersActionsUi } = useKibana().services; + const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + + useEffect(() => { + setEditFlyoutVisibility(true); + }, [currentRule]); + const EditAlertFlyout = useMemo( + () => + triggersActionsUi.getEditAlertFlyout({ + initialRule: currentRule, + onClose: () => { + setEditFlyoutVisibility(false); + }, + onSave, + }), + [currentRule, setEditFlyoutVisibility, triggersActionsUi, onSave] + ); + return <>{editFlyoutVisible && EditAlertFlyout}; +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/execution_status.tsx b/x-pack/plugins/observability/public/pages/rules/components/execution_status.tsx new file mode 100644 index 00000000000000..4cdcabe5743964 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/execution_status.tsx @@ -0,0 +1,43 @@ +/* + * 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 { EuiHealth, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { AlertExecutionStatusErrorReasons } from '../../../../../alerting/common'; +import { getHealthColor, rulesStatusesTranslationsMapping } from '../config'; +import { RULE_STATUS_LICENSE_ERROR } from '../translations'; +import { ExecutionStatusProps } from '../types'; + +export function ExecutionStatus({ executionStatus }: ExecutionStatusProps) { + const healthColor = getHealthColor(executionStatus.status); + const tooltipMessage = + executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; + const isLicenseError = executionStatus.error?.reason === AlertExecutionStatusErrorReasons.License; + const statusMessage = isLicenseError + ? RULE_STATUS_LICENSE_ERROR + : rulesStatusesTranslationsMapping[executionStatus.status]; + + const health = ( + + {statusMessage} + + ); + + const healthWithTooltip = tooltipMessage ? ( + + {health} + + ) : ( + health + ); + + return ( + + {healthWithTooltip} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/last_response_filter.tsx b/x-pack/plugins/observability/public/pages/rules/components/last_response_filter.tsx new file mode 100644 index 00000000000000..5a9be48252909e --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/last_response_filter.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. + */ + +/* eslint-disable react/function-component-definition */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFilterGroup, + EuiPopover, + EuiFilterButton, + EuiFilterSelectItem, + EuiHealth, +} from '@elastic/eui'; +import { AlertExecutionStatuses, AlertExecutionStatusValues } from '../../../../../alerting/common'; +import { getHealthColor, rulesStatusesTranslationsMapping } from '../config'; +import { StatusFilterProps } from '../types'; + +export const LastResponseFilter: React.FunctionComponent = ({ + selectedStatuses, + onChange, +}: StatusFilterProps) => { + const [selectedValues, setSelectedValues] = useState(selectedStatuses); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + }, [selectedValues, onChange]); + + useEffect(() => { + setSelectedValues(selectedStatuses); + }, [selectedStatuses]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="ruleStatusFilterButton" + > + + + } + > +
+ {[...AlertExecutionStatusValues].sort().map((item: AlertExecutionStatuses) => { + 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={`ruleStatus${item}FilerOption`} + > + {rulesStatusesTranslationsMapping[item]} + + ); + })} +
+
+
+ ); +}; diff --git a/x-pack/plugins/observability/public/pages/rules/components/last_run.tsx b/x-pack/plugins/observability/public/pages/rules/components/last_run.tsx new file mode 100644 index 00000000000000..08bb6fb229b94d --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/last_run.tsx @@ -0,0 +1,24 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import moment from 'moment'; +import { LastRunProps } from '../types'; + +export function LastRun({ date }: LastRunProps) { + return ( + <> + + + + {moment(date).fromNow()} + + + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx new file mode 100644 index 00000000000000..2b1f8312569104 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -0,0 +1,50 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiBadge } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { RuleNameProps } from '../types'; +import { useKibana } from '../../../utils/kibana_react'; + +export function Name({ name, rule }: RuleNameProps) { + const { http } = useKibana().services; + const detailsLink = http.basePath.prepend( + `/app/management/insightsAndAlerting/triggersActions/rule/${rule.id}` + ); + const link = ( + + + + + + {name} + + + + + + + {rule.ruleType} + + + + ); + return ( + <> + {link} + {rule.enabled && rule.muteAll && ( + + + + )} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/rules_table.tsx b/x-pack/plugins/observability/public/pages/rules/components/rules_table.tsx new file mode 100644 index 00000000000000..db1febf8d3eddc --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/rules_table.tsx @@ -0,0 +1,67 @@ +/* + * 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 } from 'react'; +import { EuiBasicTable, EuiSpacer, EuiTableSortingType } from '@elastic/eui'; +import { RulesTableProps } from '../types'; +import { RuleTableItem } from '../../../../../triggers_actions_ui/public'; + +export interface Pagination { + index: number; + size: number; +} + +export function RulesTable({ + columns, + rules, + page, + totalItemCount, + onPageChange, + sort, + onSortChange, + isLoading, +}: RulesTableProps) { + const onChange = useCallback( + ({ + page: changedPage, + sort: changedSort, + }: { + page?: Pagination; + sort?: EuiTableSortingType['sort']; + }) => { + if (changedPage) { + onPageChange(changedPage); + } + if (changedSort) { + onSortChange(changedSort); + } + }, + [onPageChange, onSortChange] + ); + return ( +
+ + <> + + + + +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status.tsx b/x-pack/plugins/observability/public/pages/rules/components/status.tsx new file mode 100644 index 00000000000000..abc2dc8bfa492f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/status.tsx @@ -0,0 +1,25 @@ +/* + * 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 { EuiBadge } from '@elastic/eui'; +import { StatusProps } from '../types'; +import { statusMap } from '../config'; + +export function Status({ type, onClick }: StatusProps) { + return ( + + {statusMap[type].label} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx new file mode 100644 index 00000000000000..49761d7c431542 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { + EuiPopover, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { Status } from './status'; +import { RuleStatus, StatusContextProps } from '../types'; +import { statusMap } from '../config'; + +export function StatusContext({ + item, + onStatusChanged, + enableRule, + disableRule, + muteRule, +}: StatusContextProps) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + + const currentStatus = item.enabled ? RuleStatus.enabled : RuleStatus.disabled; + const popOverButton = useMemo( + () => , + [currentStatus, togglePopover] + ); + + const onContextMenuItemClick = useCallback( + async (status: RuleStatus) => { + togglePopover(); + if (currentStatus !== status) { + setIsUpdating(true); + + if (status === RuleStatus.enabled) { + await enableRule({ ...item, enabled: true }); + } else if (status === RuleStatus.disabled) { + await disableRule({ ...item, enabled: false }); + } + setIsUpdating(false); + onStatusChanged(status); + } + }, + [item, togglePopover, enableRule, disableRule, currentStatus, onStatusChanged] + ); + const panelItems = useMemo( + () => + Object.values(RuleStatus).map((status: RuleStatus) => ( + onContextMenuItemClick(status)} + > + {statusMap[status].label} + + )), + [currentStatus, onContextMenuItemClick] + ); + + return isUpdating ? ( + + ) : ( + setIsPopoverOpen(false)} + anchorPosition="downLeft" + isOpen={isPopoverOpen} + panelPaddingSize="none" + > + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts new file mode 100644 index 00000000000000..0296fdb73b9515 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -0,0 +1,93 @@ +/* + * 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 { Status, RuleStatus } from './types'; +import { + RULE_STATUS_OK, + RULE_STATUS_ACTIVE, + RULE_STATUS_ERROR, + RULE_STATUS_PENDING, + RULE_STATUS_UNKNOWN, +} from './translations'; +import { AlertExecutionStatuses } from '../../../../alerting/common'; +import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public'; + +export const statusMap: Status = { + [RuleStatus.enabled]: { + color: 'primary', + label: 'Enabled', + }, + [RuleStatus.disabled]: { + color: 'default', + label: 'Disabled', + }, +}; + +export const DEFAULT_SEARCH_PAGE_SIZE: number = 25; + +export function getHealthColor(status: AlertExecutionStatuses) { + switch (status) { + case 'active': + return 'success'; + case 'error': + return 'danger'; + case 'ok': + return 'primary'; + case 'pending': + return 'accent'; + default: + return 'subdued'; + } +} + +export const rulesStatusesTranslationsMapping = { + ok: RULE_STATUS_OK, + active: RULE_STATUS_ACTIVE, + error: RULE_STATUS_ERROR, + pending: RULE_STATUS_PENDING, + unknown: RULE_STATUS_UNKNOWN, +}; + +export const OBSERVABILITY_RULE_TYPES = [ + 'xpack.uptime.alerts.monitorStatus', + 'xpack.uptime.alerts.tls', + 'xpack.uptime.alerts.tlsCertificate', + 'xpack.uptime.alerts.durationAnomaly', + 'apm.error_rate', + 'apm.transaction_error_rate', + 'apm.transaction_duration', + 'apm.transaction_duration_anomaly', + 'metrics.alert.inventory.threshold', + 'metrics.alert.threshold', + 'logs.alert.document.count', +]; + +export const OBSERVABILITY_SOLUTIONS = ['logs', 'uptime', 'infrastructure', 'apm']; + +export type InitialRule = Partial & + Pick; + +export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean { + return ruleType?.authorizedConsumers[rule.consumer]?.all ?? false; +} + +export function convertRulesToTableItems( + rules: Rule[], + ruleTypeIndex: RuleTypeIndex, + canExecuteActions: boolean +) { + return rules.map((rule, index: number) => ({ + ...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, + })); +} diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx index d4eba98b8c5cbd..d6f932baeefba7 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -5,199 +5,213 @@ * 2.0. */ -import React, { useState, useEffect, useCallback } from 'react'; -import moment from 'moment'; +import React, { useState, useMemo } from 'react'; import { - EuiBasicTable, + EuiButton, + EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiText, - EuiBadge, - EuiPopover, - EuiContextMenuPanel, - EuiContextMenuItem, EuiHorizontalRule, EuiAutoRefreshButton, + EuiTableSortingType, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; - import { useKibana } from '../../utils/kibana_react'; - -const DEFAULT_SEARCH_PAGE_SIZE: number = 25; - -interface RuleState { - data: []; - totalItemsCount: number; -} - -interface Pagination { - index: number; - size: number; -} +import { useFetchRules } from '../../hooks/use_fetch_rules'; +import { RulesTable } from './components/rules_table'; +import { Name } from './components/name'; +import { LastResponseFilter } from './components/last_response_filter'; +import { StatusContext } from './components/status_context'; +import { ExecutionStatus } from './components/execution_status'; +import { LastRun } from './components/last_run'; +import { EditRuleFlyout } from './components/edit_rule_flyout'; +import { DeleteModalConfirmation } from './components/delete_modal_confirmation'; +import { + deleteRules, + RuleTableItem, + enableRule, + disableRule, + muteRule, + useLoadRuleTypes, +} from '../../../../triggers_actions_ui/public'; +import { AlertExecutionStatus, ALERTS_FEATURE_ID } from '../../../../alerting/common'; +import { Pagination } from './types'; +import { + DEFAULT_SEARCH_PAGE_SIZE, + convertRulesToTableItems, + OBSERVABILITY_SOLUTIONS, +} from './config'; +import { + LAST_RESPONSE_COLUMN_TITLE, + LAST_RUN_COLUMN_TITLE, + RULE_COLUMN_TITLE, + STATUS_COLUMN_TITLE, + ACTIONS_COLUMN_TITLE, + EDIT_ACTION_ARIA_LABEL, + EDIT_ACTION_TOOLTIP, + DELETE_ACTION_TOOLTIP, + DELETE_ACTION_ARIA_LABEL, + RULES_PAGE_TITLE, + RULES_BREADCRUMB_TEXT, + RULES_SINGLE_TITLE, + RULES_PLURAL_TITLE, +} from './translations'; export function RulesPage() { const { ObservabilityPageTemplate } = usePluginContext(); const { http, docLinks, + triggersActionsUi, notifications: { toasts }, } = useKibana().services; - const [rules, setRules] = useState({ data: [], totalItemsCount: 0 }); const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [sort, setSort] = useState['sort']>({ + field: 'name', + direction: 'asc', + }); + const [ruleLastResponseFilter, setRuleLastResponseFilter] = useState([]); + const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); + const [rulesToDelete, setRulesToDelete] = useState([]); + const [createRuleFlyoutVisibility, setCreateRuleFlyoutVisibility] = useState(false); - async function loadObservabilityRules() { - const { loadRules } = await import('../../../../triggers_actions_ui/public'); - try { - const response = await loadRules({ - http, - page: { index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }, - typesFilter: [ - 'xpack.uptime.alerts.monitorStatus', - 'xpack.uptime.alerts.tls', - 'xpack.uptime.alerts.tlsCertificate', - 'xpack.uptime.alerts.durationAnomaly', - 'apm.error_rate', - 'apm.transaction_error_rate', - 'apm.transaction_duration', - 'apm.transaction_duration_anomaly', - 'metrics.alert.inventory.threshold', - 'metrics.alert.threshold', - 'logs.alert.document.count', - ], - }); - setRules({ - data: response.data as any, - totalItemsCount: response.total, - }); - } catch (_e) { - toasts.addDanger({ - title: i18n.translate('xpack.observability.rules.loadError', { - defaultMessage: 'Unable to load rules', - }), - }); - } - } + const onRuleEdit = (ruleItem: RuleTableItem) => { + setCurrentRuleToEdit(ruleItem); + }; - enum RuleStatus { - enabled = 'enabled', - disabled = 'disabled', - } - - const statuses = Object.values(RuleStatus); - const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); - const popOverButton = ( - - Enabled - - ); - - const panelItems = statuses.map((status) => ( - - {status} - - )); - - useEffect(() => { - loadObservabilityRules(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { rulesState, setRulesState, reload } = useFetchRules({ + ruleLastResponseFilter, + page, + sort, + }); + const { data: rules, totalItemCount, error } = rulesState; + const { ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS }); useBreadcrumbs([ { - text: i18n.translate('xpack.observability.breadcrumbs.rulesLinkText', { - defaultMessage: 'Rules', - }), + text: RULES_BREADCRUMB_TEXT, }, ]); - const rulesTableColumns = [ - { - field: 'name', - name: i18n.translate('xpack.observability.rules.rulesTable.columns.nameTitle', { - defaultMessage: 'Rule Name', - }), - }, - { - field: 'executionStatus.lastExecutionDate', - name: i18n.translate('xpack.observability.rules.rulesTable.columns.lastRunTitle', { - defaultMessage: 'Last run', - }), - render: (date: Date) => { - if (date) { + const getRulesTableColumns = () => { + return [ + { + field: 'name', + name: RULE_COLUMN_TITLE, + sortable: true, + truncateText: true, + width: '30%', + 'data-test-subj': 'rulesTableCell-name', + render: (name: string, rule: RuleTableItem) => , + }, + { + field: 'executionStatus.lastExecutionDate', + name: LAST_RUN_COLUMN_TITLE, + sortable: true, + render: (date: Date) => , + }, + { + field: 'executionStatus.status', + name: LAST_RESPONSE_COLUMN_TITLE, + sortable: true, + truncateText: false, + width: '120px', + 'data-test-subj': 'rulesTableCell-status', + render: (_executionStatus: AlertExecutionStatus, item: RuleTableItem) => ( + + ), + }, + { + field: 'enabled', + name: STATUS_COLUMN_TITLE, + sortable: true, + render: (_enabled: boolean, item: RuleTableItem) => { return ( - <> - - - - {moment(date).fromNow()} - - - - + reload()} + enableRule={async () => await enableRule({ http, id: item.id })} + disableRule={async () => await disableRule({ http, id: item.id })} + muteRule={async () => await muteRule({ http, id: item.id })} + /> ); - } + }, }, - }, - { - field: 'executionStatus.status', - name: i18n.translate('xpack.observability.rules.rulesTable.columns.lastResponseTitle', { - defaultMessage: 'Last response', - }), - }, - { - field: 'enabled', - name: i18n.translate('xpack.observability.rules.rulesTable.columns.statusTitle', { - defaultMessage: 'Status', - }), - render: (_enabled: boolean) => { - return ( - - - - ); + { + name: ACTIONS_COLUMN_TITLE, + width: '10%', + render(item: RuleTableItem) { + return ( + + + + + onRuleEdit(item)} + iconType={'pencil'} + aria-label={EDIT_ACTION_ARIA_LABEL} + /> + + + setRulesToDelete([item.id])} + iconType={'trash'} + aria-label={DELETE_ACTION_ARIA_LABEL} + /> + + + + + ); + }, }, - }, - { - field: '*', - name: i18n.translate('xpack.observability.rules.rulesTable.columns.actionsTitle', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: 'Edit', - isPrimary: true, - description: 'Edit this rule', - icon: 'pencil', - type: 'icon', - onClick: () => {}, - 'data-test-subj': 'action-edit', + ]; + }; + + const CreateRuleFlyout = useMemo( + () => + triggersActionsUi.getAddAlertFlyout({ + consumer: ALERTS_FEATURE_ID, + onClose: () => { + setCreateRuleFlyoutVisibility(false); + reload(); }, - ], - }, - ]; + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + return ( {i18n.translate('xpack.observability.rulesTitle', { defaultMessage: 'Rules' })} - ), + pageTitle: <>{RULES_PAGE_TITLE} , rightSideItems: [ + setCreateRuleFlyoutVisibility(true)} + > + + , + { + // a new state that rule is deleted, that's the one + setRulesToDelete([]); + // this should cause the fetcher to reload the rules + reload(); + }} + onErrors={async () => { + // Refresh the rules from the server, some rules may have beend deleted + reload(); + setRulesToDelete([]); + }} + onCancel={() => { + setRulesToDelete([]); + }} + apiDeleteCall={deleteRules} + idsToDelete={rulesToDelete} + singleTitle={RULES_SINGLE_TITLE} + multipleTitle={RULES_PLURAL_TITLE} + setIsLoadingState={(isLoading: boolean) => { + setRulesState({ ...rulesState, isLoading }); + }} + /> + + + setRuleLastResponseFilter(ids)} + /> + + @@ -219,8 +265,8 @@ export function RulesPage() { id="xpack.observability.rules.totalItemsCountDescription" defaultMessage="Showing: {pageSize} of {totalItemCount} Rules" values={{ - totalItemCount: rules.totalItemsCount, - pageSize: rules.data.length, + totalItemCount, + pageSize: rules.length, }} /> @@ -237,26 +283,26 @@ export function RulesPage() { - { - setPage(changedPage); - }} - selection={{ - selectable: () => true, - onSelectionChange: (selectedItems) => {}, + setPage(index)} + sort={sort} + onSortChange={(changedSort) => { + setSort(changedSort); }} /> + {error && + toasts.addDanger({ + title: error, + })} + {currentRuleToEdit && } + {createRuleFlyoutVisibility && CreateRuleFlyout} ); } diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts new file mode 100644 index 00000000000000..36fe05232a1b97 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/translations.ts @@ -0,0 +1,180 @@ +/* + * 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'; + +export const RULE_STATUS_LICENSE_ERROR = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusLicenseError', + { + defaultMessage: 'License Error', + } +); + +export const RULE_STATUS_OK = i18n.translate('xpack.observability.rules.rulesTable.ruleStatusOk', { + defaultMessage: 'Ok', +}); + +export const RULE_STATUS_ACTIVE = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusActive', + { + defaultMessage: 'Active', + } +); + +export const RULE_STATUS_ERROR = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusError', + { + defaultMessage: 'Error', + } +); + +export const RULE_STATUS_PENDING = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusPending', + { + defaultMessage: 'Pending', + } +); + +export const RULE_STATUS_UNKNOWN = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusUnknown', + { + defaultMessage: 'Unknown', + } +); + +export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.lastResponseTitle', + { + defaultMessage: 'Last response', + } +); + +export const LAST_RUN_COLUMN_TITLE = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.lastRunTitle', + { + defaultMessage: 'Last run', + } +); + +export const RULE_COLUMN_TITLE = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.nameTitle', + { + defaultMessage: 'Rule', + } +); + +export const STATUS_COLUMN_TITLE = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.statusTitle', + { + defaultMessage: 'Status', + } +); + +export const ACTIONS_COLUMN_TITLE = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.actionsTitle', + { + defaultMessage: 'Actions', + } +); + +export const EDIT_ACTION_ARIA_LABEL = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.editAriaLabel', + { defaultMessage: 'Edit' } +); + +export const EDIT_ACTION_TOOLTIP = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.editButtonTooltip', + { + defaultMessage: 'Edit', + } +); + +export const DELETE_ACTION_TOOLTIP = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.deleteButtonTooltip', + { defaultMessage: 'Delete' } +); + +export const DELETE_ACTION_ARIA_LABEL = i18n.translate( + 'xpack.observability.rules.rulesTable.columns.deleteAriaLabel', + { defaultMessage: 'Delete' } +); + +export const RULES_PAGE_TITLE = i18n.translate('xpack.observability.rulesTitle', { + defaultMessage: 'Rules', +}); + +export const RULES_BREADCRUMB_TEXT = i18n.translate( + 'xpack.observability.breadcrumbs.rulesLinkText', + { + defaultMessage: 'Rules', + } +); + +export const RULES_LOAD_ERROR = i18n.translate('xpack.observability.rules.loadError', { + defaultMessage: 'Unable to load rules', +}); + +export const RULES_SINGLE_TITLE = i18n.translate( + 'xpack.observability.rules.rulesTable.singleTitle', + { + defaultMessage: 'rule', + } +); + +export const RULES_PLURAL_TITLE = i18n.translate( + 'xpack.observability.rules.rulesTable.pluralTitle', + { + defaultMessage: 'rules', + } +); + +export const confirmModalText = ( + numIdsToDelete: number, + singleTitle: string, + multipleTitle: string +) => + i18n.translate('xpack.observability.rules.deleteSelectedIdsConfirmModal.descriptionText', { + defaultMessage: + "You can't recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.", + values: { numIdsToDelete, singleTitle, multipleTitle }, + }); + +export const confirmButtonText = ( + numIdsToDelete: number, + singleTitle: string, + multipleTitle: string +) => + i18n.translate('xpack.observability.rules.deleteSelectedIdsConfirmModal.deleteButtonLabel', { + defaultMessage: + 'Delete {numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}} ', + values: { numIdsToDelete, singleTitle, multipleTitle }, + }); + +export const cancelButtonText = i18n.translate( + 'xpack.observability.rules.deleteSelectedIdsConfirmModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } +); + +export const deleteSuccessText = ( + numSuccesses: number, + singleTitle: string, + multipleTitle: string +) => + i18n.translate('xpack.observability.rules.deleteSelectedIdsSuccessNotification.descriptionText', { + defaultMessage: + 'Deleted {numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}', + values: { numSuccesses, singleTitle, multipleTitle }, + }); + +export const deleteErrorText = (numErrors: number, singleTitle: string, multipleTitle: string) => + i18n.translate('xpack.observability.rules.deleteSelectedIdsErrorNotification.descriptionText', { + defaultMessage: + 'Failed to delete {numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}', + values: { numErrors, singleTitle, multipleTitle }, + }); diff --git a/x-pack/plugins/observability/public/pages/rules/types.ts b/x-pack/plugins/observability/public/pages/rules/types.ts new file mode 100644 index 00000000000000..9d58847b8e0c91 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/types.ts @@ -0,0 +1,79 @@ +/* + * 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 { EuiTableSortingType, EuiBasicTableColumn } from '@elastic/eui'; +import { AlertExecutionStatus } from '../../../../alerting/common'; +import { RuleTableItem, Rule } from '../../../../triggers_actions_ui/public'; +export interface StatusProps { + type: RuleStatus; + onClick: () => void; +} + +export enum RuleStatus { + enabled = 'enabled', + disabled = 'disabled', +} + +export type Status = Record< + RuleStatus, + { + color: string; + label: string; + } +>; + +export interface StatusContextProps { + item: RuleTableItem; + onStatusChanged: (status: RuleStatus) => void; + enableRule: (rule: Rule) => Promise; + disableRule: (rule: Rule) => Promise; + muteRule: (rule: Rule) => Promise; +} + +export interface StatusFilterProps { + selectedStatuses: string[]; + onChange?: (selectedRuleStatusesIds: string[]) => void; +} + +export interface ExecutionStatusProps { + executionStatus: AlertExecutionStatus; +} + +export interface LastRunProps { + date: Date; +} + +export interface RuleNameProps { + name: string; + rule: RuleTableItem; +} + +export interface EditFlyoutProps { + currentRule: RuleTableItem; + onSave: () => Promise; +} + +export interface Pagination { + index: number; + size: number; +} + +export interface FetchRulesProps { + ruleLastResponseFilter: string[]; + page: Pagination; + sort: EuiTableSortingType['sort']; +} + +export interface RulesTableProps { + columns: Array>; + rules: RuleTableItem[]; + page: Pagination; + totalItemCount: number; + onPageChange: (changedPage: Pagination) => void; + sort: EuiTableSortingType['sort']; + onSortChange: (changedSort: EuiTableSortingType['sort']) => void; + isLoading: boolean; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_types.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_types.ts new file mode 100644 index 00000000000000..74f6d9c2197b95 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_types.ts @@ -0,0 +1,71 @@ +/* + * 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 { useEffect, useState, useRef } from 'react'; +import { loadRuleTypes } from '../lib/rule_api'; +import { RuleType, RuleTypeIndex } from '../../types'; +import { useKibana } from '../../common/lib/kibana'; + +interface RuleTypesState { + isLoading: boolean; + data: Array>; + error: string | null; +} + +interface RuleTypesProps { + filteredSolutions?: string[] | undefined; +} + +export function useLoadRuleTypes({ filteredSolutions }: RuleTypesProps) { + const { http } = useKibana().services; + const isMounted = useRef(false); + const [ruleTypesState, setRuleTypesState] = useState({ + isLoading: false, + data: [], + error: null, + }); + const [ruleTypeIndex, setRuleTypeIndex] = useState(new Map()); + + async function fetchRuleTypes() { + setRuleTypesState({ ...ruleTypesState, isLoading: true }); + try { + const response = await loadRuleTypes({ http }); + const index: RuleTypeIndex = new Map(); + for (const ruleTypeItem of response) { + index.set(ruleTypeItem.id, ruleTypeItem); + } + if (isMounted.current) { + setRuleTypeIndex(index); + + let filteredResponse = response; + + if (filteredSolutions && filteredSolutions.length > 0) { + filteredResponse = response.filter((item) => filteredSolutions.includes(item.producer)); + } + setRuleTypesState({ ...ruleTypesState, isLoading: false, data: filteredResponse }); + } + } catch (e) { + if (isMounted.current) { + setRuleTypesState({ ...ruleTypesState, isLoading: false, error: e }); + } + } + } + + useEffect(() => { + isMounted.current = true; + fetchRuleTypes(); + return () => { + isMounted.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + ruleTypes: ruleTypesState.data, + error: ruleTypesState.error, + ruleTypeIndex, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx index 2854e652e769e8..14f04d8872b942 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx @@ -47,6 +47,7 @@ const RuleAdd = ({ reloadRules, onSave, metadata, + filteredSolutions, ...props }: RuleAddProps) => { const onSaveHandler = onSave ?? reloadRules; @@ -252,6 +253,7 @@ const RuleAdd = ({ actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} metadata={metadata} + filteredSolutions={filteredSolutions} /> ({ - loadRuleTypes: jest.fn(), + +jest.mock('../../hooks/use_load_rule_types', () => ({ + useLoadRuleTypes: jest.fn(), })); jest.mock('../../../common/lib/kibana'); @@ -95,7 +96,7 @@ describe('rule_form', () => { async function setup() { const mocks = coreMock.createSetup(); - const { loadRuleTypes } = jest.requireMock('../../lib/rule_api'); + const { useLoadRuleTypes } = jest.requireMock('../../hooks/use_load_rule_types'); const ruleTypes: RuleType[] = [ { id: 'my-rule-type', @@ -144,7 +145,7 @@ describe('rule_form', () => { enabledInLicense: false, }, ]; - loadRuleTypes.mockResolvedValue(ruleTypes); + useLoadRuleTypes.mockReturnValue({ ruleTypes }); const [ { application: { capabilities }, @@ -266,46 +267,48 @@ describe('rule_form', () => { let wrapper: ReactWrapper; async function setup() { - const { loadRuleTypes } = jest.requireMock('../../lib/rule_api'); - - loadRuleTypes.mockResolvedValue([ - { - id: 'other-consumer-producer-rule-type', - name: 'Test', - actionGroups: [ - { - id: 'testActionGroup', - name: 'Test Action Group', + const { useLoadRuleTypes } = jest.requireMock('../../hooks/use_load_rule_types'); + + useLoadRuleTypes.mockReturnValue({ + ruleTypes: [ + { + id: 'other-consumer-producer-rule-type', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', + recoveryActionGroup: RecoveredActionGroup, + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, }, - ], - defaultActionGroupId: 'testActionGroup', - minimumLicenseRequired: 'basic', - recoveryActionGroup: RecoveredActionGroup, - producer: ALERTS_FEATURE_ID, - authorizedConsumers: { - [ALERTS_FEATURE_ID]: { read: true, all: true }, - test: { read: true, all: true }, }, - }, - { - id: 'same-consumer-producer-rule-type', - name: 'Test', - actionGroups: [ - { - id: 'testActionGroup', - name: 'Test Action Group', + { + id: 'same-consumer-producer-rule-type', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', + recoveryActionGroup: RecoveredActionGroup, + producer: 'test', + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, }, - ], - defaultActionGroupId: 'testActionGroup', - minimumLicenseRequired: 'basic', - recoveryActionGroup: RecoveredActionGroup, - producer: 'test', - authorizedConsumers: { - [ALERTS_FEATURE_ID]: { read: true, all: true }, - test: { read: true, all: true }, }, - }, - ]); + ], + }); const mocks = coreMock.createSetup(); const [ { @@ -379,7 +382,7 @@ describe('rule_form', () => { wrapper.update(); }); - expect(loadRuleTypes).toHaveBeenCalled(); + expect(useLoadRuleTypes).toHaveBeenCalled(); } it('renders rule type options which producer correspond to the rule consumer', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 6ec6a93c34af22..9df4679afad26c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -42,14 +42,12 @@ import { getDurationNumberInItsUnit, getDurationUnitValue, } from '../../../../../alerting/common/parse_duration'; -import { loadRuleTypes } from '../../lib/rule_api'; import { RuleReducerAction, InitialRule } from './rule_reducer'; import { RuleTypeModel, Rule, IErrorObject, RuleAction, - RuleTypeIndex, RuleType, RuleTypeRegistryContract, ActionTypeRegistryContract, @@ -76,6 +74,7 @@ import { ruleTypeCompare, ruleTypeGroupCompare } from '../../lib/rule_type_compa import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { SectionLoading } from '../../components/section_loading'; import { DEFAULT_ALERT_INTERVAL } from '../../constants'; +import { useLoadRuleTypes } from '../../hooks/use_load_rule_types'; const ENTER_KEY = 13; @@ -95,6 +94,7 @@ interface RuleFormProps> { setHasActionsDisabled?: (value: boolean) => void; setHasActionsWithBrokenConnector?: (value: boolean) => void; metadata?: MetaData; + filteredSolutions?: string[] | undefined; } const defaultScheduleInterval = getDurationNumberInItsUnit(DEFAULT_ALERT_INTERVAL); @@ -112,9 +112,9 @@ export const RuleForm = ({ ruleTypeRegistry, actionTypeRegistry, metadata, + filteredSolutions, }: RuleFormProps) => { const { - http, notifications: { toasts }, docLinks, application: { capabilities }, @@ -143,7 +143,6 @@ export const RuleForm = ({ rule.throttle ? getDurationUnitValue(rule.throttle) : 'h' ); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); - const [ruleTypeIndex, setRuleTypeIndex] = useState(null); const [availableRuleTypes, setAvailableRuleTypes] = useState< Array<{ ruleTypeModel: RuleTypeModel; ruleType: RuleType }> @@ -156,53 +155,77 @@ export const RuleForm = ({ const [solutions, setSolutions] = useState | undefined>(undefined); const [solutionsFilter, setSolutionFilter] = useState([]); let hasDisabledByLicenseRuleTypes: boolean = false; + const { + ruleTypes, + error: loadRuleTypesError, + ruleTypeIndex, + } = useLoadRuleTypes({ filteredSolutions }); // load rule types useEffect(() => { - (async () => { - try { - const ruleTypesResult = await loadRuleTypes({ http }); - const index: RuleTypeIndex = new Map(); - for (const ruleTypeItem of ruleTypesResult) { - index.set(ruleTypeItem.id, ruleTypeItem); - } - if (rule.ruleTypeId && index.has(rule.ruleTypeId)) { - setDefaultActionGroupId(index.get(rule.ruleTypeId)!.defaultActionGroupId); - } - setRuleTypeIndex(index); - - const availableRuleTypesResult = getAvailableRuleTypes(ruleTypesResult); - setAvailableRuleTypes(availableRuleTypesResult); - - const solutionsResult = availableRuleTypesResult.reduce( - (result: Map, ruleTypeItem) => { - if (!result.has(ruleTypeItem.ruleType.producer)) { - result.set( - ruleTypeItem.ruleType.producer, - (kibanaFeatures - ? getProducerFeatureName(ruleTypeItem.ruleType.producer, kibanaFeatures) - : capitalize(ruleTypeItem.ruleType.producer)) ?? - capitalize(ruleTypeItem.ruleType.producer) - ); + if (rule.ruleTypeId && ruleTypeIndex?.has(rule.ruleTypeId)) { + setDefaultActionGroupId(ruleTypeIndex.get(rule.ruleTypeId)!.defaultActionGroupId); + } + + const getAvailableRuleTypes = (ruleTypesResult: RuleType[]) => + ruleTypeRegistry + .list() + .reduce( + ( + arr: Array<{ ruleType: RuleType; ruleTypeModel: RuleTypeModel }>, + ruleTypeRegistryItem: RuleTypeModel + ) => { + const ruleType = ruleTypesResult.find((item) => ruleTypeRegistryItem.id === item.id); + if (ruleType) { + arr.push({ + ruleType, + ruleTypeModel: ruleTypeRegistryItem, + }); } - return result; + return arr; }, - new Map() - ); - setSolutions( - new Map([...solutionsResult.entries()].sort(([, a], [, b]) => a.localeCompare(b))) + [] + ) + .filter((item) => item.ruleType && hasAllPrivilege(rule, item.ruleType)) + .filter((item) => + rule.consumer === ALERTS_FEATURE_ID + ? !item.ruleTypeModel.requiresAppContext + : item.ruleType!.producer === rule.consumer ); - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.ruleForm.unableToLoadRuleTypesMessage', - { defaultMessage: 'Unable to load rule types' } - ), - }); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + + const availableRuleTypesResult = getAvailableRuleTypes(ruleTypes); + setAvailableRuleTypes(availableRuleTypesResult); + + const solutionsResult = availableRuleTypesResult.reduce( + (result: Map, ruleTypeItem) => { + if (!result.has(ruleTypeItem.ruleType.producer)) { + result.set( + ruleTypeItem.ruleType.producer, + (kibanaFeatures + ? getProducerFeatureName(ruleTypeItem.ruleType.producer, kibanaFeatures) + : capitalize(ruleTypeItem.ruleType.producer)) ?? + capitalize(ruleTypeItem.ruleType.producer) + ); + } + return result; + }, + new Map() + ); + setSolutions( + new Map([...solutionsResult.entries()].sort(([, a], [, b]) => a.localeCompare(b))) + ); + }, [ruleTypes, ruleTypeIndex, rule.ruleTypeId, kibanaFeatures, rule, ruleTypeRegistry]); + + useEffect(() => { + if (loadRuleTypesError) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.unableToLoadRuleTypesMessage', + { defaultMessage: 'Unable to load rule types' } + ), + }); + } + }, [loadRuleTypesError, toasts]); useEffect(() => { setRuleTypeModel(rule.ruleTypeId ? ruleTypeRegistry.get(rule.ruleTypeId) : null); @@ -280,31 +303,6 @@ export const RuleForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ruleTypeRegistry, availableRuleTypes, searchText, JSON.stringify(solutionsFilter)]); - const getAvailableRuleTypes = (ruleTypesResult: RuleType[]) => - ruleTypeRegistry - .list() - .reduce( - ( - arr: Array<{ ruleType: RuleType; ruleTypeModel: RuleTypeModel }>, - ruleTypeRegistryItem: RuleTypeModel - ) => { - const ruleType = ruleTypesResult.find((item) => ruleTypeRegistryItem.id === item.id); - if (ruleType) { - arr.push({ - ruleType, - ruleTypeModel: ruleTypeRegistryItem, - }); - } - return arr; - }, - [] - ) - .filter((item) => item.ruleType && hasAllPrivilege(rule, item.ruleType)) - .filter((item) => - rule.consumer === ALERTS_FEATURE_ID - ? !item.ruleTypeModel.requiresAppContext - : item.ruleType!.producer === rule.consumer - ); const selectedRuleType = rule?.ruleTypeId ? ruleTypeIndex?.get(rule?.ruleTypeId) : undefined; const recoveryActionGroup = selectedRuleType?.recoveryActionGroup?.id; const getDefaultActionParams = useCallback( diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 3ba665871e721c..473fc7e42497b7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -13,6 +13,8 @@ import { Plugin } from './plugin'; export type { RuleAction, Rule, + RuleType, + RuleTypeIndex, RuleTypeModel, ActionType, ActionTypeRegistryContract, @@ -25,6 +27,7 @@ export type { RuleFlyoutCloseReason, RuleTypeParams, AsApiContract, + RuleTableItem, } from './types'; export { @@ -47,7 +50,12 @@ export { Plugin }; export * from './plugin'; // TODO remove this import when we expose the Rules tables as a component export { loadRules } from './application/lib/rule_api/rules'; +export { deleteRules } from './application/lib/rule_api/delete'; +export { enableRule } from './application/lib/rule_api/enable'; +export { disableRule } from './application/lib/rule_api/disable'; +export { muteRule } from './application/lib/rule_api/mute'; export { loadRuleAggregations } from './application/lib/rule_api/aggregate'; +export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; export { loadActionTypes } from './application/lib/action_connector_api/connector_types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index d4658c3e3f5a5b..2446b76f956edf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -339,6 +339,7 @@ export interface RuleAddProps> { onSave?: () => Promise; metadata?: MetaData; ruleTypeIndex?: RuleTypeIndex; + filteredSolutions?: string[] | undefined; } export enum Percentiles {