diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx index 3f236ea25a19b..b63300a83fcbc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/alert_muted_switch.tsx @@ -8,7 +8,7 @@ import React, { useState } from 'react'; import { EuiSwitch, EuiLoadingSpinner } from '@elastic/eui'; -import { AlertListItem } from './rule'; +import { AlertListItem } from './types'; interface ComponentOpts { alert: AlertListItem; 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 ac103f113a8f1..a782030093ac0 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 @@ -10,13 +10,21 @@ import uuid from 'uuid'; import { shallow } from 'enzyme'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { act } from 'react-dom/test-utils'; -import { RuleComponent, AlertListItem, alertToListItem } from './rule'; +import { RuleComponent, alertToListItem } from './rule'; +import { AlertListItem } from './types'; +import { RuleAlertList } from './rule_alert_list'; import { Rule, RuleSummary, AlertStatus, RuleType } from '../../../../types'; -import { EuiBasicTable } from '@elastic/eui'; import { ExecutionDurationChart } from '../../common/components/execution_duration_chart'; +import { RuleEventLogList } from './rule_event_log_list'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn(), +})); + +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; + const fakeNow = new Date('2020-02-09T23:15:41.941Z'); const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z'); @@ -33,6 +41,10 @@ beforeAll(() => { global.Date.now = jest.fn(() => fakeNow.getTime()); }); +beforeEach(() => { + (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => false); +}); + describe('rules', () => { it('render a list of rules', () => { const rule = mockRule(); @@ -59,19 +71,17 @@ describe('rules', () => { alertToListItem(fakeNow.getTime(), ruleType, 'first_rule', ruleSummary.alerts.first_rule), ]; - expect( - shallow( - - ) - .find(EuiBasicTable) - .prop('items') - ).toEqual(rules); + const wrapper = shallow( + + ); + + expect(wrapper.find(RuleAlertList).prop('items')).toEqual(rules); }); it('render a hidden field with duration epoch', () => { @@ -120,7 +130,7 @@ describe('rules', () => { })} /> ) - .find(EuiBasicTable) + .find(RuleAlertList) .prop('items') ).toEqual([ alertToListItem(fakeNow.getTime(), ruleType, 'us-central', alerts['us-central']), @@ -157,7 +167,7 @@ describe('rules', () => { })} /> ) - .find(EuiBasicTable) + .find(RuleAlertList) .prop('items') ).toEqual([ alertToListItem(fakeNow.getTime(), ruleType, 'us-west', ruleUsWest), @@ -379,6 +389,58 @@ describe('execution duration overview', () => { }); }); +describe('tabbed content', () => { + it('tabbed content renders when the event log experiment is on', async () => { + // Enable the event log experiment + (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => true); + + const rule = mockRule(); + const ruleType = mockRuleType(); + const ruleSummary = mockRuleSummary({ + alerts: { + first_rule: { + status: 'OK', + muted: false, + actionGroupId: 'default', + }, + second_rule: { + status: 'Active', + muted: false, + actionGroupId: 'action group id unknown', + }, + }, + }); + + const wrapper = shallow( + + ); + + const tabbedContent = wrapper.find('[data-test-subj="ruleDetailsTabbedContent"]').dive(); + + // Need to mock this function + (tabbedContent.instance() as any).focusTab = jest.fn(); + tabbedContent.update(); + + expect(tabbedContent.find(RuleEventLogList).exists()).toBeTruthy(); + expect(tabbedContent.find(RuleAlertList).exists()).toBeFalsy(); + + tabbedContent.find('[data-test-subj="ruleAlertListTab"]').simulate('click'); + + expect(tabbedContent.find(RuleEventLogList).exists()).toBeFalsy(); + expect(tabbedContent.find(RuleAlertList).exists()).toBeTruthy(); + + tabbedContent.find('[data-test-subj="eventLogListTab"]').simulate('click'); + expect(tabbedContent.find(RuleEventLogList).exists()).toBeTruthy(); + expect(tabbedContent.find(RuleAlertList).exists()).toBeFalsy(); + }); +}); + function mockRule(overloads: Partial = {}): Rule { return { id: uuid.v4(), 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 1a08f12c11743..c062b495c4d8b 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 @@ -6,23 +6,21 @@ */ import React, { useState } from 'react'; -import moment, { Duration } from 'moment'; import { i18n } from '@kbn/i18n'; import { - EuiBasicTable, EuiHealth, EuiSpacer, - EuiToolTip, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiPanel, EuiStat, EuiIconTip, + EuiTabbedContent, } from '@elastic/eui'; // @ts-ignore import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; -import { padStart, chunk } from 'lodash'; +import { chunk } from 'lodash'; import { ActionGroup, AlertExecutionStatusErrorReasons, @@ -35,7 +33,6 @@ import { } from '../../common/components/with_bulk_rule_api_operations'; import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import './rule.scss'; -import { AlertMutedSwitch } from './alert_muted_switch'; import { getHealthColor } from '../../rules_list/components/rule_status_filter'; import { rulesStatusesTranslationsMapping, @@ -46,6 +43,10 @@ import { shouldShowDurationWarning, } from '../../../lib/execution_duration_utils'; import { ExecutionDurationChart } from '../../common/components/execution_duration_chart'; +import { RuleAlertList } from './rule_alert_list'; +import { RuleEventLogList } from './rule_event_log_list'; +import { AlertListItem } from './types'; +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; type RuleProps = { rule: Rule; @@ -59,95 +60,22 @@ type RuleProps = { isLoadingChart?: boolean; } & Pick; -export const alertsTableColumns = ( - onMuteAction: (alert: AlertListItem) => Promise, - readOnly: boolean -) => [ - { - field: 'alert', - name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.Alert', { - defaultMessage: 'Alert', - }), - sortable: false, - truncateText: true, - width: '45%', - 'data-test-subj': 'alertsTableCell-alert', - render: (value: string) => { - return ( - - {value} - - ); - }, - }, - { - field: 'status', - name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status', { - defaultMessage: 'Status', - }), - width: '15%', - render: (value: AlertListItemStatus) => { - return ( - - {value.label} - {value.actionGroup ? ` (${value.actionGroup})` : ``} - - ); - }, - sortable: false, - 'data-test-subj': 'alertsTableCell-status', - }, - { - field: 'start', - width: '190px', - render: (value: Date | undefined) => { - return value ? moment(value).format('D MMM YYYY @ HH:mm:ss') : ''; - }, - name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.start', { - defaultMessage: 'Start', - }), - sortable: false, - 'data-test-subj': 'alertsTableCell-start', - }, - { - field: 'duration', - render: (value: number) => { - return value ? durationAsString(moment.duration(value)) : ''; - }, - name: i18n.translate( - 'xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.duration', - { defaultMessage: 'Duration' } - ), - sortable: false, - width: '80px', - 'data-test-subj': 'alertsTableCell-duration', - }, +const EVENT_LOG_LIST_TAB = 'rule_event_log_list'; +const ALERT_LIST_TAB = 'rule_alert_list'; + +const eventLogTabText = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.rule.eventLogTabText', { - field: '', - align: RIGHT_ALIGNMENT, - width: '60px', - name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.mute', { - defaultMessage: 'Mute', - }), - render: (alert: AlertListItem) => { - return ( - await onMuteAction(alert)} - alert={alert} - /> - ); - }, - sortable: false, - 'data-test-subj': 'alertsTableCell-actions', - }, -]; + defaultMessage: 'Execution history', + } +); -function durationAsString(duration: Duration): string { - return [duration.hours(), duration.minutes(), duration.seconds()] - .map((value) => padStart(`${value}`, 2, '0')) - .join(':'); -} +const alertsTabText = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.rule.alertsTabText', + { + defaultMessage: 'Alerts', + } +); export function RuleComponent({ rule, @@ -171,7 +99,7 @@ export function RuleComponent({ .map(([alertId, alert]) => alertToListItem(durationEpoch, ruleType, alertId, alert)) .sort((leftAlert, rightAlert) => leftAlert.sortPriority - rightAlert.sortPriority); - const pageOfAlerts = getPage(alerts, pagination); + const pageOfAlerts = getPage(alerts, pagination); const onMuteAction = async (alert: AlertListItem) => { await (alert.isMuted @@ -192,6 +120,42 @@ export function RuleComponent({ ? ALERT_STATUS_LICENSE_ERROR : rulesStatusesTranslationsMapping[rule.executionStatus.status]; + const renderRuleAlertList = () => { + return ( + + ); + }; + + const tabs = [ + { + id: EVENT_LOG_LIST_TAB, + name: eventLogTabText, + 'data-test-subj': 'eventLogListTab', + content: , + }, + { + id: ALERT_LIST_TAB, + name: alertsTabText, + 'data-test-subj': 'ruleAlertListTab', + content: renderRuleAlertList(), + }, + ]; + + const renderTabs = () => { + const isEnabled = getIsExperimentalFeatureEnabled('rulesListDatagrid'); + if (isEnabled) { + return ; + } + return renderRuleAlertList(); + }; + return ( <> @@ -277,50 +241,16 @@ export function RuleComponent({ name="alertsDurationEpoch" value={durationEpoch} /> - { - setPagination(changedPage); - }} - rowProps={() => ({ - 'data-test-subj': 'alert-row', - })} - cellProps={() => ({ - 'data-test-subj': 'cell', - })} - columns={alertsTableColumns(onMuteAction, readOnly)} - data-test-subj="alertsList" - tableLayout="fixed" - className="alertsList" - /> + {renderTabs()} ); } export const RuleWithApi = withBulkRuleOperations(RuleComponent); -function getPage(items: any[], pagination: Pagination) { +function getPage(items: T[], pagination: Pagination) { return chunk(items, pagination.size)[pagination.index] || []; } -interface AlertListItemStatus { - label: string; - healthColor: string; - actionGroup?: string; -} -export interface AlertListItem { - alert: string; - status: AlertListItemStatus; - start?: Date; - duration: number; - isMuted: boolean; - sortPriority: number; -} - const ACTIVE_LABEL = i18n.translate( 'xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active', { defaultMessage: 'Active' } 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 new file mode 100644 index 0000000000000..6074d0245bc0a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_alert_list.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, { useMemo, useCallback } from 'react'; +import moment, { Duration } from 'moment'; +import { padStart } from 'lodash'; +import { EuiHealth, EuiBasicTable, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { Pagination } from '../../../../types'; +import { AlertListItemStatus, AlertListItem } from './types'; +import { AlertMutedSwitch } from './alert_muted_switch'; + +const durationAsString = (duration: Duration): string => { + return [duration.hours(), duration.minutes(), duration.seconds()] + .map((value) => padStart(`${value}`, 2, '0')) + .join(':'); +}; + +const alertsTableColumns = ( + onMuteAction: (alert: AlertListItem) => Promise, + readOnly: boolean +) => [ + { + field: 'alert', + name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.Alert', { + defaultMessage: 'Alert', + }), + sortable: false, + truncateText: true, + width: '45%', + 'data-test-subj': 'alertsTableCell-alert', + render: (value: string) => { + return ( + + {value} + + ); + }, + }, + { + field: 'status', + name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.status', { + defaultMessage: 'Status', + }), + width: '15%', + render: (value: AlertListItemStatus) => { + return ( + + {value.label} + {value.actionGroup ? ` (${value.actionGroup})` : ``} + + ); + }, + sortable: false, + 'data-test-subj': 'alertsTableCell-status', + }, + { + field: 'start', + width: '190px', + render: (value: Date | undefined) => { + return value ? moment(value).format('D MMM YYYY @ HH:mm:ss') : ''; + }, + name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.start', { + defaultMessage: 'Start', + }), + sortable: false, + 'data-test-subj': 'alertsTableCell-start', + }, + { + field: 'duration', + render: (value: number) => { + return value ? durationAsString(moment.duration(value)) : ''; + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.duration', + { defaultMessage: 'Duration' } + ), + sortable: false, + width: '80px', + 'data-test-subj': 'alertsTableCell-duration', + }, + { + field: '', + align: RIGHT_ALIGNMENT, + width: '60px', + name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.alertsList.columns.mute', { + defaultMessage: 'Mute', + }), + render: (alert: AlertListItem) => { + return ( + await onMuteAction(alert)} + alert={alert} + /> + ); + }, + sortable: false, + 'data-test-subj': 'alertsTableCell-actions', + }, +]; + +interface RuleAlertListProps { + items: AlertListItem[]; + pagination: Pagination; + paginationItemCount: number; + readOnly: boolean; + onMuteAction: (alert: AlertListItem) => Promise; + onPaginate: (pagination: Pagination) => void; +} + +const getRowProps = () => ({ + 'data-test-subj': 'alert-row', +}); + +const getCellProps = () => ({ + 'data-test-subj': 'cell', +}); + +export const RuleAlertList = (props: RuleAlertListProps) => { + const { items, pagination, paginationItemCount, readOnly, onMuteAction, onPaginate } = props; + + const paginationOptions = useMemo(() => { + return { + pageIndex: pagination.index, + pageSize: pagination.size, + totalItemCount: paginationItemCount, + }; + }, [pagination, paginationItemCount]); + + const onChange = useCallback( + ({ page: changedPage }: { page: Pagination }) => { + onPaginate(changedPage); + }, + [onPaginate] + ); + + return ( + + items={items} + pagination={paginationOptions} + onChange={onChange} + rowProps={getRowProps} + cellProps={getCellProps} + columns={alertsTableColumns(onMuteAction, readOnly)} + data-test-subj="alertsList" + tableLayout="fixed" + className="alertsList" + /> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.tsx new file mode 100644 index 0000000000000..20cc86ca6e2c1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.tsx @@ -0,0 +1,14 @@ +/* + * 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'; + +export const RuleEventLogList = () => { + return
To be implemented
; +}; + +RuleEventLogList.displayName = 'RuleEventLogList'; 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 new file mode 100644 index 0000000000000..0a4b706c8676e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/types.ts @@ -0,0 +1,21 @@ +/* + * 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 AlertListItemStatus { + label: string; + healthColor: string; + actionGroup?: string; +} + +export interface AlertListItem { + alert: string; + status: AlertListItemStatus; + start?: Date; + duration: number; + isMuted: boolean; + sortPriority: number; +} 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 b280e9a3e78c5..918e0f5b608c9 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 @@ -550,6 +550,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // 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'); @@ -648,6 +651,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // refresh to see rule await browser.refresh(); + // If the tab exists, click on the alert list + await pageObjects.triggersActionsUI.maybeClickOnAlertTab(); + const alertsList: any[] = await pageObjects.ruleDetailsUI.getAlertsList(); expect(alertsList.filter((a) => a.alert === 'eu/east')).to.eql([ { @@ -660,6 +666,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('allows the user to mute a specific alert', async () => { + // If the tab exists, click on the alert list + await pageObjects.triggersActionsUI.maybeClickOnAlertTab(); + // Verify content await testSubjects.existOrFail('alertsList'); @@ -674,6 +683,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('allows the user to unmute a specific alert', async () => { + // If the tab exists, click on the alert list + await pageObjects.triggersActionsUI.maybeClickOnAlertTab(); + // Verify content await testSubjects.existOrFail('alertsList'); @@ -694,6 +706,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('allows the user unmute an inactive alert', async () => { + // If the tab exists, click on the alert list + await pageObjects.triggersActionsUI.maybeClickOnAlertTab(); + log.debug(`Ensuring eu/east is muted`); await pageObjects.ruleDetailsUI.ensureAlertMuteState('eu/east', true); @@ -747,6 +762,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const PAGE_SIZE = 10; it('renders the first page', async () => { + // If the tab exists, click on the alert list + await pageObjects.triggersActionsUI.maybeClickOnAlertTab(); + // Verify content await testSubjects.existOrFail('alertsList'); @@ -760,6 +778,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('navigates to the next page', async () => { + // If the tab exists, click on the alert list + await pageObjects.triggersActionsUI.maybeClickOnAlertTab(); + // Verify content await testSubjects.existOrFail('alertsList'); diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index c715800abd37e..c50ed9e3269c4 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -141,6 +141,12 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) await this.searchAlerts(name); await find.clickDisplayedByCssSelector(`[data-test-subj="rulesList"] [title="${name}"]`); }, + async maybeClickOnAlertTab() { + if (await testSubjects.exists('ruleDetailsTabbedContent')) { + const alertTab = await testSubjects.find('ruleAlertListTab'); + await alertTab.click(); + } + }, async changeTabs(tab: 'rulesTab' | 'connectorsTab') { await testSubjects.click(tab); },