diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index b81046df99d28..53b2f68821710 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -6,6 +6,7 @@ */ import { useEffect, useState, useCallback } from 'react'; +import { isEmpty } from 'lodash'; import { loadRules, Rule } from '../../../triggers_actions_ui/public'; import { RULES_LOAD_ERROR } from '../pages/rules/translations'; import { FetchRulesProps } from '../pages/rules/types'; @@ -19,7 +20,13 @@ interface RuleState { totalItemCount: number; } -export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }: FetchRulesProps) { +export function useFetchRules({ + searchText, + ruleLastResponseFilter, + setPage, + page, + sort, +}: FetchRulesProps) { const { http } = useKibana().services; const [rulesState, setRulesState] = useState({ @@ -29,6 +36,9 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } totalItemCount: 0, }); + const [noData, setNoData] = useState(true); + const [initialLoad, setInitialLoad] = useState(true); + const fetchRules = useCallback(async () => { setRulesState((oldState) => ({ ...oldState, isLoading: true })); @@ -47,10 +57,18 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } data: response.data, totalItemCount: response.total, })); + + if (!response.data?.length && page.index > 0) { + setPage({ ...page, index: 0 }); + } + const isFilterApplied = !(isEmpty(searchText) && isEmpty(ruleLastResponseFilter)); + + setNoData(response.data.length === 0 && !isFilterApplied); } catch (_e) { setRulesState((oldState) => ({ ...oldState, isLoading: false, error: RULES_LOAD_ERROR })); } - }, [http, page, searchText, ruleLastResponseFilter, sort]); + setInitialLoad(false); + }, [http, page, setPage, searchText, ruleLastResponseFilter, sort]); useEffect(() => { fetchRules(); }, [fetchRules]); @@ -59,5 +77,7 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } rulesState, reload: fetchRules, setRulesState, + noData, + initialLoad, }; } diff --git a/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx b/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx new file mode 100644 index 0000000000000..867d530eb4e2f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.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 { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiLoadingSpinnerSize } from '@elastic/eui/src/components/loading/loading_spinner'; + +interface Props { + size?: EuiLoadingSpinnerSize; +} + +export function CenterJustifiedSpinner({ size }: Props) { + return ( + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index 2b1f831256910..cbde68ea27eb4 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { RuleNameProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; @@ -34,17 +33,5 @@ export function Name({ name, rule }: RuleNameProps) { ); - return ( - <> - {link} - {rule.enabled && rule.muteAll && ( - - - - )} - - ); + return <>{link}; } diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx new file mode 100644 index 0000000000000..b9c0e24160004 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx @@ -0,0 +1,69 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiButton, EuiEmptyPrompt, EuiLink, EuiButtonEmpty, EuiPageTemplate } from '@elastic/eui'; + +export function NoDataPrompt({ + onCTAClicked, + documentationLink, +}: { + onCTAClicked: () => void; + documentationLink: string; +}) { + return ( + + + + + } + body={ +

+ +

+ } + actions={[ + + + , + + + Documentation + + , + ]} + /> +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx new file mode 100644 index 0000000000000..edfe1c6840d8b --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx @@ -0,0 +1,44 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; + +export function NoPermissionPrompt() { + return ( + + + + + } + body={ +

+ +

+ } + /> +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status.tsx b/x-pack/plugins/observability/public/pages/rules/components/status.tsx index abc2dc8bfa492..612d6f8f30bdd 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status.tsx @@ -5,19 +5,28 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiBadge } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import { StatusProps } from '../types'; import { statusMap } from '../config'; +import { RULES_CHANGE_STATUS } from '../translations'; -export function Status({ type, onClick }: StatusProps) { +export function Status({ type, disabled, onClick }: StatusProps) { + const props = useMemo( + () => ({ + color: statusMap[type].color, + ...(!disabled ? { onClick } : { onClick: noop }), + ...(!disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }), + }), + [disabled, onClick, type] + ); 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 index 49761d7c43154..c7bd29d85b17a 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx @@ -18,19 +18,26 @@ import { statusMap } from '../config'; export function StatusContext({ item, + disabled = false, onStatusChanged, enableRule, disableRule, muteRule, + unMuteRule, }: 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; + let currentStatus: RuleStatus; + if (item.enabled) { + currentStatus = item.muteAll ? RuleStatus.snoozed : RuleStatus.enabled; + } else { + currentStatus = RuleStatus.disabled; + } const popOverButton = useMemo( - () => , - [currentStatus, togglePopover] + () => , + [disabled, currentStatus, togglePopover] ); const onContextMenuItemClick = useCallback( @@ -41,15 +48,30 @@ export function StatusContext({ if (status === RuleStatus.enabled) { await enableRule({ ...item, enabled: true }); + if (item.muteAll) { + await unMuteRule({ ...item, muteAll: false }); + } } else if (status === RuleStatus.disabled) { await disableRule({ ...item, enabled: false }); + } else if (status === RuleStatus.snoozed) { + await muteRule({ ...item, muteAll: true }); } setIsUpdating(false); onStatusChanged(status); } }, - [item, togglePopover, enableRule, disableRule, currentStatus, onStatusChanged] + [ + item, + togglePopover, + enableRule, + disableRule, + muteRule, + unMuteRule, + currentStatus, + onStatusChanged, + ] ); + const panelItems = useMemo( () => Object.values(RuleStatus).map((status: RuleStatus) => ( @@ -57,6 +79,7 @@ export function StatusContext({ icon={status === currentStatus ? 'check' : 'empty'} key={status} onClick={() => onContextMenuItemClick(status)} + disabled={status === RuleStatus.snoozed && currentStatus === RuleStatus.disabled} > {statusMap[status].label} diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index afff097776e19..736f538ee7b21 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -13,6 +13,9 @@ import { RULE_STATUS_PENDING, RULE_STATUS_UNKNOWN, RULE_STATUS_WARNING, + RULE_STATUS_ENABLED, + RULE_STATUS_DISABLED, + RULE_STATUS_SNOOZED_INDEFINITELY, } from './translations'; import { AlertExecutionStatuses } from '../../../../alerting/common'; import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public'; @@ -20,11 +23,15 @@ import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/p export const statusMap: Status = { [RuleStatus.enabled]: { color: 'primary', - label: 'Enabled', + label: RULE_STATUS_ENABLED, }, [RuleStatus.disabled]: { color: 'default', - label: 'Disabled', + label: RULE_STATUS_DISABLED, + }, + [RuleStatus.snoozed]: { + color: 'warning', + label: RULE_STATUS_SNOOZED_INDEFINITELY, }, }; @@ -93,3 +100,8 @@ export function convertRulesToTableItems( enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, })); } + +type Capabilities = Record; + +export const hasExecuteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.execute; diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx index 8c44fa90fb3d1..21664ca63507d 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -32,6 +32,9 @@ 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 { NoDataPrompt } from './components/prompts/no_data_prompt'; +import { NoPermissionPrompt } from './components/prompts/no_permission_prompt'; +import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { deleteRules, RuleTableItem, @@ -39,6 +42,7 @@ import { disableRule, muteRule, useLoadRuleTypes, + unmuteRule, } from '../../../../triggers_actions_ui/public'; import { AlertExecutionStatus, ALERTS_FEATURE_ID } from '../../../../alerting/common'; import { Pagination } from './types'; @@ -46,6 +50,7 @@ import { DEFAULT_SEARCH_PAGE_SIZE, convertRulesToTableItems, OBSERVABILITY_SOLUTIONS, + hasExecuteActionsCapability, } from './config'; import { LAST_RESPONSE_COLUMN_TITLE, @@ -73,9 +78,12 @@ export function RulesPage() { http, docLinks, triggersActionsUi, + application: { capabilities }, notifications: { toasts }, } = useKibana().services; - + const documentationLink = docLinks.links.alerting.guide; + const ruleTypeRegistry = triggersActionsUi.ruleTypeRegistry; + const canExecuteActions = hasExecuteActionsCapability(capabilities); const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }); const [sort, setSort] = useState['sort']>({ field: 'name', @@ -90,6 +98,9 @@ export function RulesPage() { const [rulesToDelete, setRulesToDelete] = useState([]); const [createRuleFlyoutVisibility, setCreateRuleFlyoutVisibility] = useState(false); + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + const onRuleEdit = (ruleItem: RuleTableItem) => { setCurrentRuleToEdit(ruleItem); }; @@ -102,14 +113,22 @@ export function RulesPage() { setRefreshInterval(refreshIntervalChanged); }; - const { rulesState, setRulesState, reload } = useFetchRules({ + const { rulesState, setRulesState, reload, noData, initialLoad } = useFetchRules({ searchText, ruleLastResponseFilter, page, + setPage, sort, }); const { data: rules, totalItemCount, error } = rulesState; - const { ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS }); + const { ruleTypeIndex, ruleTypes } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + const authorizedRuleTypes = [...ruleTypes.values()]; + + const authorizedToCreateAnyRules = authorizedRuleTypes.some( + (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all + ); useEffect(() => { const interval = setInterval(() => { @@ -161,11 +180,13 @@ export function RulesPage() { render: (_enabled: boolean, item: RuleTableItem) => { return ( 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 })} + unMuteRule={async () => await unmuteRule({ http, id: item.id })} /> ); }, @@ -180,6 +201,9 @@ export function RulesPage() { { + if (noData && !rulesState.isLoading) { + return authorizedToCreateAnyRules ? ( + setCreateRuleFlyoutVisibility(true)} + /> + ) : ( + + ); + } + if (initialLoad) { + return ; + } + return ( + <> + + + { + setInputText(e.target.value); + if (e.target.value === '') { + setSearchText(e.target.value); + } + }} + onKeyUp={(e) => { + if (e.keyCode === ENTER_KEY) { + setSearchText(inputText); + } + }} + placeholder={SEARCH_PLACEHOLDER} + /> + + + setRuleLastResponseFilter(ids)} + /> + + + + + + , + + + + + + + + + + + + + + + + setPage(index)} + sort={sort} + onSortChange={(changedSort) => { + setSort(changedSort); + }} + /> + + + + ); + }; + return ( ), rightSideItems: [ - setCreateRuleFlyoutVisibility(true)} - > - - , + authorizedToCreateAnyRules && ( + setCreateRuleFlyoutVisibility(true)} + > + + + ), - - - { - setInputText(e.target.value); - if (e.target.value === '') { - setSearchText(e.target.value); - } - }} - onKeyUp={(e) => { - if (e.keyCode === ENTER_KEY) { - setSearchText(inputText); - } - }} - placeholder={SEARCH_PLACEHOLDER} - /> - - - setRuleLastResponseFilter(ids)} - /> - - - - - - , - - - - - - - - - - - - - - - - setPage(index)} - sort={sort} - onSortChange={(changedSort) => { - setSort(changedSort); - }} - /> - - + {getRulesTable()} {error && toasts.addDanger({ title: error, diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts index b72d03bf8e566..36f8ff62f1a4c 100644 --- a/x-pack/plugins/observability/public/pages/rules/translations.ts +++ b/x-pack/plugins/observability/public/pages/rules/translations.ts @@ -53,6 +53,27 @@ export const RULE_STATUS_WARNING = i18n.translate( } ); +export const RULE_STATUS_ENABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusEnabled', + { + defaultMessage: 'Enabled', + } +); + +export const RULE_STATUS_DISABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusDisabled', + { + defaultMessage: 'Disabled', + } +); + +export const RULE_STATUS_SNOOZED_INDEFINITELY = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusSnoozedIndefinitely', + { + defaultMessage: 'Snoozed indefinitely', + } +); + export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate( 'xpack.observability.rules.rulesTable.columns.lastResponseTitle', { @@ -144,6 +165,13 @@ export const SEARCH_PLACEHOLDER = i18n.translate( { defaultMessage: 'Search' } ); +export const RULES_CHANGE_STATUS = i18n.translate( + 'xpack.observability.rules.rulesTable.changeStatusAriaLabel', + { + defaultMessage: 'Change status', + } +); + export const confirmModalText = ( numIdsToDelete: number, singleTitle: string, diff --git a/x-pack/plugins/observability/public/pages/rules/types.ts b/x-pack/plugins/observability/public/pages/rules/types.ts index 23443890ad8fa..1a15cf3d9cef2 100644 --- a/x-pack/plugins/observability/public/pages/rules/types.ts +++ b/x-pack/plugins/observability/public/pages/rules/types.ts @@ -4,17 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { Dispatch, SetStateAction } from 'react'; import { EuiTableSortingType, EuiBasicTableColumn } from '@elastic/eui'; import { AlertExecutionStatus } from '../../../../alerting/common'; import { RuleTableItem, Rule } from '../../../../triggers_actions_ui/public'; export interface StatusProps { type: RuleStatus; + disabled: boolean; onClick: () => void; } export enum RuleStatus { enabled = 'enabled', disabled = 'disabled', + snoozed = 'snoozed', } export type Status = Record< @@ -27,10 +30,12 @@ export type Status = Record< export interface StatusContextProps { item: RuleTableItem; + disabled: boolean; onStatusChanged: (status: RuleStatus) => void; enableRule: (rule: Rule) => Promise; disableRule: (rule: Rule) => Promise; muteRule: (rule: Rule) => Promise; + unMuteRule: (rule: Rule) => Promise; } export interface StatusFilterProps { @@ -65,6 +70,7 @@ export interface FetchRulesProps { searchText: string | undefined; ruleLastResponseFilter: string[]; page: Pagination; + setPage: Dispatch>; sort: EuiTableSortingType['sort']; } diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index eb346e43cfbc9..b1ef489bfef70 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -55,6 +55,7 @@ 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 { unmuteRule } from './application/lib/rule_api/unmute'; export { loadRuleAggregations } from './application/lib/rule_api/aggregate'; export { useLoadRuleTypes } from './application/hooks/use_load_rule_types';