diff --git a/x-pack/plugins/alerting/common/execution_log_types.ts b/x-pack/plugins/alerting/common/execution_log_types.ts index e5047aae9f154..df74c46ad9b43 100644 --- a/x-pack/plugins/alerting/common/execution_log_types.ts +++ b/x-pack/plugins/alerting/common/execution_log_types.ts @@ -36,7 +36,21 @@ export interface IExecutionLog { timed_out: boolean; } +export interface IExecutionErrors { + id: string; + timestamp: string; + type: string; + message: string; +} + +export interface IExecutionErrorsResult { + totalErrors: number; + errors: IExecutionErrors[]; +} + export interface IExecutionLogResult { total: number; data: IExecutionLog[]; } + +export type IExecutionLogWithErrorsResult = IExecutionLogResult & IExecutionErrorsResult; diff --git a/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts b/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts index a169640c4fc83..ef5b931310f6a 100644 --- a/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts +++ b/x-pack/plugins/alerting/server/lib/format_execution_log_errors.ts @@ -7,6 +7,7 @@ import { get } from 'lodash'; import { QueryEventsBySavedObjectResult, IValidatedEvent } from '../../../event_log/server'; +import { IExecutionErrors, IExecutionErrorsResult } from '../../common'; const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; const TIMESTAMP_FIELD = '@timestamp'; @@ -14,18 +15,6 @@ const PROVIDER_FIELD = 'event.provider'; const MESSAGE_FIELD = 'message'; const ERROR_MESSAGE_FIELD = 'error.message'; -export interface IExecutionErrors { - id: string; - timestamp: string; - type: string; - message: string; -} - -export interface IExecutionErrorsResult { - totalErrors: number; - errors: IExecutionErrors[]; -} - export const EMPTY_EXECUTION_ERRORS_RESULT = { totalErrors: 0, errors: [], diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts index f304c7be86131..2394e159a9f19 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -11,7 +11,7 @@ import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { rulesClientMock } from '../rules_client.mock'; -import { IExecutionLogWithErrorsResult } from '../rules_client'; +import { IExecutionLogWithErrorsResult } from '../../common'; const rulesClient = rulesClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 901d7102f40c6..5377ec562847f 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -89,13 +89,10 @@ import { formatExecutionLogResult, getExecutionLogAggregation, } from '../lib/get_execution_log_aggregation'; -import { IExecutionLogResult } from '../../common'; +import { IExecutionLogWithErrorsResult } from '../../common'; import { validateSnoozeDate } from '../lib/validate_snooze_date'; import { RuleMutedError } from '../lib/errors/rule_muted'; -import { - formatExecutionErrorsResult, - IExecutionErrorsResult, -} from '../lib/format_execution_log_errors'; +import { formatExecutionErrorsResult } from '../lib/format_execution_log_errors'; export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; @@ -263,7 +260,6 @@ export interface GetExecutionLogByIdParams { sort: estypes.Sort; } -export type IExecutionLogWithErrorsResult = IExecutionLogResult & IExecutionErrorsResult; interface ScheduleRuleOptions { id: string; consumer: string; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1f0117fd7096c..ff3312f5c1737 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -27777,13 +27777,6 @@ "xpack.triggersActionsUI.sections.ruleDetails.actionWithBrokenConnectorWarningBannerEditText": "ルールを編集", "xpack.triggersActionsUI.sections.ruleDetails.actionWithBrokenConnectorWarningBannerTitle": "このルールに関連付けられたコネクターの1つで問題が発生しています。", "xpack.triggersActionsUI.sections.ruleDetails.ruleDetailsTitle": "{ruleName}", - "xpack.triggersActionsUI.sections.ruleDetails.alertInstances.disabledRule": "このルールは無効になっていて再表示できません。", - "xpack.triggersActionsUI.sections.ruleDetails.alertInstances.disabledRuleTitle": "無効なルール", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.enableLoadingTitle": "有効にする", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.enableTitle": "有効にする", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.muteLoadingTitle": "ミュート", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.muteTitle": "ミュート", - "xpack.triggersActionsUI.sections.ruleDetails.dismissButtonTitle": "閉じる", "xpack.triggersActionsUI.sections.ruleDetails.editRuleButtonLabel": "編集", "xpack.triggersActionsUI.sections.ruleDetails.manageLicensePlanBannerLinkTitle": "ライセンスの管理", "xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun": "ルール", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2e661138cbcac..3f3e1f07664f2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -27806,13 +27806,6 @@ "xpack.triggersActionsUI.sections.ruleDetails.actionWithBrokenConnectorWarningBannerEditText": "编辑规则", "xpack.triggersActionsUI.sections.ruleDetails.actionWithBrokenConnectorWarningBannerTitle": "与此规则关联的连接器之一出现问题。", "xpack.triggersActionsUI.sections.ruleDetails.ruleDetailsTitle": "{ruleName}", - "xpack.triggersActionsUI.sections.ruleDetails.alertInstances.disabledRule": "此规则已禁用,无法显示。", - "xpack.triggersActionsUI.sections.ruleDetails.alertInstances.disabledRuleTitle": "已禁用规则", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.enableLoadingTitle": "启用", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.enableTitle": "启用", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.muteLoadingTitle": "静音", - "xpack.triggersActionsUI.sections.ruleDetails.collapsedItemActons.muteTitle": "静音", - "xpack.triggersActionsUI.sections.ruleDetails.dismissButtonTitle": "关闭", "xpack.triggersActionsUI.sections.ruleDetails.editRuleButtonLabel": "编辑", "xpack.triggersActionsUI.sections.ruleDetails.manageLicensePlanBannerLinkTitle": "管理许可证", "xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun": "规则", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.ts index 2dceac6dfd7d9..bb631b32328f4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/load_execution_log_aggregations.ts @@ -12,9 +12,9 @@ import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey' import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; import { - IExecutionLogResult, IExecutionLog, ExecutionLogSortFields, + IExecutionLogWithErrorsResult, } from '../../../../../alerting/common'; import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; @@ -36,9 +36,12 @@ const getRenamedLog = (data: IExecutionLog) => { }; }; -const rewriteBodyRes: RewriteRequestCase = ({ data, total }: any) => ({ +const rewriteBodyRes: RewriteRequestCase = ({ + data, + ...rest +}: any) => ({ data: data.map((log: IExecutionLog) => getRenamedLog(log)), - total, + ...rest, }); const getFilter = (filter: string[] | undefined) => { @@ -77,7 +80,7 @@ export const loadExecutionLogAggregations = async ({ }: LoadExecutionLogAggregationsProps & { http: HttpSetup }) => { const sortField: any[] = sort; - const result = await http.get>( + const result = await http.get>( `${INTERNAL_BASE_ALERTING_API_PATH}/rule/${id}/_execution_log`, { query: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx index a9c9dfa72279c..3398df07ce219 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_rule_api_operations.tsx @@ -35,8 +35,10 @@ import { resolveRule, loadExecutionLogAggregations, LoadExecutionLogAggregationsProps, + snoozeRule, + unsnoozeRule, } from '../../../lib/rule_api'; -import { IExecutionLogResult } from '../../../../../../alerting/common'; +import { IExecutionLogWithErrorsResult } from '../../../../../../alerting/common'; import { useKibana } from '../../../../common/lib/kibana'; export interface ComponentOpts { @@ -64,9 +66,11 @@ export interface ComponentOpts { loadRuleTypes: () => Promise; loadExecutionLogAggregations: ( props: LoadExecutionLogAggregationsProps - ) => Promise; + ) => Promise; getHealth: () => Promise; resolveRule: (id: Rule['id']) => Promise; + snoozeRule: (rule: Rule, snoozeEndTime: string | -1) => Promise; + unsnoozeRule: (rule: Rule) => Promise; } export type PropsWithOptionalApiHandlers = Omit & Partial; @@ -145,6 +149,12 @@ export function withBulkRuleOperations( } resolveRule={async (ruleId: Rule['id']) => resolveRule({ http, ruleId })} getHealth={async () => alertingFrameworkHealth({ http })} + snoozeRule={async (rule: Rule, snoozeEndTime: string | -1) => { + return await snoozeRule({ http, id: rule.id, snoozeEndTime }); + }} + unsnoozeRule={async (rule: Rule) => { + return await unsnoozeRule({ http, id: rule.id }); + }} /> ); }; 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 c3eb699cc0c90..dca16e5acbf1b 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 @@ -12,14 +12,16 @@ import { EuiSpacer, EuiFlexGroup, EuiFlexItem, - EuiHorizontalRule, EuiPanel, EuiStat, EuiIconTip, EuiTabbedContent, + EuiText, } from '@elastic/eui'; // @ts-ignore import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; +import { FormattedMessage } from '@kbn/i18n-react'; +import moment from 'moment'; import { ActionGroup, AlertExecutionStatusErrorReasons, @@ -47,6 +49,7 @@ import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experime import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props'; const RuleEventLogListWithApi = lazy(() => import('./rule_event_log_list')); +const RuleErrorLogWithApi = lazy(() => import('./rule_error_log')); const RuleAlertList = lazy(() => import('./rule_alert_list')); @@ -56,6 +59,7 @@ type RuleProps = { readOnly: boolean; ruleSummary: RuleSummary; requestRefresh: () => Promise; + refreshToken?: number; numberOfExecutions: number; onChangeDuration: (length: number) => void; durationEpoch?: number; @@ -64,6 +68,7 @@ type RuleProps = { const EVENT_LOG_LIST_TAB = 'rule_event_log_list'; const ALERT_LIST_TAB = 'rule_alert_list'; +const EVENT_ERROR_LOG_TAB = 'rule_error_log_list'; export function RuleComponent({ rule, @@ -73,6 +78,7 @@ export function RuleComponent({ muteAlertInstance, unmuteAlertInstance, requestRefresh, + refreshToken, numberOfExecutions, onChangeDuration, durationEpoch = Date.now(), @@ -116,10 +122,13 @@ export function RuleComponent({ { id: EVENT_LOG_LIST_TAB, name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.rule.eventLogTabText', { - defaultMessage: 'Execution History', + defaultMessage: 'Execution history', }), 'data-test-subj': 'eventLogListTab', - content: suspendedComponentWithProps(RuleEventLogListWithApi, 'xl')({ rule }), + content: suspendedComponentWithProps( + RuleEventLogListWithApi, + 'xl' + )({ requestRefresh, rule, refreshToken }), }, { id: ALERT_LIST_TAB, @@ -129,6 +138,17 @@ export function RuleComponent({ 'data-test-subj': 'ruleAlertListTab', content: renderRuleAlertList(), }, + { + id: EVENT_ERROR_LOG_TAB, + name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.rule.errorLogTabText', { + defaultMessage: 'Error log', + }), + 'data-test-subj': 'errorLogTab', + content: suspendedComponentWithProps( + RuleErrorLogWithApi, + 'xl' + )({ requestRefresh, rule, refreshToken }), + }, ]; const renderTabs = () => { @@ -141,29 +161,51 @@ export function RuleComponent({ return ( <> - - + + - {statusMessage} - - } - description={i18n.translate( - 'xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription', - { - defaultMessage: `Last response`, - } - )} - /> + titleSize="xs" + title={ + + {statusMessage} + + } + description={i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription', + { + defaultMessage: `Last response`, + } + )} + /> + + +

+ + + + + {moment(rule.executionStatus.lastExecutionDate).fromNow()} + +

+
+
@@ -217,6 +259,7 @@ export function RuleComponent({ /> + ({ @@ -64,6 +59,8 @@ const mockRuleApis = { disableRule: jest.fn(), requestRefresh: jest.fn(), refreshToken: Date.now(), + snoozeRule: jest.fn(), + unsnoozeRule: jest.fn(), }; const authorizedConsumers = { @@ -103,30 +100,29 @@ describe('rule_details', () => { ).toBeTruthy(); }); - it('renders the rule error banner with error message, when rule status is an error', () => { + it('renders the rule error banner with error message, when rule has a license error', () => { const rule = mockRule({ + enabled: true, executionStatus: { status: 'error', lastExecutionDate: new Date('2020-08-20T19:23:38Z'), error: { - reason: AlertExecutionStatusErrorReasons.Unknown, + reason: AlertExecutionStatusErrorReasons.License, message: 'test', }, }, }); - expect( - shallow( - - ).containsMatchingElement( - - {'test'} - - ) - ).toBeTruthy(); + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="ruleErrorBanner"]').first().text()).toMatchInlineSnapshot( + `" Cannot run rule, test "` + ); }); it('renders the rule warning banner with warning message, when rule status is a warning', () => { const rule = mockRule({ + enabled: true, executionStatus: { status: 'warning', lastExecutionDate: new Date('2020-08-20T19:23:38Z'), @@ -136,15 +132,12 @@ describe('rule_details', () => { }, }, }); + const wrapper = shallow( + + ); expect( - shallow( - - ).containsMatchingElement( - - {'warning message'} - - ) - ).toBeTruthy(); + wrapper.find('[data-test-subj="ruleWarningBanner"]').first().text() + ).toMatchInlineSnapshot(`" Action limit exceeded warning message"`); }); it('displays a toast message when interval is less than configured minimum', async () => { @@ -190,7 +183,7 @@ describe('rule_details', () => { ]; expect( - shallow( + mountWithIntl( { }, ]; - const details = shallow( + const details = mountWithIntl( ); @@ -302,63 +295,71 @@ describe('rule_details', () => { }); }); -describe('disable button', () => { - it('should render a disable button when rule is enabled', () => { +describe('disable/enable functionality', () => { + it('should show that the rule is enabled', () => { const rule = mockRule({ enabled: true, }); - const enableButton = shallow( + const wrapper = mountWithIntl( - ) - .find(EuiSwitch) - .find('[name="enable"]') - .first(); + ); + const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); - expect(enableButton.props()).toMatchObject({ - checked: true, - disabled: false, - }); + expect(actionsElem.text()).toEqual('Enabled'); }); - it('should render a enable button and empty state when rule is disabled', async () => { + it('should show that the rule is disabled', async () => { const rule = mockRule({ enabled: false, }); const wrapper = mountWithIntl( ); + const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); + + expect(actionsElem.text()).toEqual('Disabled'); + }); + + it('should disable the rule when picking disable in the dropdown', async () => { + const rule = mockRule({ + enabled: true, + }); + const disableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); await act(async () => { await nextTick(); wrapper.update(); }); - const enableButton = wrapper.find(EuiSwitch).find('[name="enable"]').first(); - const disabledEmptyPrompt = wrapper.find('[data-test-subj="disabledEmptyPrompt"]'); - const disabledEmptyPromptAction = wrapper.find('[data-test-subj="disabledEmptyPromptAction"]'); - - expect(enableButton.props()).toMatchObject({ - checked: false, - disabled: false, - }); - expect(disabledEmptyPrompt.exists()).toBeTruthy(); - expect(disabledEmptyPromptAction.exists()).toBeTruthy(); - - disabledEmptyPromptAction.first().simulate('click'); await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); await nextTick(); - wrapper.update(); }); - expect(mockRuleApis.enableRule).toHaveBeenCalledTimes(1); + expect(disableRule).toHaveBeenCalledTimes(1); }); - it('should disable the rule when rule is enabled and button is clicked', () => { + it('if rule is already disable should do nothing when picking disable in the dropdown', async () => { const rule = mockRule({ - enabled: true, + enabled: false, }); const disableRule = jest.fn(); - const enableButton = shallow( + const wrapper = mountWithIntl( { {...mockRuleApis} disableRule={disableRule} /> - ) - .find(EuiSwitch) - .find('[name="enable"]') + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); + await nextTick(); + }); - enableButton.simulate('click'); - const handler = enableButton.prop('onChange'); - expect(typeof handler).toEqual('function'); expect(disableRule).toHaveBeenCalledTimes(0); - handler!({} as React.FormEvent); - expect(disableRule).toHaveBeenCalledTimes(1); }); - it('should enable the rule when rule is disabled and button is clicked', () => { + it('should enable the rule when picking enable in the dropdown', async () => { const rule = mockRule({ enabled: false, }); const enableRule = jest.fn(); - const enableButton = shallow( + const wrapper = mountWithIntl( { {...mockRuleApis} enableRule={enableRule} /> - ) - .find(EuiSwitch) - .find('[name="enable"]') + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(0).simulate('click'); + await nextTick(); + }); - enableButton.simulate('click'); - const handler = enableButton.prop('onChange'); - expect(typeof handler).toEqual('function'); - expect(enableRule).toHaveBeenCalledTimes(0); - handler!({} as React.FormEvent); expect(enableRule).toHaveBeenCalledTimes(1); }); - it('should reset error banner dismissal after re-enabling the rule', async () => { + it('if rule is already enable should do nothing when picking enable in the dropdown', async () => { const rule = mockRule({ enabled: true, - executionStatus: { - status: 'error', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: AlertExecutionStatusErrorReasons.Execute, - message: 'Fail', - }, - }, }); - - const disableRule = jest.fn(); const enableRule = jest.fn(); const wrapper = mountWithIntl( { ruleType={ruleType} actionTypes={[]} {...mockRuleApis} - disableRule={disableRule} enableRule={enableRule} /> ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); await act(async () => { await nextTick(); wrapper.update(); }); - // Dismiss the error banner - await act(async () => { - wrapper.find('[data-test-subj="dismiss-execution-error"]').first().simulate('click'); - await nextTick(); - }); - - // Disable the rule - await act(async () => { - wrapper.find('[data-test-subj="enableSwitch"] .euiSwitch__button').first().simulate('click'); - await nextTick(); - }); - expect(disableRule).toHaveBeenCalled(); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - // Enable the rule await act(async () => { - wrapper.find('[data-test-subj="enableSwitch"] .euiSwitch__button').first().simulate('click'); + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(0).simulate('click'); await nextTick(); }); - expect(enableRule).toHaveBeenCalled(); - // Ensure error banner is back - expect(wrapper.find('[data-test-subj="dismiss-execution-error"]').length).toBeGreaterThan(0); + expect(enableRule).toHaveBeenCalledTimes(0); }); it('should show the loading spinner when the rule enabled switch was clicked and the server responded with some delay', async () => { const rule = mockRule({ enabled: true, - executionStatus: { - status: 'error', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: AlertExecutionStatusErrorReasons.Execute, - message: 'Fail', - }, - }, }); const disableRule = jest.fn(async () => { @@ -493,139 +476,53 @@ describe('disable button', () => { /> ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + await act(async () => { await nextTick(); wrapper.update(); }); - // Dismiss the error banner await act(async () => { - wrapper.find('[data-test-subj="dismiss-execution-error"]').first().simulate('click'); - await nextTick(); + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); }); - // Disable the rule - await act(async () => { - wrapper.find('[data-test-subj="enableSwitch"] .euiSwitch__button').first().simulate('click'); - await nextTick(); - }); - expect(disableRule).toHaveBeenCalled(); - await act(async () => { await nextTick(); wrapper.update(); }); - // Enable the rule await act(async () => { - expect(wrapper.find('[data-test-subj="enableSpinner"]').length).toBeGreaterThan(0); - await nextTick(); + expect(disableRule).toHaveBeenCalled(); + expect( + wrapper.find('[data-test-subj="statusDropdown"] .euiBadge__childButton .euiLoadingSpinner') + .length + ).toBeGreaterThan(0); }); }); }); -describe('mute button', () => { - it('should render an mute button when rule is enabled', () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - }); - const enableButton = shallow( - - ) - .find(EuiSwitch) - .find('[name="mute"]') - .first(); - expect(enableButton.props()).toMatchObject({ - checked: false, - disabled: false, - }); - }); - - it('should render an muted button when rule is muted', () => { +describe('snooze functionality', () => { + it('should render "Snooze Indefinitely" when rule is enabled and mute all', () => { const rule = mockRule({ enabled: true, muteAll: true, }); - const enableButton = shallow( - - ) - .find(EuiSwitch) - .find('[name="mute"]') - .first(); - expect(enableButton.props()).toMatchObject({ - checked: true, - disabled: false, - }); - }); - - it('should mute the rule when rule is unmuted and button is clicked', () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - }); - const muteRule = jest.fn(); - const enableButton = shallow( - - ) - .find(EuiSwitch) - .find('[name="mute"]') - .first(); - enableButton.simulate('click'); - const handler = enableButton.prop('onChange'); - expect(typeof handler).toEqual('function'); - expect(muteRule).toHaveBeenCalledTimes(0); - handler!({} as React.FormEvent); - expect(muteRule).toHaveBeenCalledTimes(1); - }); - - it('should unmute the rule when rule is muted and button is clicked', () => { - const rule = mockRule({ - enabled: true, - muteAll: true, - }); - const unmuteRule = jest.fn(); - const enableButton = shallow( - - ) - .find(EuiSwitch) - .find('[name="mute"]') - .first(); - enableButton.simulate('click'); - const handler = enableButton.prop('onChange'); - expect(typeof handler).toEqual('function'); - expect(unmuteRule).toHaveBeenCalledTimes(0); - handler!({} as React.FormEvent); - expect(unmuteRule).toHaveBeenCalledTimes(1); - }); - - it('should disabled mute button when rule is disabled', () => { - const rule = mockRule({ - enabled: false, - muteAll: false, - }); - const enableButton = shallow( + const wrapper = mountWithIntl( - ) - .find(EuiSwitch) - .find('[name="mute"]') + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') .first(); - expect(enableButton.props()).toMatchObject({ - checked: false, - disabled: true, - }); + expect(actionsElem.text()).toEqual('Snoozed'); + expect(wrapper.find('[data-test-subj="remainingSnoozeTime"]').first().text()).toEqual( + 'Indefinitely' + ); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index 736178cc5ab3e..8a0237bc8a69d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -16,15 +16,13 @@ import { EuiFlexItem, EuiBadge, EuiPageContentBody, - EuiSwitch, EuiCallOut, EuiSpacer, EuiButtonEmpty, EuiButton, - EuiLoadingSpinner, EuiIconTip, - EuiEmptyPrompt, - EuiPageTemplate, + EuiIcon, + EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; @@ -38,6 +36,7 @@ import { ActionType, ActionConnector, TriggersActionsUiConfig, + RuleTableItem, } from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, @@ -55,6 +54,7 @@ import { useKibana } from '../../../../common/lib/kibana'; import { ruleReducer } from '../../rule_form/rule_reducer'; import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; +import { RuleStatusDropdown } from '../../rules_list/components/rule_status_dropdown'; export type RuleDetailsProps = { rule: Rule; @@ -62,7 +62,7 @@ export type RuleDetailsProps = { actionTypes: ActionType[]; requestRefresh: () => Promise; refreshToken?: number; -} & Pick; +} & Pick; export const RuleDetails: React.FunctionComponent = ({ rule, @@ -70,8 +70,8 @@ export const RuleDetails: React.FunctionComponent = ({ actionTypes, disableRule, enableRule, - unmuteRule, - muteRule, + snoozeRule, + unsnoozeRule, requestRefresh, refreshToken, }) => { @@ -150,13 +150,7 @@ export const RuleDetails: React.FunctionComponent = ({ const ruleActions = rule.actions; const uniqueActions = Array.from(new Set(ruleActions.map((item: any) => item.actionTypeId))); - const [isEnabled, setIsEnabled] = useState(rule.enabled); - const [isEnabledUpdating, setIsEnabledUpdating] = useState(false); - const [isMutedUpdating, setIsMutedUpdating] = useState(false); - const [isMuted, setIsMuted] = useState(rule.muteAll); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); - const [dismissRuleErrors, setDismissRuleErrors] = useState(false); - const [dismissRuleWarning, setDismissRuleWarning] = useState(false); // Check whether interval is below configured minium useEffect(() => { @@ -269,6 +263,95 @@ export const RuleDetails: React.FunctionComponent = ({ values={{ ruleName: rule.name }} /> } + description={ + + + + + +

+ +

+
+
+ + await disableRule(rule)} + enableRule={async () => await enableRule(rule)} + snoozeRule={async (snoozeEndTime: string | -1) => + await snoozeRule(rule, snoozeEndTime) + } + unsnoozeRule={async () => await unsnoozeRule(rule)} + item={rule as RuleTableItem} + onRuleChanged={requestRefresh} + direction="row" + isEditable={hasEditButton} + /> + +
+
+ + + + +

+ +

+
+
+ + {ruleType.name} + +
+
+ + {uniqueActions && uniqueActions.length ? ( + + + + {' '} + {hasActionsWithBrokenConnector && ( + + )} + + + + + {uniqueActions.map((action, index) => ( + + + {actionTypesByTypeId[action].name ?? action} + + + ))} + + + + ) : null} + +
+ } rightSideItems={[ , = ({ /> - - - -

- -

-
- - {ruleType.name} -
- - {uniqueActions && uniqueActions.length ? ( - <> - - {' '} - {hasActionsWithBrokenConnector && ( - - )} - - - - - {uniqueActions.map((action, index) => ( - - - {actionTypesByTypeId[action].name ?? action} - - - ))} - - - ) : null} - - - - - - {isEnabledUpdating ? ( - - - - - - - - - - - - ) : ( - { - setIsEnabledUpdating(true); - if (isEnabled) { - setIsEnabled(false); - await disableRule(rule); - // Reset dismiss if previously clicked - setDismissRuleErrors(false); - } else { - setIsEnabled(true); - await enableRule(rule); - } - requestRefresh(); - setIsEnabledUpdating(false); - }} - label={ - - } - /> - )} - - - {isMutedUpdating ? ( - - - - - - - - - - - - ) : ( - { - setIsMutedUpdating(true); - if (isMuted) { - setIsMuted(false); - await unmuteRule(rule); - } else { - setIsMuted(true); - await muteRule(rule); - } - requestRefresh(); - setIsMutedUpdating(false); - }} - label={ - - } - /> - )} - - - -
- {rule.enabled && !dismissRuleErrors && rule.executionStatus.status === 'error' ? ( + {rule.enabled && + rule.executionStatus.error?.reason === AlertExecutionStatusErrorReasons.License ? ( - - + +

+ +   + {getRuleStatusErrorReasonText()},  {rule.executionStatus.error?.message} - - - - - setDismissRuleErrors(true)} - > - - - - {rule.executionStatus.error?.reason === - AlertExecutionStatusErrorReasons.License && ( - - - - - - )} - +   + + + +

) : null} - {rule.enabled && !dismissRuleWarning && rule.executionStatus.status === 'warning' ? ( + {rule.enabled && rule.executionStatus.status === 'warning' ? ( - +

+ +   + {getRuleStatusWarningReasonText()} +   {rule.executionStatus.warning?.message} - - - - - setDismissRuleWarning(true)} - > - - - - +

@@ -521,89 +426,41 @@ export const RuleDetails: React.FunctionComponent = ({ color="warning" data-test-subj="actionWithBrokenConnectorWarningBanner" size="s" - title={i18n.translate( - 'xpack.triggersActionsUI.sections.ruleDetails.actionWithBrokenConnectorWarningBannerTitle', - { - defaultMessage: - 'There is an issue with one of the connectors associated with this rule.', - } - )} > - {hasEditButton && ( - - - setEditFlyoutVisibility(true)} - > - - - - - )} +

+ +   + +   + {hasEditButton && ( + setEditFlyoutVisibility(true)} + > + + + )} +

)} - {rule.enabled ? ( - - ) : ( - <> - - - - - - } - body={ - <> -

- -

- - } - actions={[ - { - setIsEnabledUpdating(true); - setIsEnabled(true); - await enableRule(rule); - requestRefresh(); - setIsEnabledUpdating(false); - }} - > - Enable - , - ]} - /> -
- - )} +
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_error_log.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_error_log.test.tsx new file mode 100644 index 0000000000000..e37f9abf67de3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_error_log.test.tsx @@ -0,0 +1,312 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; +import { useKibana } from '../../../../common/lib/kibana'; + +import { EuiSuperDatePicker } from '@elastic/eui'; +import { Rule } from '../../../../types'; +import { RuleErrorLog } from './rule_error_log'; + +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../../../common/lib/kibana'); + +const mockLogResponse: any = { + total: 8, + data: [], + totalErrors: 12, + errors: [ + { + id: '66b9c04a-d5d3-4ed4-aa7c-94ddaca3ac1d', + timestamp: '2022-03-31T18:03:33.133Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '14fcfe1c-5403-458f-8549-fa8ef59cdea3', + timestamp: '2022-03-31T18:02:30.119Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: 'd53a401e-2a3a-4abe-8913-26e08a5039fd', + timestamp: '2022-03-31T18:01:27.112Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '9cfeae08-24b4-4d5c-b870-a303418f14d6', + timestamp: '2022-03-31T18:00:24.113Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '66b9c04a-d5d3-4ed4-aa7c-94ddaca3ac23', + timestamp: '2022-03-31T18:03:21.133Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '14fcfe1c-5403-458f-8549-fa8ef59cde18', + timestamp: '2022-03-31T18:02:18.119Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: 'd53a401e-2a3a-4abe-8913-26e08a503915', + timestamp: '2022-03-31T18:01:15.112Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '9cfeae08-24b4-4d5c-b870-a303418f1412', + timestamp: '2022-03-31T18:00:12.113Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '66b9c04a-d5d3-4ed4-aa7c-94ddaca3ac09', + timestamp: '2022-03-31T18:03:09.133Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '14fcfe1c-5403-458f-8549-fa8ef59cde06', + timestamp: '2022-03-31T18:02:06.119Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: 'd53a401e-2a3a-4abe-8913-26e08a503903', + timestamp: '2022-03-31T18:01:03.112Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + { + id: '9cfeae08-24b4-4d5c-b870-a303418f1400', + timestamp: '2022-03-31T18:00:00.113Z', + type: 'alerting', + message: + "rule execution failure: .es-query:d87fcbd0-b11b-11ec-88f6-293354dba871: 'Mine' - x_content_parse_exception: [parsing_exception] Reason: unknown query [match_allxxxx] did you mean [match_all]?", + }, + ], +}; + +const mockRule: Rule = { + id: '56b61397-13d7-43d0-a583-0fa8c704a46f', + enabled: true, + name: 'rule-56b61397-13d7-43d0-a583-0fa8c704a46f', + tags: [], + ruleTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, +}; + +const loadExecutionLogAggregationsMock = jest.fn(); + +describe('rule_error_log', () => { + beforeEach(() => { + jest.clearAllMocks(); + useKibanaMock().services.uiSettings.get = jest.fn().mockImplementation((value: string) => { + if (value === 'timepicker:quickRanges') { + return [ + { + from: 'now-15m', + to: 'now', + display: 'Last 15 minutes', + }, + ]; + } + }); + loadExecutionLogAggregationsMock.mockResolvedValue(mockLogResponse); + }); + + it('renders correctly', async () => { + const nowMock = jest.spyOn(Date, 'now').mockReturnValue(0); + const wrapper = mountWithIntl( + + ); + + // No data initially + expect(wrapper.find('.euiTableRow .euiTableCellContent__text').first().text()).toEqual( + 'No items found' + ); + + // Run the initial load fetch call + expect(loadExecutionLogAggregationsMock).toHaveBeenCalledTimes(1); + + expect(loadExecutionLogAggregationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + dateEnd: '1969-12-31T19:00:00-05:00', + dateStart: '1969-12-30T19:00:00-05:00', + id: '56b61397-13d7-43d0-a583-0fa8c704a46f', + page: 0, + perPage: 1, + sort: { timestamp: { order: 'desc' } }, + }) + ); + + // Loading + expect(wrapper.find(EuiSuperDatePicker).props().isLoading).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="tableHeaderCell_timestamp_0"]').exists()).toBeTruthy(); + + // Let the load resolve + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiSuperDatePicker).props().isLoading).toBeFalsy(); + expect(wrapper.find('.euiTableRow').length).toEqual(10); + + nowMock.mockRestore(); + }); + + it('can sort on timestamp columns', async () => { + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect( + wrapper.find('.euiTableRow').first().find('.euiTableCellContent').first().text() + ).toEqual('Mar 31, 2022 @ 14:03:33.133'); + + wrapper.find('button[data-test-subj="tableHeaderSortButton"]').first().simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect( + wrapper.find('.euiTableRow').first().find('.euiTableCellContent').first().text() + ).toEqual('Mar 31, 2022 @ 14:00:00.113'); + }); + + it('can paginate', async () => { + loadExecutionLogAggregationsMock.mockResolvedValue({ + ...mockLogResponse, + total: 100, + }); + + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('.euiPagination').exists()).toBeTruthy(); + + // Paginate to the next page + wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('.euiTableRow').length).toEqual(2); + }); + + it('can filter by start and end date', async () => { + const nowMock = jest.spyOn(Date, 'now').mockReturnValue(0); + + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + dateEnd: '1969-12-31T19:00:00-05:00', + dateStart: '1969-12-30T19:00:00-05:00', + id: '56b61397-13d7-43d0-a583-0fa8c704a46f', + page: 0, + perPage: 1, + sort: { timestamp: { order: 'desc' } }, + }) + ); + + wrapper + .find('[data-test-subj="superDatePickerToggleQuickMenuButton"] button') + .simulate('click'); + + wrapper + .find('[data-test-subj="superDatePickerCommonlyUsed_Last_15 minutes"] button') + .simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(loadExecutionLogAggregationsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + dateStart: '1969-12-31T18:45:00-05:00', + dateEnd: '1969-12-31T19:00:00-05:00', + id: '56b61397-13d7-43d0-a583-0fa8c704a46f', + page: 0, + perPage: 1, + sort: { timestamp: { order: 'desc' } }, + }) + ); + + nowMock.mockRestore(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_error_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_error_log.tsx new file mode 100644 index 0000000000000..e47c65ff4e3e9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_error_log.tsx @@ -0,0 +1,266 @@ +/* + * 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, { useCallback, useEffect, useState, useMemo, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; +import datemath from '@elastic/datemath'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiProgress, + EuiSpacer, + Pagination, + EuiSuperDatePicker, + OnTimeChangeProps, + EuiBasicTable, + EuiTableSortingType, + EuiBasicTableColumn, +} from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; + +import { LoadExecutionLogAggregationsProps } from '../../../lib/rule_api'; +import { Rule } from '../../../../types'; +import { IExecutionErrors } from '../../../../../../alerting/common'; +import { + ComponentOpts as RuleApis, + withBulkRuleOperations, +} from '../../common/components/with_bulk_rule_api_operations'; +import { RuleEventLogListCellRenderer } from './rule_event_log_list_cell_renderer'; + +const getParsedDate = (date: string) => { + if (date.includes('now')) { + return datemath.parse(date)?.format() || date; + } + return date; +}; + +const API_FAILED_MESSAGE = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.errorLogColumn.apiError', + { + defaultMessage: 'Failed to fetch error log', + } +); + +const updateButtonProps = { + iconOnly: true, + fill: false, +}; + +const sortErrorLog = ( + a: IExecutionErrors, + b: IExecutionErrors, + direction: 'desc' | 'asc' = 'desc' +) => + direction === 'desc' + ? new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + : new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + +export type RuleErrorLogProps = { + rule: Rule; + refreshToken?: number; + requestRefresh?: () => Promise; +} & Pick; + +export const RuleErrorLog = (props: RuleErrorLogProps) => { + const { rule, loadExecutionLogAggregations, refreshToken } = props; + + const { uiSettings, notifications } = useKibana().services; + + // Data grid states + const [logs, setLogs] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + totalItemCount: 0, + }); + const [sort, setSort] = useState['sort']>({ + field: 'timestamp', + direction: 'desc', + }); + + // Date related states + const [isLoading, setIsLoading] = useState(false); + const [dateStart, setDateStart] = useState('now-24h'); + const [dateEnd, setDateEnd] = useState('now'); + const [dateFormat] = useState(() => uiSettings?.get('dateFormat')); + const [commonlyUsedRanges] = useState(() => { + return ( + uiSettings + ?.get('timepicker:quickRanges') + ?.map(({ from, to, display }: { from: string; to: string; display: string }) => ({ + start: from, + end: to, + label: display, + })) || [] + ); + }); + + const isInitialized = useRef(false); + + const loadEventLogs = async () => { + setIsLoading(true); + try { + const result = await loadExecutionLogAggregations({ + id: rule.id, + sort: { + [sort?.field || 'timestamp']: { order: sort?.direction || 'desc' }, + } as unknown as LoadExecutionLogAggregationsProps['sort'], + dateStart: getParsedDate(dateStart), + dateEnd: getParsedDate(dateEnd), + page: 0, + perPage: 1, + }); + setLogs(result.errors); + setPagination({ + ...pagination, + totalItemCount: result.totalErrors, + }); + } catch (e) { + notifications.toasts.addDanger({ + title: API_FAILED_MESSAGE, + text: e.body.message, + }); + } + setIsLoading(false); + }; + + const onTimeChange = useCallback( + ({ start, end, isInvalid }: OnTimeChangeProps) => { + if (isInvalid) { + return; + } + setDateStart(start); + setDateEnd(end); + }, + [setDateStart, setDateEnd] + ); + + const onRefresh = () => { + loadEventLogs(); + }; + + const columns: Array> = useMemo( + () => [ + { + field: 'timestamp', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.errorLogColumn.timestamp', + { + defaultMessage: 'Timestamp', + } + ), + render: (date: string) => ( + + ), + sortable: true, + width: '250px', + }, + { + field: 'type', + name: i18n.translate('xpack.triggersActionsUI.sections.ruleDetails.errorLogColumn.type', { + defaultMessage: 'Type', + }), + sortable: false, + width: '100px', + }, + { + field: 'message', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.errorLogColumn.message', + { + defaultMessage: 'Message', + } + ), + sortable: false, + }, + ], + [dateFormat] + ); + + const logList = useMemo(() => { + const start = pagination.pageIndex * pagination.pageSize; + const logsSortDesc = logs.sort((a, b) => sortErrorLog(a, b, sort?.direction)); + return logsSortDesc.slice(start, start + pagination.pageSize); + }, [logs, pagination.pageIndex, pagination.pageSize, sort?.direction]); + + useEffect(() => { + loadEventLogs(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateStart, dateEnd]); + + useEffect(() => { + if (isInitialized.current) { + loadEventLogs(); + } + isInitialized.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshToken]); + + return ( +
+ + + + + + + + {isLoading && ( + + )} + { + if (changedPage) { + setPagination((prevPagination) => { + if ( + prevPagination.pageIndex !== changedPage.index || + prevPagination.pageSize !== changedPage.size + ) { + return { + ...prevPagination, + pageIndex: changedPage.index, + pageSize: changedPage.size, + }; + } + return prevPagination; + }); + } + if (changedSort) { + setSort((prevSort) => { + if (prevSort?.direction !== changedSort.direction) { + return changedSort; + } + return prevSort; + }); + } + }} + /> +
+ ); +}; + +export const RuleErrorLogWithApi = withBulkRuleOperations(RuleErrorLog); + +// eslint-disable-next-line import/no-default-export +export { RuleErrorLogWithApi as default }; 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 index 7b9ade9b5f192..cc3bb0b20a203 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import datemath from '@elastic/datemath'; import { @@ -233,6 +233,8 @@ const updateButtonProps = { export type RuleEventLogListProps = { rule: Rule; localStorageKey?: string; + refreshToken?: number; + requestRefresh?: () => Promise; } & Pick; export const RuleEventLogList = (props: RuleEventLogListProps) => { @@ -240,6 +242,7 @@ export const RuleEventLogList = (props: RuleEventLogListProps) => { rule, localStorageKey = RULE_EVENT_LOG_LIST_STORAGE_KEY, loadExecutionLogAggregations, + refreshToken, } = props; const { uiSettings, notifications } = useKibana().services; @@ -277,6 +280,8 @@ export const RuleEventLogList = (props: RuleEventLogListProps) => { ); }); + const isInitialized = useRef(false); + // Main cell renderer, renders durations, statuses, etc. const renderCell = ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { const { pageIndex, pageSize } = pagination; @@ -406,6 +411,14 @@ export const RuleEventLogList = (props: RuleEventLogListProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortingColumns, dateStart, dateEnd, filter, pagination.pageIndex, pagination.pageSize]); + useEffect(() => { + if (isInitialized.current) { + loadEventLogs(); + } + isInitialized.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshToken]); + useEffect(() => { localStorage.setItem(localStorageKey, JSON.stringify(visibleColumns)); }, [localStorageKey, visibleColumns]); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.tsx index 393cdc404db9e..3e11f987138d2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.tsx @@ -77,6 +77,7 @@ export const RuleRoute: React.FunctionComponent = ({ return ruleSummary ? ( Promise; unsnoozeRule: () => Promise; isEditable: boolean; + direction?: 'column' | 'row'; } export const RuleStatusDropdown: React.FunctionComponent = ({ @@ -53,6 +54,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ snoozeRule, unsnoozeRule, isEditable, + direction = 'column', }: ComponentOpts) => { const [isEnabled, setIsEnabled] = useState(item.enabled); const [isSnoozed, setIsSnoozed] = useState(isItemSnoozed(item)); @@ -70,6 +72,9 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ const onChangeEnabledStatus = useCallback( async (enable: boolean) => { + if (item.enabled === enable) { + return; + } setIsUpdating(true); if (enable) { await enableRule(); @@ -80,7 +85,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ onRuleChanged(); setIsUpdating(false); }, - [setIsUpdating, isEnabled, setIsEnabled, onRuleChanged, enableRule, disableRule] + [item.enabled, isEnabled, onRuleChanged, enableRule, disableRule] ); const onChangeSnooze = useCallback( async (value: number, unit?: SnoozeUnit) => { @@ -136,10 +141,11 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ return ( {isEditable ? ( @@ -259,7 +265,7 @@ const RuleStatusMenu: React.FunctionComponent = ({ }, ]; - return ; + return ; }; interface SnoozePanelProps { @@ -329,7 +335,7 @@ const SnoozePanel: React.FunctionComponent = ({ /> - + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.applySnooze', { defaultMessage: 'Apply', })} @@ -380,7 +386,7 @@ const SnoozePanel: React.FunctionComponent = ({ - + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeIndefinitely', { defaultMessage: 'Snooze indefinitely', })} @@ -392,7 +398,7 @@ const SnoozePanel: React.FunctionComponent = ({ - + Cancel snooze diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 657a2fe54c00b..af66fa6c93f1a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -831,7 +831,10 @@ export const RulesList: React.FunctionComponent = () => { }, { field: 'enabled', - name: '', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.triggerActionsTitle', + { defaultMessage: 'Trigger actions' } + ), sortable: true, truncateText: false, width: '10%', 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 22c98b189a590..3813a8686826e 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 @@ -180,75 +180,90 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should disable the rule', async () => { - const enableSwitch = await testSubjects.find('enableSwitch'); + const actionsDropdown = await testSubjects.find('statusDropdown'); - const isChecked = await enableSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('true'); + expect(await actionsDropdown.getVisibleText()).to.eql('Enabled'); - await enableSwitch.click(); + await actionsDropdown.click(); + const actionsMenuElem = await testSubjects.find('ruleStatusMenu'); + const actionsMenuItemElem = await actionsMenuElem.findAllByClassName('euiContextMenuItem'); - const disableSwitchAfterDisabling = await testSubjects.find('enableSwitch'); - const isCheckedAfterDisabling = await disableSwitchAfterDisabling.getAttribute( - 'aria-checked' - ); - expect(isCheckedAfterDisabling).to.eql('false'); + await actionsMenuItemElem.at(1)?.click(); + + await retry.try(async () => { + expect(await actionsDropdown.getVisibleText()).to.eql('Disabled'); + }); }); - it('shouldnt allow you to mute a disabled rule', async () => { - const disabledEnableSwitch = await testSubjects.find('enableSwitch'); - expect(await disabledEnableSwitch.getAttribute('aria-checked')).to.eql('false'); + it('shouldnt allow you to snooze a disabled rule', async () => { + const actionsDropdown = await testSubjects.find('statusDropdown'); - const muteSwitch = await testSubjects.find('muteSwitch'); - expect(await muteSwitch.getAttribute('aria-checked')).to.eql('false'); + expect(await actionsDropdown.getVisibleText()).to.eql('Disabled'); - await muteSwitch.click(); + await actionsDropdown.click(); + const actionsMenuElem = await testSubjects.find('ruleStatusMenu'); + const actionsMenuItemElem = await actionsMenuElem.findAllByClassName('euiContextMenuItem'); - const muteSwitchAfterTryingToMute = await testSubjects.find('muteSwitch'); - const isDisabledMuteAfterDisabling = await muteSwitchAfterTryingToMute.getAttribute( - 'aria-checked' - ); - expect(isDisabledMuteAfterDisabling).to.eql('false'); + expect(await actionsMenuItemElem.at(2)?.getVisibleText()).to.eql('Snooze'); + expect(await actionsMenuItemElem.at(2)?.getAttribute('disabled')).to.eql('true'); + // close the dropdown + await actionsDropdown.click(); }); it('should reenable a disabled the rule', async () => { - const enableSwitch = await testSubjects.find('enableSwitch'); + const actionsDropdown = await testSubjects.find('statusDropdown'); - const isChecked = await enableSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('false'); + expect(await actionsDropdown.getVisibleText()).to.eql('Disabled'); - await enableSwitch.click(); + await actionsDropdown.click(); + const actionsMenuElem = await testSubjects.find('ruleStatusMenu'); + const actionsMenuItemElem = await actionsMenuElem.findAllByClassName('euiContextMenuItem'); - const disableSwitchAfterReenabling = await testSubjects.find('enableSwitch'); - const isCheckedAfterDisabling = await disableSwitchAfterReenabling.getAttribute( - 'aria-checked' - ); - expect(isCheckedAfterDisabling).to.eql('true'); + await actionsMenuItemElem.at(0)?.click(); + + await retry.try(async () => { + expect(await actionsDropdown.getVisibleText()).to.eql('Enabled'); + }); }); - it('should mute the rule', async () => { - const muteSwitch = await testSubjects.find('muteSwitch'); + it('should snooze the rule', async () => { + const actionsDropdown = await testSubjects.find('statusDropdown'); - const isChecked = await muteSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('false'); + expect(await actionsDropdown.getVisibleText()).to.eql('Enabled'); - await muteSwitch.click(); + await actionsDropdown.click(); + const actionsMenuElem = await testSubjects.find('ruleStatusMenu'); + const actionsMenuItemElem = await actionsMenuElem.findAllByClassName('euiContextMenuItem'); - const muteSwitchAfterDisabling = await testSubjects.find('muteSwitch'); - const isCheckedAfterDisabling = await muteSwitchAfterDisabling.getAttribute('aria-checked'); - expect(isCheckedAfterDisabling).to.eql('true'); + await actionsMenuItemElem.at(2)?.click(); + + const snoozeIndefinite = await testSubjects.find('ruleSnoozeIndefiniteApply'); + await snoozeIndefinite.click(); + + await retry.try(async () => { + expect(await actionsDropdown.getVisibleText()).to.eql('Snoozed'); + const remainingSnoozeTime = await testSubjects.find('remainingSnoozeTime'); + expect(await remainingSnoozeTime.getVisibleText()).to.eql('Indefinitely'); + }); }); - it('should unmute the rule', async () => { - const muteSwitch = await testSubjects.find('muteSwitch'); + it('should unsnooze the rule', async () => { + const actionsDropdown = await testSubjects.find('statusDropdown'); - const isChecked = await muteSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('true'); + expect(await actionsDropdown.getVisibleText()).to.eql('Snoozed'); - await muteSwitch.click(); + await actionsDropdown.click(); + const actionsMenuElem = await testSubjects.find('ruleStatusMenu'); + const actionsMenuItemElem = await actionsMenuElem.findAllByClassName('euiContextMenuItem'); - const muteSwitchAfterUnmuting = await testSubjects.find('muteSwitch'); - const isCheckedAfterDisabling = await muteSwitchAfterUnmuting.getAttribute('aria-checked'); - expect(isCheckedAfterDisabling).to.eql('false'); + await actionsMenuItemElem.at(2)?.click(); + + const snoozeCancel = await testSubjects.find('ruleSnoozeCancel'); + await snoozeCancel.click(); + + await retry.try(async () => { + expect(await actionsDropdown.getVisibleText()).to.eql('Enabled'); + }); }); });