From 4a4138dc3a8f2f84bc6a37cff00063397115b65b Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Mon, 30 Jan 2023 15:20:14 -0800 Subject: [PATCH] [RAM][Flapping] Add flapping alert status to alert table (#149176) ## Summary Resolves: https://github.com/elastic/kibana/issues/148759 Adds a new component that will display an alert's flapping status in addition to its `active/recovered` status in the alerts table. This component is used both in the O11Y alert table and the stack management alerts table. This PR also allows the new alert status badge component to be shareable. ### Alerts Table: Active ![active](https://user-images.githubusercontent.com/74562234/213611338-151985f8-f320-4b04-86fe-4b25956c8b07.png) ### Alerts Table: Flapping ![flapping](https://user-images.githubusercontent.com/74562234/213611388-b969058d-b47f-4cb4-86b7-472d4996ae94.png) ### Alerts Table: Recovered (Recovered is preferred over flapping) ![recovered](https://user-images.githubusercontent.com/74562234/213611401-0b54e7a2-5b7e-4a33-b7f1-daead94188d6.png) ### Stack Management Alerts List: ![alertsList](https://user-images.githubusercontent.com/74562234/213612245-466a14a3-be0f-4c79-9c45-cc51f8eff83c.png) ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Xavier Mouligneau --- .../components/case_view_alerts.test.tsx | 16 +++- .../case_view/components/case_view_alerts.tsx | 2 + .../containers/alerts_page/alerts_page.tsx | 1 + .../overview_page/overview_page.tsx | 1 + .../public/pages/rule_details/index.tsx | 1 + .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../alert_lifecycle_status_badge.stories.tsx | 58 ++++++++++++ .../alert_lifecycle_status_badge.test.tsx | 50 ++++++++++ .../alert_lifecycle_status_badge.tsx | 94 +++++++++++++++++++ .../alerts_table/alerts_table.test.tsx | 77 +++++++++++---- .../sections/alerts_table/alerts_table.tsx | 68 ++++++++++---- .../alerts_table/alerts_table_state.tsx | 4 + .../rule_details/components/rule.test.tsx | 47 +++++----- .../sections/rule_details/components/rule.tsx | 33 +------ .../components/rule_alert_list.tsx | 24 +++-- .../sections/rule_details/components/types.ts | 10 +- .../triggers_actions_ui/public/types.ts | 2 +- .../apps/triggers_actions_ui/details.ts | 20 +--- x-pack/test/tsconfig.json | 1 - 21 files changed, 383 insertions(+), 132 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.stories.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.tsx diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx index 87bcf1557f57..ebc8d360cb2b 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { waitFor } from '@testing-library/dom'; +import { OBSERVABILITY_OWNER } from '../../../../common/constants'; import { alertCommentWithIndices, basicCase } from '../../../containers/mock'; import type { AppMockRenderer } from '../../../common/mock'; import { createAppMockRenderer } from '../../../common/mock'; @@ -54,6 +55,7 @@ describe('Case View Page activity tab', () => { }, flyoutSize: 'm', showExpandToDetails: true, + showAlertStatusWithFlapping: false, }); }); }); @@ -61,14 +63,21 @@ describe('Case View Page activity tab', () => { it('should call the alerts table with correct props for observability', async () => { const getFeatureIdsMock = jest.spyOn(api, 'getFeatureIds'); getFeatureIdsMock.mockResolvedValueOnce(['observability']); - appMockRender.render(); + appMockRender.render( + + ); await waitFor(async () => { expect(getAlertsStateTableMock).toHaveBeenCalledWith({ alertsTableConfigurationRegistry: expect.anything(), - configurationId: 'securitySolution', + configurationId: 'observability', featureIds: ['observability'], - id: 'case-details-alerts-securitySolution', + id: 'case-details-alerts-observability', query: { ids: { values: ['alert-id-1'], @@ -76,6 +85,7 @@ describe('Case View Page activity tab', () => { }, flyoutSize: 's', showExpandToDetails: false, + showAlertStatusWithFlapping: true, }); }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx index 1c0369524e03..ab2bf2831e21 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx @@ -9,6 +9,7 @@ import React, { useMemo } from 'react'; import type { EuiFlyoutSize } from '@elastic/eui'; import { EuiFlexItem, EuiFlexGroup, EuiProgress } from '@elastic/eui'; +import { SECURITY_SOLUTION_OWNER } from '../../../../common/constants'; import type { Case } from '../../../../common'; import { useKibana } from '../../../common/lib/kibana'; import { getManualAlertIds, getRegistrationContextFromAlerts } from './helpers'; @@ -46,6 +47,7 @@ export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => { featureIds: alertFeatureIds ?? [], query: alertIdsQuery, showExpandToDetails: Boolean(alertFeatureIds?.includes('siem')), + showAlertStatusWithFlapping: caseData.owner !== SECURITY_SOLUTION_OWNER, }; if (alertIdsQuery.ids.values.length === 0) { 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 1d68fa947f9c..b41e04afe1bc 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 @@ -199,6 +199,7 @@ function InternalAlertsPage() { featureIds={observabilityAlertFeatureIds} query={esQuery} showExpandToDetails={false} + showAlertStatusWithFlapping pageSize={ALERTS_PER_PAGE} /> )} diff --git a/x-pack/plugins/observability/public/pages/overview/containers/overview_page/overview_page.tsx b/x-pack/plugins/observability/public/pages/overview/containers/overview_page/overview_page.tsx index fe2e08d26760..5208933ea988 100644 --- a/x-pack/plugins/observability/public/pages/overview/containers/overview_page/overview_page.tsx +++ b/x-pack/plugins/observability/public/pages/overview/containers/overview_page/overview_page.tsx @@ -209,6 +209,7 @@ export function OverviewPage() { pageSize={ALERTS_PER_PAGE} query={esQuery} showExpandToDetails={false} + showAlertStatusWithFlapping /> diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 10b783cf2410..31b483232d1c 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -281,6 +281,7 @@ export function RuleDetailsPage() { featureIds={featureIds} query={esQuery} showExpandToDetails={false} + showAlertStatusWithFlapping /> )} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 67ac7610f91f..0737305c309e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -35119,8 +35119,6 @@ "xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.apiError": "Impossible de récupérer le KPI du log d'événements.", "xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.responseTooltip": "Réponses pour un maximum de 10 000 exécutions de règles les plus récentes.", "xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription": "Dernière réponse", - "xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active": "Actif", - "xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.inactive": "Récupéré", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.disabledOptionText": "La règle est désactivée", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.enabledOptionText": "La règle est activée", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.snoozedOptionText": "La règle s’est répétée", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0a12e305395a..9439e3e65c59 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -35087,8 +35087,6 @@ "xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.apiError": "イベントログKPIを取得できませんでした。", "xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.responseTooltip": "最大10,000件の直近のルール実行の応答。", "xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription": "前回の応答", - "xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active": "アクティブ", - "xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.inactive": "回復済み", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.disabledOptionText": "ルールが無効です", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.enabledOptionText": "ルールが有効です", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.snoozedOptionText": "ルールがスヌーズされました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5a43329b8363..6b9b1d02ab38 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -35124,8 +35124,6 @@ "xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.apiError": "无法提取事件日志 KPI。", "xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.responseTooltip": "多达 10,000 次最近规则运行的响应。", "xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription": "上次响应", - "xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active": "活动", - "xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.inactive": "已恢复", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.disabledOptionText": "规则已禁用", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.enabledOptionText": "规则已启用", "xpack.triggersActionsUI.sections.ruleDetails.ruleStateFilter.snoozedOptionText": "规则已暂停", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.stories.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.stories.tsx new file mode 100644 index 000000000000..2eb55ad4f463 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.stories.tsx @@ -0,0 +1,58 @@ +/* + * 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, { ComponentProps } from 'react'; +import { Story } from '@storybook/react'; +import { ALERT_STATUS_RECOVERED, ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils'; +import { + AlertLifecycleStatusBadge, + AlertLifecycleStatusBadgeProps, +} from './alert_lifecycle_status_badge'; + +type Args = ComponentProps; + +export default { + title: 'app/AlertLifecyceStatusBadge', + component: AlertLifecycleStatusBadge, + argTypes: { + alertStatus: { + defaultValue: ALERT_STATUS_ACTIVE, + control: { + type: 'select', + options: [ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED], + }, + }, + flapping: { + defaultValue: false, + control: { + type: 'boolean', + }, + }, + }, +}; + +const Template: Story = (args: AlertLifecycleStatusBadgeProps) => { + return ; +}; + +export const Active = Template.bind({}); +Active.args = { + alertStatus: ALERT_STATUS_ACTIVE, + flapping: false, +}; + +export const Flapping = Template.bind({}); +Flapping.args = { + alertStatus: ALERT_STATUS_ACTIVE, + flapping: true, +}; + +export const Recovered = Template.bind({}); +Recovered.args = { + alertStatus: ALERT_STATUS_RECOVERED, + flapping: false, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.test.tsx new file mode 100644 index 000000000000..0b959e6b8a2d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.test.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 { render } from '@testing-library/react'; +import { AlertLifecycleStatusBadge } from './alert_lifecycle_status_badge'; + +describe('alertLifecycleStatusBadge', () => { + it('should display the alert status correctly when active and not flapping', () => { + const { getByText } = render( + + ); + expect(getByText('Active')).toBeTruthy(); + }); + + it('should display the alert status correctly when active and flapping', () => { + const { getByText } = render( + + ); + expect(getByText('Flapping')).toBeTruthy(); + }); + + it('should display the alert status correctly when recovered and not flapping', () => { + const { getByText } = render( + + ); + expect(getByText('Recovered')).toBeTruthy(); + }); + + it('should prioritize recovered over flapping when recovered and flapping', () => { + const { getByText } = render( + + ); + expect(getByText('Recovered')).toBeTruthy(); + }); + + it('should display active alert status correctly is flapping is not defined', () => { + const { getByText } = render(); + expect(getByText('Active')).toBeTruthy(); + }); + + it('should display recovered alert status correctly is flapping is not defined', () => { + const { getByText } = render(); + expect(getByText('Recovered')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.tsx new file mode 100644 index 000000000000..46d8753d3c0c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_lifecycle_status_badge.tsx @@ -0,0 +1,94 @@ +/* + * 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, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; +import { AlertStatus, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; + +export interface AlertLifecycleStatusBadgeProps { + alertStatus: AlertStatus; + flapping?: boolean | string; +} + +const ACTIVE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.alertLifecycleStatusBadge.activeLabel', + { + defaultMessage: 'Active', + } +); + +const RECOVERED_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.alertLifecycleStatusBadge.recoveredLabel', + { + defaultMessage: 'Recovered', + } +); + +const FLAPPING_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.alertLifecycleStatusBadge.flappingLabel', + { + defaultMessage: 'Flapping', + } +); + +interface BadgeProps { + label: string; + color: string; + iconProps?: { + iconType: EuiBadgeProps['iconType']; + }; +} + +const getBadgeProps = (alertStatus: AlertStatus, flapping: boolean | undefined): BadgeProps => { + // Prefer recovered over flapping + if (alertStatus === ALERT_STATUS_RECOVERED) { + return { + label: RECOVERED_LABEL, + color: 'success', + }; + } + + if (flapping) { + return { + label: FLAPPING_LABEL, + color: 'danger', + iconProps: { + iconType: 'visGauge', + }, + }; + } + + return { + label: ACTIVE_LABEL, + color: 'danger', + }; +}; + +const castFlapping = (flapping: boolean | string | undefined) => { + if (typeof flapping === 'string') { + return flapping === 'true'; + } + return flapping; +}; + +export const AlertLifecycleStatusBadge = memo((props: AlertLifecycleStatusBadgeProps) => { + const { alertStatus, flapping } = props; + + const castedFlapping = castFlapping(flapping); + + const { label, color, iconProps } = getBadgeProps(alertStatus, castedFlapping); + + return ( + + {label} + + ); +}); + +// eslint-disable-next-line import/no-default-export +export { AlertLifecycleStatusBadge as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx index 6b0d289050f5..2f61c8d51996 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx @@ -10,9 +10,9 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; - +import { ALERT_RULE_NAME, ALERT_REASON, ALERT_FLAPPING, ALERT_STATUS } from '@kbn/rule-data-utils'; import { AlertsTable } from './alerts_table'; -import { AlertsField, AlertsTableProps, BulkActionsState, RowSelectionState } from '../../../types'; +import { AlertsTableProps, BulkActionsState, RowSelectionState } from '../../../types'; import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { BulkActionsContext } from './bulk_actions/context'; @@ -25,27 +25,47 @@ jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({ const columns = [ { - id: AlertsField.name, + id: ALERT_RULE_NAME, displayAsText: 'Name', }, { - id: AlertsField.reason, + id: ALERT_REASON, displayAsText: 'Reason', }, + { + id: ALERT_STATUS, + displayAsText: 'Alert status', + }, ]; -describe('AlertsTable', () => { - const alerts = [ - { - [AlertsField.name]: ['one'], - [AlertsField.reason]: ['two'], - }, - { - [AlertsField.name]: ['three'], - [AlertsField.reason]: ['four'], - }, - ] as unknown as EcsFieldsResponse[]; +const alerts = [ + { + [ALERT_RULE_NAME]: ['one'], + [ALERT_REASON]: ['two'], + [ALERT_STATUS]: ['active'], + [ALERT_FLAPPING]: [true], + }, + { + [ALERT_RULE_NAME]: ['three'], + [ALERT_REASON]: ['four'], + [ALERT_STATUS]: ['active'], + [ALERT_FLAPPING]: [false], + }, + { + [ALERT_RULE_NAME]: ['five'], + [ALERT_REASON]: ['six'], + [ALERT_STATUS]: ['recovered'], + [ALERT_FLAPPING]: [true], + }, + { + [ALERT_RULE_NAME]: ['seven'], + [ALERT_REASON]: ['eight'], + [ALERT_STATUS]: ['recovered'], + [ALERT_FLAPPING]: [false], + }, +] as unknown as EcsFieldsResponse[]; +describe('AlertsTable', () => { const fetchAlertsData = { activePage: 0, alerts, @@ -164,6 +184,24 @@ describe('AlertsTable', () => { expect(getByTestId('toolbar-alerts-count')).not.toBe(null); }); + it('should show alert status', () => { + const props = { + ...tableProps, + showAlertStatusWithFlapping: true, + pageSize: alerts.length, + alertsTableConfiguration: { + ...alertsTableConfiguration, + getRenderCellValue: undefined, + }, + }; + + const { queryAllByTestId } = render(); + expect(queryAllByTestId('alertLifecycleStatusBadge')[0].textContent).toEqual('Flapping'); + expect(queryAllByTestId('alertLifecycleStatusBadge')[1].textContent).toEqual('Active'); + expect(queryAllByTestId('alertLifecycleStatusBadge')[2].textContent).toEqual('Recovered'); + expect(queryAllByTestId('alertLifecycleStatusBadge')[3].textContent).toEqual('Recovered'); + }); + describe('leading control columns', () => { it('should return at least the flyout action control', async () => { const wrapper = render(); @@ -206,6 +244,7 @@ describe('AlertsTable', () => { onClick={() => {}} size="s" data-test-subj="testActionColumn" + aria-label="testActionLabel" /> @@ -215,6 +254,7 @@ describe('AlertsTable', () => { onClick={() => {}} size="s" data-test-subj="testActionColumn2" + aria-label="testActionLabel2" /> @@ -249,6 +289,7 @@ describe('AlertsTable', () => { onClick={() => {}} size="s" data-test-subj="testActionColumn" + aria-label="testActionLabel" /> @@ -258,6 +299,7 @@ describe('AlertsTable', () => { onClick={() => {}} size="s" data-test-subj="testActionColumn2" + aria-label="testActionLabel2" /> @@ -307,6 +349,7 @@ describe('AlertsTable', () => { onClick={() => {}} size="s" data-test-subj="testActionColumn" + aria-label="testActionLabel" /> @@ -332,8 +375,8 @@ describe('AlertsTable', () => { expect(within(selectedOptions[0]).queryByRole('checkbox')).not.toBeInTheDocument(); // second row, first column - expect(within(selectedOptions[4]).queryByLabelText('Loading')).not.toBeInTheDocument(); - expect(within(selectedOptions[4]).getByRole('checkbox')).toBeDefined(); + expect(within(selectedOptions[5]).queryByLabelText('Loading')).not.toBeInTheDocument(); + expect(within(selectedOptions[5]).getByRole('checkbox')).toBeDefined(); }); it('should show the row loader when callback triggered with false', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx index 22b3d4f87008..e1cc38b8c75c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { ALERT_UUID, ALERT_STATUS, ALERT_FLAPPING } from '@kbn/rule-data-utils'; +import { AlertStatus } from '@kbn/rule-data-utils'; import React, { useState, Suspense, lazy, useCallback, useMemo, useEffect } from 'react'; import { EuiDataGrid, @@ -23,6 +24,7 @@ import { ALERTS_TABLE_CONTROL_COLUMNS_ACTIONS_LABEL, ALERTS_TABLE_CONTROL_COLUMNS_VIEW_DETAILS_LABEL, } from './translations'; +import { AlertLifecycleStatusBadge } from '../../components/alert_lifecycle_status_badge'; import './alerts_table.scss'; import { getToolbarVisibility } from './toolbar'; @@ -36,6 +38,40 @@ const GridStyles: EuiDataGridStyle = { fontSize: 's', }; +const basicRenderCellValue = ({ + data, + columnId, +}: { + data: Array<{ field: string; value: string[] }>; + columnId: string; +}) => { + const value = data.find((d) => d.field === columnId)?.value ?? []; + if (Array.isArray(value)) { + return <>{value.length ? value.join() : '--'}; + } + return <>{value}; +}; + +const renderAlertLifecycleStatus = ({ + data, + columnId, +}: { + data: Array<{ field: string; value: string[] }>; + columnId: string; +}) => { + const alertStatus = data.find((d) => d.field === ALERT_STATUS)?.value ?? []; + if (Array.isArray(alertStatus) && alertStatus.length) { + const flapping = data.find((d) => d.field === ALERT_FLAPPING)?.value ?? []; + return ( + + ); + } + return basicRenderCellValue({ data, columnId }); +}; + const AlertsTable: React.FunctionComponent = (props: AlertsTableProps) => { const [rowClasses, setRowClasses] = useState({}); const alertsData = props.useFetchAlertsData(); @@ -86,6 +122,7 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab updatedAt, browserFields, onChangeVisibleColumns, + showAlertStatusWithFlapping = false, } = props; // TODO when every solution is using this table, we will be able to simplify it by just passing the alert index @@ -216,20 +253,6 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab const handleFlyoutClose = useCallback(() => setFlyoutAlertIndex(-1), [setFlyoutAlertIndex]); - const basicRenderCellValue = ({ - data, - columnId, - }: { - data: Array<{ field: string; value: string[] }>; - columnId: string; - }) => { - const value = data.find((d) => d.field === columnId)?.value ?? []; - if (Array.isArray(value)) { - return <>{value.length ? value.join() : '--'}; - } - return <>{value}; - }; - const renderCellValue = useCallback( () => props.alertsTableConfiguration?.getRenderCellValue @@ -249,6 +272,12 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab Object.entries(alert ?? {}).forEach(([key, value]) => { data.push({ field: key, value: value as string[] }); }); + if (showAlertStatusWithFlapping && _props.columnId === ALERT_STATUS) { + return renderAlertLifecycleStatus({ + ..._props, + data, + }); + } return renderCellValue({ ..._props, data, @@ -258,7 +287,14 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab } return null; }, - [alerts, isLoading, pagination.pageIndex, pagination.pageSize, renderCellValue] + [ + alerts, + isLoading, + pagination.pageIndex, + pagination.pageSize, + renderCellValue, + showAlertStatusWithFlapping, + ] ); return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx index a7f6c91a8f5d..9f5ec56efde3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx @@ -59,6 +59,7 @@ export interface AlertsTableStateProps { query: Pick; pageSize?: number; showExpandToDetails: boolean; + showAlertStatusWithFlapping?: boolean; } export interface AlertsTableStorage { @@ -96,6 +97,7 @@ const AlertsTableState = ({ query, pageSize, showExpandToDetails, + showAlertStatusWithFlapping, }: AlertsTableStateProps) => { const { cases } = useKibana<{ cases: CaseUi }>().services; @@ -246,6 +248,7 @@ const AlertsTableState = ({ id, leadingControlColumns: [], showExpandToDetails, + showAlertStatusWithFlapping, trailingControlColumns: [], useFetchAlertsData, visibleColumns, @@ -265,6 +268,7 @@ const AlertsTableState = ({ pagination.pageSize, id, showExpandToDetails, + showAlertStatusWithFlapping, useFetchAlertsData, visibleColumns, updatedAt, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx index 83924e7307d6..f3775e5de506 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx @@ -86,9 +86,9 @@ describe('rules', () => { const rules: AlertListItem[] = [ // active first - alertToListItem(fakeNow.getTime(), ruleType, 'second_rule', ruleSummary.alerts.second_rule), + alertToListItem(fakeNow.getTime(), 'second_rule', ruleSummary.alerts.second_rule), // ok second - alertToListItem(fakeNow.getTime(), ruleType, 'first_rule', ruleSummary.alerts.first_rule), + alertToListItem(fakeNow.getTime(), 'first_rule', ruleSummary.alerts.first_rule), ]; const wrapper = mountWithIntl( @@ -164,8 +164,8 @@ describe('rules', () => { }); expect(wrapper.find(RuleAlertList).prop('items')).toEqual([ - alertToListItem(fakeNow.getTime(), ruleType, 'us-central', alerts['us-central']), - alertToListItem(fakeNow.getTime(), ruleType, 'us-east', alerts['us-east']), + alertToListItem(fakeNow.getTime(), 'us-central', alerts['us-central']), + alertToListItem(fakeNow.getTime(), 'us-east', alerts['us-east']), ]); }); @@ -206,20 +206,14 @@ describe('rules', () => { }); expect(wrapper.find(RuleAlertList).prop('items')).toEqual([ - alertToListItem(fakeNow.getTime(), ruleType, 'us-west', ruleUsWest), - alertToListItem(fakeNow.getTime(), ruleType, 'us-east', ruleUsEast), + alertToListItem(fakeNow.getTime(), 'us-west', ruleUsWest), + alertToListItem(fakeNow.getTime(), 'us-east', ruleUsEast), ]); }); }); describe('alertToListItem', () => { it('handles active rules', () => { - const ruleType = mockRuleType({ - actionGroups: [ - { id: 'default', name: 'Default Action Group' }, - { id: 'testing', name: 'Test Action Group' }, - ], - }); const start = fake2MinutesAgo; const alert: AlertStatus = { status: 'Active', @@ -229,9 +223,10 @@ describe('alertToListItem', () => { flapping: false, }; - expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ + expect(alertToListItem(fakeNow.getTime(), 'id', alert)).toEqual({ alert: 'id', - status: { label: 'Active', actionGroup: 'Test Action Group', healthColor: 'primary' }, + status: 'Active', + flapping: false, start, sortPriority: 0, duration: fakeNow.getTime() - fake2MinutesAgo.getTime(), @@ -240,7 +235,6 @@ describe('alertToListItem', () => { }); it('handles active rules with no action group id', () => { - const ruleType = mockRuleType(); const start = fake2MinutesAgo; const alert: AlertStatus = { status: 'Active', @@ -249,9 +243,10 @@ describe('alertToListItem', () => { flapping: false, }; - expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ + expect(alertToListItem(fakeNow.getTime(), 'id', alert)).toEqual({ alert: 'id', - status: { label: 'Active', actionGroup: 'Default Action Group', healthColor: 'primary' }, + status: 'Active', + flapping: false, start, sortPriority: 0, duration: fakeNow.getTime() - fake2MinutesAgo.getTime(), @@ -260,7 +255,6 @@ describe('alertToListItem', () => { }); it('handles active muted rules', () => { - const ruleType = mockRuleType(); const start = fake2MinutesAgo; const alert: AlertStatus = { status: 'Active', @@ -270,9 +264,10 @@ describe('alertToListItem', () => { flapping: false, }; - expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ + expect(alertToListItem(fakeNow.getTime(), 'id', alert)).toEqual({ alert: 'id', - status: { label: 'Active', actionGroup: 'Default Action Group', healthColor: 'primary' }, + status: 'Active', + flapping: false, start, sortPriority: 0, duration: fakeNow.getTime() - fake2MinutesAgo.getTime(), @@ -281,7 +276,6 @@ describe('alertToListItem', () => { }); it('handles active rules with start date', () => { - const ruleType = mockRuleType(); const alert: AlertStatus = { status: 'Active', muted: false, @@ -289,9 +283,10 @@ describe('alertToListItem', () => { flapping: false, }; - expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ + expect(alertToListItem(fakeNow.getTime(), 'id', alert)).toEqual({ alert: 'id', - status: { label: 'Active', actionGroup: 'Default Action Group', healthColor: 'primary' }, + status: 'Active', + flapping: false, start: undefined, duration: 0, sortPriority: 0, @@ -300,16 +295,16 @@ describe('alertToListItem', () => { }); it('handles muted inactive rules', () => { - const ruleType = mockRuleType(); const alert: AlertStatus = { status: 'OK', muted: true, actionGroupId: 'default', flapping: false, }; - expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ + expect(alertToListItem(fakeNow.getTime(), 'id', alert)).toEqual({ alert: 'id', - status: { label: 'Recovered', healthColor: 'subdued' }, + status: 'OK', + flapping: false, start: undefined, duration: 0, sortPriority: 1, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx index d7540150c90b..81c5e0d7d7e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx @@ -8,7 +8,7 @@ import React, { lazy } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiTabbedContent } from '@elastic/eui'; -import { ActionGroup, AlertStatusValues } from '@kbn/alerting-plugin/common'; +import { AlertStatusValues } from '@kbn/alerting-plugin/common'; import { useKibana } from '../../../../common/lib/kibana'; import { Rule, RuleSummary, AlertStatus, RuleType } from '../../../../types'; import { @@ -68,7 +68,7 @@ export function RuleComponent({ const { ruleTypeRegistry, actionTypeRegistry } = useKibana().services; const alerts = Object.entries(ruleSummary.alerts) - .map(([alertId, alert]) => alertToListItem(durationEpoch, ruleType, alertId, alert)) + .map(([alertId, alert]) => alertToListItem(durationEpoch, alertId, alert)) .sort((leftAlert, rightAlert) => leftAlert.sortPriority - rightAlert.sortPriority); const onMuteAction = async (alert: AlertListItem) => { @@ -179,39 +179,13 @@ export function RuleComponent({ } export const RuleWithApi = withBulkRuleOperations(RuleComponent); -const ACTIVE_LABEL = i18n.translate( - 'xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active', - { defaultMessage: 'Active' } -); - -const INACTIVE_LABEL = i18n.translate( - 'xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.inactive', - { defaultMessage: 'Recovered' } -); - -function getActionGroupName(ruleType: RuleType, actionGroupId?: string): string | undefined { - actionGroupId = actionGroupId || ruleType.defaultActionGroupId; - const actionGroup = ruleType?.actionGroups?.find( - (group: ActionGroup) => group.id === actionGroupId - ); - return actionGroup?.name; -} - export function alertToListItem( durationEpoch: number, - ruleType: RuleType, alertId: string, alert: AlertStatus ): AlertListItem { const isMuted = !!alert?.muted; - const status = - alert?.status === 'Active' - ? { - label: ACTIVE_LABEL, - actionGroup: getActionGroupName(ruleType, alert?.actionGroupId), - healthColor: 'primary', - } - : { label: INACTIVE_LABEL, healthColor: 'subdued' }; + const status = alert.status; const start = alert?.activeStartDate ? new Date(alert.activeStartDate) : undefined; const duration = start ? durationEpoch - start.valueOf() : 0; const sortPriority = getSortPriorityByStatus(alert?.status); @@ -222,6 +196,7 @@ export function alertToListItem( duration, isMuted, sortPriority, + flapping: alert.flapping, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx index 5756edd36039..3b3142181d8e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.tsx @@ -8,13 +8,23 @@ import React, { useMemo, useCallback, useState } from 'react'; import moment, { Duration } from 'moment'; import { padStart, chunk } from 'lodash'; -import { EuiHealth, EuiBasicTable, EuiToolTip } from '@elastic/eui'; +import { EuiBasicTable, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { AlertStatus, ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; +import { AlertStatusValues } from '@kbn/alerting-plugin/common'; import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { Pagination } from '../../../../types'; -import { AlertListItemStatus, AlertListItem } from './types'; +import { AlertListItem } from './types'; import { AlertMutedSwitch } from './alert_muted_switch'; +import { AlertLifecycleStatusBadge } from '../../../components/alert_lifecycle_status_badge'; + +export const getConvertedAlertStatus = (status: AlertStatusValues): AlertStatus => { + if (status === 'Active') { + return ALERT_STATUS_ACTIVE; + } + return ALERT_STATUS_RECOVERED; +}; const durationAsString = (duration: Duration): string => { return [duration.hours(), duration.minutes(), duration.seconds()] @@ -49,13 +59,9 @@ const alertsTableColumns = ( defaultMessage: 'Status', }), width: '15%', - render: (value: AlertListItemStatus) => { - return ( - - {value.label} - {value.actionGroup ? ` (${value.actionGroup})` : ``} - - ); + render: (value: AlertStatusValues, alert: AlertListItem) => { + const convertedStatus = getConvertedAlertStatus(value); + return ; }, sortable: false, 'data-test-subj': 'alertsTableCell-status', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts index 0a4b706c8676..c469b61690c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts @@ -4,18 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -export interface AlertListItemStatus { - label: string; - healthColor: string; - actionGroup?: string; -} +import { AlertStatusValues } from '@kbn/alerting-plugin/common'; export interface AlertListItem { alert: string; - status: AlertListItemStatus; + status: AlertStatusValues; start?: Date; duration: number; isMuted: boolean; sortPriority: number; + flapping: boolean; } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 4b77db024454..109a88b6b5a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -81,7 +81,6 @@ import type { } from './application/sections/field_browser/types'; import { RulesListVisibleColumns } from './application/sections/rules_list/components/rules_list_column_selector'; import { TimelineItem } from './application/sections/alerts_table/bulk_actions/components/toolbar'; - // In Triggers and Actions we treat all `Alert`s as `SanitizedRule` // so the `Params` is a black-box of Record type SanitizedRule = Omit< @@ -475,6 +474,7 @@ export interface AlertsTableProps { id?: string; leadingControlColumns: EuiDataGridControlColumn[]; showExpandToDetails: boolean; + showAlertStatusWithFlapping?: boolean; trailingControlColumns: EuiDataGridControlColumn[]; useFetchAlertsData: () => FetchAlertData; visibleColumns: string[]; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 65a7d143127a..2d5c8a181521 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -10,7 +10,6 @@ import { v4 as uuidv4 } from 'uuid'; import { omit, mapValues, range, flatten } from 'lodash'; import moment from 'moment'; import { asyncForEach } from '@kbn/std'; -import { alwaysFiringAlertType } from '@kbn/alerting-fixture-plugin/server/plugin'; import { RuleNotifyWhen } from '@kbn/alerting-plugin/common'; import { FtrProviderContext } from '../../ftr_provider_context'; import { ObjectRemover } from '../../lib/object_remover'; @@ -747,31 +746,18 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // refresh to ensure Api call and UI are looking at freshest output await browser.refresh(); - // Get action groups - const { actionGroups } = alwaysFiringAlertType; - // If the tab exists, click on the alert list await pageObjects.triggersActionsUI.maybeClickOnAlertTab(); // Verify content await testSubjects.existOrFail('alertsList'); - const actionGroupNameFromId = (actionGroupId: string) => - actionGroups.find( - (actionGroup: { id: string; name: string }) => actionGroup.id === actionGroupId - )?.name; - const summary = await getAlertSummary(rule.id); const dateOnAllAlertsFromApiResponse: Record = mapValues( summary.alerts, (a) => a.activeStartDate ); - const actionGroupNameOnAllInstancesFromApiResponse = mapValues(summary.alerts, (a) => { - const name = actionGroupNameFromId(a.actionGroupId); - return name ? ` (${name})` : ''; - }); - log.debug( `API RESULT: ${Object.entries(dateOnAllAlertsFromApiResponse) .map(([id, date]) => `${id}: ${moment(date).utc()}`) @@ -782,21 +768,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(alertsList.map((a) => omit(a, 'duration'))).to.eql([ { alert: 'us-central', - status: `Active${actionGroupNameOnAllInstancesFromApiResponse['us-central']}`, + status: `Active`, start: moment(dateOnAllAlertsFromApiResponse['us-central']) .utc() .format('D MMM YYYY @ HH:mm:ss'), }, { alert: 'us-east', - status: `Active${actionGroupNameOnAllInstancesFromApiResponse['us-east']}`, + status: `Active`, start: moment(dateOnAllAlertsFromApiResponse['us-east']) .utc() .format('D MMM YYYY @ HH:mm:ss'), }, { alert: 'us-west', - status: `Active${actionGroupNameOnAllInstancesFromApiResponse['us-west']}`, + status: `Active`, start: moment(dateOnAllAlertsFromApiResponse['us-west']) .utc() .format('D MMM YYYY @ HH:mm:ss'), diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 0deb6cd41c43..43f96bcd0c42 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -109,7 +109,6 @@ "@kbn/apm-synthtrace-client", "@kbn/utils", "@kbn/journeys", - "@kbn/alerting-fixture-plugin", "@kbn/stdio-dev-helpers", "@kbn/alerting-api-integration-helpers", "@kbn/securitysolution-ecs",