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');
+ });
});
});