[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 <xavier.mouligneau@elastic.co>
This commit is contained in:
Jiawei Wu 2023-01-30 15:20:14 -08:00 committed by GitHub
parent 4e66e01c73
commit 4a4138dc3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 383 additions and 132 deletions

View file

@ -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(<CaseViewAlerts caseData={caseData} />);
appMockRender.render(
<CaseViewAlerts
caseData={{
...caseData,
owner: OBSERVABILITY_OWNER,
}}
/>
);
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,
});
});
});

View file

@ -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) {

View file

@ -199,6 +199,7 @@ function InternalAlertsPage() {
featureIds={observabilityAlertFeatureIds}
query={esQuery}
showExpandToDetails={false}
showAlertStatusWithFlapping
pageSize={ALERTS_PER_PAGE}
/>
)}

View file

@ -209,6 +209,7 @@ export function OverviewPage() {
pageSize={ALERTS_PER_PAGE}
query={esQuery}
showExpandToDetails={false}
showAlertStatusWithFlapping
/>
</CasesContext>
</SectionContainer>

View file

@ -281,6 +281,7 @@ export function RuleDetailsPage() {
featureIds={featureIds}
query={esQuery}
showExpandToDetails={false}
showAlertStatusWithFlapping
/>
)}
</EuiFlexItem>

View file

@ -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 sest répétée",

View file

@ -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": "ルールがスヌーズされました",

View file

@ -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": "规则已暂停",

View file

@ -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<typeof AlertLifecycleStatusBadge>;
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> = (args: AlertLifecycleStatusBadgeProps) => {
return <AlertLifecycleStatusBadge {...args} />;
};
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,
};

View file

@ -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(
<AlertLifecycleStatusBadge alertStatus="active" flapping={false} />
);
expect(getByText('Active')).toBeTruthy();
});
it('should display the alert status correctly when active and flapping', () => {
const { getByText } = render(
<AlertLifecycleStatusBadge alertStatus="active" flapping={true} />
);
expect(getByText('Flapping')).toBeTruthy();
});
it('should display the alert status correctly when recovered and not flapping', () => {
const { getByText } = render(
<AlertLifecycleStatusBadge alertStatus="recovered" flapping={false} />
);
expect(getByText('Recovered')).toBeTruthy();
});
it('should prioritize recovered over flapping when recovered and flapping', () => {
const { getByText } = render(
<AlertLifecycleStatusBadge alertStatus="recovered" flapping={true} />
);
expect(getByText('Recovered')).toBeTruthy();
});
it('should display active alert status correctly is flapping is not defined', () => {
const { getByText } = render(<AlertLifecycleStatusBadge alertStatus="active" />);
expect(getByText('Active')).toBeTruthy();
});
it('should display recovered alert status correctly is flapping is not defined', () => {
const { getByText } = render(<AlertLifecycleStatusBadge alertStatus="recovered" />);
expect(getByText('Recovered')).toBeTruthy();
});
});

View file

@ -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 (
<EuiBadge data-test-subj="alertLifecycleStatusBadge" color={color} {...iconProps}>
{label}
</EuiBadge>
);
});
// eslint-disable-next-line import/no-default-export
export { AlertLifecycleStatusBadge as default };

View file

@ -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(<AlertsTableWithLocale {...props} />);
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(<AlertsTableWithLocale {...tableProps} />);
@ -206,6 +244,7 @@ describe('AlertsTable', () => {
onClick={() => {}}
size="s"
data-test-subj="testActionColumn"
aria-label="testActionLabel"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -215,6 +254,7 @@ describe('AlertsTable', () => {
onClick={() => {}}
size="s"
data-test-subj="testActionColumn2"
aria-label="testActionLabel2"
/>
</EuiFlexItem>
</>
@ -249,6 +289,7 @@ describe('AlertsTable', () => {
onClick={() => {}}
size="s"
data-test-subj="testActionColumn"
aria-label="testActionLabel"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -258,6 +299,7 @@ describe('AlertsTable', () => {
onClick={() => {}}
size="s"
data-test-subj="testActionColumn2"
aria-label="testActionLabel2"
/>
</EuiFlexItem>
</>
@ -307,6 +349,7 @@ describe('AlertsTable', () => {
onClick={() => {}}
size="s"
data-test-subj="testActionColumn"
aria-label="testActionLabel"
/>
</EuiFlexItem>
</>
@ -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 () => {

View file

@ -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 (
<AlertLifecycleStatusBadge
alertStatus={alertStatus.join() as AlertStatus}
flapping={flapping[0]}
/>
);
}
return basicRenderCellValue({ data, columnId });
};
const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTableProps) => {
const [rowClasses, setRowClasses] = useState<EuiDataGridStyle['rowClasses']>({});
const alertsData = props.useFetchAlertsData();
@ -86,6 +122,7 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (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<AlertsTableProps> = (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<AlertsTableProps> = (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<AlertsTableProps> = (props: AlertsTab
}
return null;
},
[alerts, isLoading, pagination.pageIndex, pagination.pageSize, renderCellValue]
[
alerts,
isLoading,
pagination.pageIndex,
pagination.pageSize,
renderCellValue,
showAlertStatusWithFlapping,
]
);
return (

View file

@ -59,6 +59,7 @@ export interface AlertsTableStateProps {
query: Pick<QueryDslQueryContainer, 'bool' | 'ids'>;
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,

View file

@ -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,

View file

@ -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<string>) => 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,
};
}

View file

@ -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 (
<EuiHealth color={value.healthColor} className="alertsList__health">
{value.label}
{value.actionGroup ? ` (${value.actionGroup})` : ``}
</EuiHealth>
);
render: (value: AlertStatusValues, alert: AlertListItem) => {
const convertedStatus = getConvertedAlertStatus(value);
return <AlertLifecycleStatusBadge alertStatus={convertedStatus} flapping={alert.flapping} />;
},
sortable: false,
'data-test-subj': 'alertsTableCell-status',

View file

@ -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;
}

View file

@ -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<RuleTypeParams>`
// so the `Params` is a black-box of Record<string, unknown>
type SanitizedRule<Params extends RuleTypeParams = never> = Omit<
@ -475,6 +474,7 @@ export interface AlertsTableProps {
id?: string;
leadingControlColumns: EuiDataGridControlColumn[];
showExpandToDetails: boolean;
showAlertStatusWithFlapping?: boolean;
trailingControlColumns: EuiDataGridControlColumn[];
useFetchAlertsData: () => FetchAlertData;
visibleColumns: string[];

View file

@ -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<string, string> = 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'),

View file

@ -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",