diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts new file mode 100644 index 0000000000000..b2b4e144952e0 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { renderRuleStats } from './rule_stats'; +export type { RuleStatsState } from './types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx new file mode 100644 index 0000000000000..6f2edf5d0b1b6 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx @@ -0,0 +1,199 @@ +/* + * 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 { renderRuleStats } from './rule_stats'; +import { render, screen } from '@testing-library/react'; + +const RULES_PAGE_LINK = '/app/observability/alerts/rules'; +const STAT_CLASS = 'euiStat'; +const STAT_TITLE_PRIMARY_CLASS = 'euiStat__title--primary'; +const STAT_BUTTON_CLASS = 'euiButtonEmpty'; + +describe('Rule stats', () => { + test('renders all rule stats', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + expect(stats.length).toEqual(6); + }); + test('disabled stat is not clickable, when there are no disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[4]); + const disabledElement = await findByText('Disabled'); + expect(disabledElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('disabled stat is clickable, when there are disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 1, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[4]); + expect(screen.getByText('Disabled').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(disabled))` + ); + + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + }); + + test('disabled stat count is link-colored, when there are disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 1, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[4]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); + + test('snoozed stat is not clickable, when there are no snoozed rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[3]); + const snoozedElement = await findByText('Snoozed'); + expect(snoozedElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('snoozed stat is clickable, when there are snoozed rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 1, + error: 0, + snoozed: 1, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[3]); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + expect(screen.getByText('Snoozed').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(snoozed))` + ); + }); + + test('snoozed stat count is link-colored, when there are snoozed rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 1, + error: 0, + snoozed: 1, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[3]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); + + test('errors stat is not clickable, when there are no error rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[2]); + const errorsElement = await findByText('Errors'); + expect(errorsElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('errors stat is clickable, when there are error rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 2, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[2]); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + expect(screen.getByText('Errors').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(error),status:!())` + ); + }); + + test('errors stat count is link-colored, when there are error rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 2, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[2]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx new file mode 100644 index 0000000000000..62c520c7b7442 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx @@ -0,0 +1,156 @@ +/* + * 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 { EuiButtonEmpty, EuiStat } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; + +interface RuleStatsState { + total: number; + disabled: number; + muted: number; + error: number; + snoozed: number; +} +type StatType = 'disabled' | 'snoozed' | 'error'; + +const Divider = euiStyled.div` + border-right: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + height: 100%; +`; + +const StyledStat = euiStyled(EuiStat)` + .euiText { + line-height: 1; + } +`; + +const ConditionalWrap = ({ + condition, + wrap, + children, +}: { + condition: boolean; + wrap: (wrappedChildren: React.ReactNode) => JSX.Element; + children: JSX.Element; +}): JSX.Element => (condition ? wrap(children) : children); + +export const renderRuleStats = ( + ruleStats: RuleStatsState, + manageRulesHref: string, + ruleStatsLoading: boolean +) => { + const createRuleStatsLink = (stats: RuleStatsState, statType: StatType) => { + const count = stats[statType]; + let statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!())`; + if (count > 0) { + switch (statType) { + case 'error': + statsLink = `${manageRulesHref}?_a=(lastResponse:!(error),status:!())`; + break; + case 'snoozed': + case 'disabled': + statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!(${statType}))`; + break; + default: + break; + } + } + return statsLink; + }; + + const disabledStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statDisabled" + /> + + ); + + const snoozedStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statMuted" + /> + + ); + + const errorStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statErrors" + /> + + ); + return [ + , + disabledStatsComponent, + snoozedStatsComponent, + errorStatsComponent, + , + + {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { + defaultMessage: 'Manage Rules', + })} + , + ].reverse(); +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts new file mode 100644 index 0000000000000..87ff668ebf87f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts @@ -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. + */ + +export interface RuleStatsState { + total: number; + disabled: number; + muted: number; + error: number; + snoozed: number; +} + +export type StatType = 'disabled' | 'snoozed' | 'error'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index e99a3195d0f30..2fe114771c329 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -5,15 +5,13 @@ * 2.0. */ -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DataViewBase } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { ALERT_STATUS, AlertStatus } from '@kbn/rule-data-utils'; - -import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public'; import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_technical_fields'; @@ -38,6 +36,7 @@ import { } from '../state_container'; import './styles.scss'; import { AlertsStatusFilter, AlertsDisclaimer, AlertsSearchBar } from '../../components'; +import { renderRuleStats } from '../../components/rule_stats'; import { ObservabilityAppServices } from '../../../../application/types'; import { OBSERVABILITY_RULE_TYPES } from '../../../rules/config'; @@ -57,11 +56,6 @@ export interface TopAlert { active: boolean; } -const Divider = euiStyled.div` - border-right: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; - height: 100%; -`; - const regExpEscape = (str: string) => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); const NO_INDEX_PATTERNS: DataViewBase[] = []; const BASE_ALERT_REGEX = new RegExp(`\\s*${regExpEscape(ALERT_STATUS)}\\s*:\\s*"(.*?|\\*?)"`); @@ -251,54 +245,7 @@ function AlertsPage() { ), - rightSideItems: [ - , - , - , - , - , - - {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { - defaultMessage: 'Manage Rules', - })} - , - ].reverse(), + rightSideItems: renderRuleStats(ruleStats, manageRulesHref, ruleStatsLoading), }} >