[Actionable Observability ] - Rule Details Phase 2 - Migrate Rule Definition component to Triggers Actions UI (#134843)

* Migrate Rule Definition component to Triggers Actions UI [Works]

* Clean up

* Add Rule edit flyout from definition component

* WIP Adding tests for Rule Actions

* Fix rule actions tests

* Fix test mock for getRuleDefinition function

* Fix i18n keys

* Fix i18n and should be deleted later

* Update Actions to RuleActions

* Update test name

* Update test name

* Add Rule definition tests suite

* Clean up the moved code to triggersActionsUI

* Update RuleAction type

* getRuleType inside in useMemo

* User formatDuration from alerting

* Fix types and import

* Fix test

* Fix and remove unused i18n

* Fix checks

* Code review better i18n plural handling
This commit is contained in:
Faisal Kanout 2022-07-06 14:40:06 +02:00 committed by GitHub
parent f0965a39b6
commit eae262edad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 618 additions and 299 deletions

View file

@ -1,90 +0,0 @@
/*
* 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 { mount } from 'enzyme';
import { nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { Actions } from './actions';
import { observabilityPublicPluginsStartMock } from '../../../observability_public_plugins_start.mock';
import { kibanaStartMock } from '../../../utils/kibana_react.mock';
const mockUseKibanaReturnValue = kibanaStartMock.startContract();
jest.mock('../../../utils/kibana_react', () => ({
__esModule: true,
useKibana: jest.fn(() => mockUseKibanaReturnValue),
}));
jest.mock('@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api', () => ({
loadAllActions: jest.fn(),
}));
describe('Actions', () => {
async function setup() {
const ruleActions = [
{
id: 1,
group: 'metrics.inventory_threshold.fired',
actionTypeId: '.server-log',
},
{
id: 2,
group: 'metrics.inventory_threshold.fired',
actionTypeId: '.slack',
},
];
const { loadAllActions } = jest.requireMock(
'@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api'
);
loadAllActions.mockResolvedValueOnce([
{
id: 'a0d2f6c0-e682-11ec-843b-213c67313f8c',
name: 'Email',
config: {},
actionTypeId: '.email',
},
{
id: 'f57cabc0-e660-11ec-8241-7deb55b17f15',
name: 'logs',
config: {},
actionTypeId: '.server-log',
},
{
id: '05b7ab30-e683-11ec-843b-213c67313f8c',
name: 'Slack',
actionTypeId: '.slack',
},
]);
const actionTypeRegistryMock =
observabilityPublicPluginsStartMock.createStart().triggersActionsUi.actionTypeRegistry;
actionTypeRegistryMock.list.mockReturnValue([
{ id: '.server-log', iconClass: 'logsApp' },
{ id: '.slack', iconClass: 'logoSlack' },
{ id: '.email', iconClass: 'email' },
{ id: '.index', iconClass: 'indexOpen' },
]);
const wrapper = mount(
<Actions ruleActions={ruleActions} actionTypeRegistry={actionTypeRegistryMock} />
);
await act(async () => {
await nextTick();
wrapper.update();
});
return wrapper;
}
it("renders action connector icons for user's selected rule actions", async () => {
const wrapper = await setup();
wrapper.debug();
expect(wrapper.find('[data-euiicon-type]').length).toBe(2);
expect(wrapper.find('[data-euiicon-type="logsApp"]').length).toBe(1);
expect(wrapper.find('[data-euiicon-type="logoSlack"]').length).toBe(1);
expect(wrapper.find('[data-euiicon-type="index"]').length).toBe(0);
expect(wrapper.find('[data-euiicon-type="email"]').length).toBe(0);
});
});

View file

@ -8,4 +8,3 @@
export { PageTitle } from './page_title';
export { ItemTitleRuleSummary } from './item_title_rule_summary';
export { ItemValueRuleSummary } from './item_value_rule_summary';
export { Actions } from './actions';

View file

@ -42,6 +42,7 @@ import {
import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { RuleDefinitionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation';
import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner';
import { OBSERVABILITY_SOLUTIONS } from '../rules/config';
@ -50,11 +51,10 @@ import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useFetchRule } from '../../hooks/use_fetch_rule';
import { RULES_BREADCRUMB_TEXT } from '../rules/translations';
import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from './components';
import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary } from './components';
import { useKibana } from '../../utils/kibana_react';
import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts';
import { useFetchLast24hRuleExecutionLog } from '../../hooks/use_fetch_last24h_rule_execution_log';
import { formatInterval } from './utils';
import { hasExecuteActionsCapability, hasAllPrivilege } from './config';
import { paths } from '../../config/paths';
import { observabilityFeatureId } from '../../../common';
@ -67,9 +67,9 @@ export function RuleDetailsPage() {
ruleTypeRegistry,
getRuleStatusDropdown,
getEditAlertFlyout,
actionTypeRegistry,
getRuleEventLogList,
getAlertsStateTable,
getRuleDefinition,
},
application: { capabilities, navigateToUrl },
notifications: { toasts },
@ -79,7 +79,7 @@ export function RuleDetailsPage() {
const { ObservabilityPageTemplate } = usePluginContext();
const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http });
const { isLoadingExecutionLog, executionLog } = useFetchLast24hRuleExecutionLog({ http, ruleId });
const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({
const { ruleTypes } = useLoadRuleTypes({
filteredSolutions: OBSERVABILITY_SOLUTIONS,
});
@ -160,19 +160,6 @@ export function RuleDetailsPage() {
? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext
: false);
const getRuleConditionsWording = () => {
const numberOfConditions = rule?.params.criteria ? (rule?.params.criteria as any[]).length : 0;
return (
<>
{numberOfConditions}&nbsp;
{i18n.translate('xpack.observability.ruleDetails.conditions', {
defaultMessage: 'condition{s}',
values: { s: numberOfConditions > 1 ? 's' : '' },
})}
</>
);
};
const alertStateProps = {
alertsTableConfigurationRegistry,
configurationId: observabilityFeatureId,
@ -257,9 +244,6 @@ export function RuleDetailsPage() {
unsnoozeRule: async (scheduleIds) => await unsnoozeRule({ http, id: rule.id, scheduleIds }),
});
const getNotifyText = () =>
NOTIFY_WHEN_OPTIONS.current.find((option) => option.value === rule?.notifyWhen)?.inputDisplay ||
rule.notifyWhen;
return (
<ObservabilityPageTemplate
data-test-subj="ruleDetails"
@ -412,113 +396,7 @@ export function RuleDetailsPage() {
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
{/* Right side of Rule Summary */}
<EuiFlexItem data-test-subj="ruleSummaryRuleDefinition" grow={3}>
<EuiPanel color="subdued" hasBorder={false} paddingSize={'m'}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiTitle size="s">
<EuiFlexItem grow={false}>
{i18n.translate('xpack.observability.ruleDetails.definition', {
defaultMessage: 'Definition',
})}
</EuiFlexItem>
</EuiTitle>
{hasEditButton && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType={'pencil'} onClick={() => setEditFlyoutVisible(true)} />
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem>
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.ruleType', {
defaultMessage: 'Rule type',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
data-test-subj="ruleSummaryRuleType"
itemValue={ruleTypeIndex.get(rule.ruleTypeId)?.name || rule.ruleTypeId}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="flexStart" responsive={false}>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.description', {
defaultMessage: 'Description',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
itemValue={ruleTypeRegistry.get(rule.ruleTypeId).description}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.conditionsTitle', {
defaultMessage: 'Conditions',
})}
</ItemTitleRuleSummary>
<EuiFlexItem grow={3}>
<EuiFlexGroup alignItems="center">
{hasEditButton ? (
<EuiButtonEmpty onClick={() => setEditFlyoutVisible(true)}>
<EuiText size="s">{getRuleConditionsWording()}</EuiText>
</EuiButtonEmpty>
) : (
<EuiText size="s">{getRuleConditionsWording()}</EuiText>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.runsEvery', {
defaultMessage: 'Runs every',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary itemValue={formatInterval(rule.schedule.interval)} />
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.notifyWhen', {
defaultMessage: 'Notify',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary itemValue={String(getNotifyText())} />
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="baseline">
<ItemTitleRuleSummary>
{i18n.translate('xpack.observability.ruleDetails.actions', {
defaultMessage: 'Actions',
})}
</ItemTitleRuleSummary>
<EuiFlexItem grow={3}>
<Actions ruleActions={rule.actions} actionTypeRegistry={actionTypeRegistry} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
{getRuleDefinition({ rule, onEditRule: () => reloadRule() } as RuleDefinitionProps)}
</EuiFlexGroup>
<EuiSpacer size="l" />

View file

@ -12,12 +12,6 @@ export const RULE_LOAD_ERROR = (errorMessage: string) =>
values: { message: errorMessage },
});
export const ACTIONS_LOAD_ERROR = (errorMessage: string) =>
i18n.translate('xpack.observability.ruleDetails.connectorsLoadError', {
defaultMessage: 'Unable to load rule actions connectors. Reason: {message}',
values: { message: errorMessage },
});
export const EXECUTION_LOG_ERROR = (errorMessage: string) =>
i18n.translate('xpack.observability.ruleDetails.executionLogError', {
defaultMessage: 'Unable to load rule execution log. Reason: {message}',

View file

@ -1,15 +0,0 @@
/*
* 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 { formatDurationFromTimeUnitChar, TimeUnitChar } from '../../../common';
export const formatInterval = (ruleInterval: string) => {
const interval: string[] | null = ruleInterval.match(/(^\d*)([s|m|h|d])/);
if (!interval || interval.length < 3) return ruleInterval;
const value: number = +interval[1];
const unit = interval[2] as TimeUnitChar;
return formatDurationFromTimeUnitChar(value, unit);
};

View file

@ -22409,16 +22409,10 @@
"xpack.observability.resources.quick_start": "Vidéos de démarrage rapide",
"xpack.observability.resources.title": "Ressources",
"xpack.observability.resources.training": "Cours gratuit Observability",
"xpack.observability.ruleDetails.actions": "Actions",
"xpack.observability.ruleDetails.alerts": "Alertes",
"xpack.observability.ruleDetails.byWord": "par",
"xpack.observability.ruleDetails.conditions": "condition{s}",
"xpack.observability.ruleDetails.conditionsTitle": "Conditions",
"xpack.observability.ruleDetails.connectorsLoadError": "Impossible de charger les connecteurs d'actions de règles. Raison : {message}",
"xpack.observability.ruleDetails.createdWord": "Créé",
"xpack.observability.ruleDetails.definition": "Définition",
"xpack.observability.ruleDetails.deleteRule": "Supprimer la règle",
"xpack.observability.ruleDetails.description": "Description",
"xpack.observability.ruleDetails.editRule": "Modifier la règle",
"xpack.observability.ruleDetails.errorPromptBody": "Une erreur s'est produite lors du chargement des détails de la règle.",
"xpack.observability.ruleDetails.errorPromptTitle": "Impossible de charger les détails de la règle",
@ -22427,15 +22421,11 @@
"xpack.observability.ruleDetails.last24h": "(dernières 24 h)",
"xpack.observability.ruleDetails.lastRun": "Dernière exécution",
"xpack.observability.ruleDetails.lastUpdatedMessage": "Dernière mise à jour",
"xpack.observability.ruleDetails.noActions": "Aucune action",
"xpack.observability.ruleDetails.notifyWhen": "Notifier",
"xpack.observability.ruleDetails.onWord": "le",
"xpack.observability.ruleDetails.rule.alertsTabText": "Alertes",
"xpack.observability.ruleDetails.rule.eventLogTabText": "Historique d'exécution",
"xpack.observability.ruleDetails.ruleIs": "La règle est",
"xpack.observability.ruleDetails.ruleLoadError": "Impossible de charger la règle. Raison : {message}",
"xpack.observability.ruleDetails.ruleType": "Type de règle",
"xpack.observability.ruleDetails.runsEvery": "S'exécute toutes les",
"xpack.observability.ruleDetails.tagsTitle": "Balises",
"xpack.observability.ruleDetails.triggreAction.status": "Statut",
"xpack.observability.rules.addRuleButtonLabel": "Créer une règle",

View file

@ -22395,16 +22395,10 @@
"xpack.observability.resources.quick_start": "クイックスタートビデオ",
"xpack.observability.resources.title": "リソース",
"xpack.observability.resources.training": "無料のObservabilityコース",
"xpack.observability.ruleDetails.actions": "アクション",
"xpack.observability.ruleDetails.alerts": "アラート",
"xpack.observability.ruleDetails.byWord": "グループ基準",
"xpack.observability.ruleDetails.conditions": "条件{s}",
"xpack.observability.ruleDetails.conditionsTitle": "条件",
"xpack.observability.ruleDetails.connectorsLoadError": "ルールアクションコネクターを読み込めません。理由:{message}",
"xpack.observability.ruleDetails.createdWord": "作成済み",
"xpack.observability.ruleDetails.definition": "定義",
"xpack.observability.ruleDetails.deleteRule": "ルールの削除",
"xpack.observability.ruleDetails.description": "説明",
"xpack.observability.ruleDetails.editRule": "ルールを編集",
"xpack.observability.ruleDetails.errorPromptBody": "ルール詳細の読み込みエラーが発生しました。",
"xpack.observability.ruleDetails.errorPromptTitle": "ルール詳細を読み込めません",
@ -22413,15 +22407,11 @@
"xpack.observability.ruleDetails.last24h": "過去24時間",
"xpack.observability.ruleDetails.lastRun": "前回の実行",
"xpack.observability.ruleDetails.lastUpdatedMessage": "最終更新",
"xpack.observability.ruleDetails.noActions": "アクションなし",
"xpack.observability.ruleDetails.notifyWhen": "通知",
"xpack.observability.ruleDetails.onWord": "日付",
"xpack.observability.ruleDetails.rule.alertsTabText": "アラート",
"xpack.observability.ruleDetails.rule.eventLogTabText": "実行履歴",
"xpack.observability.ruleDetails.ruleIs": "ルールは",
"xpack.observability.ruleDetails.ruleLoadError": "ルールを読み込めません。理由:{message}",
"xpack.observability.ruleDetails.ruleType": "ルールタイプ",
"xpack.observability.ruleDetails.runsEvery": "次の間隔で実行",
"xpack.observability.ruleDetails.tagsTitle": "タグ",
"xpack.observability.ruleDetails.triggreAction.status": "ステータス",
"xpack.observability.rules.addRuleButtonLabel": "ルールを作成",

View file

@ -22420,15 +22420,10 @@
"xpack.observability.resources.quick_start": "快速入门视频",
"xpack.observability.resources.title": "资源",
"xpack.observability.resources.training": "免费的可观测性课程",
"xpack.observability.ruleDetails.actions": "操作",
"xpack.observability.ruleDetails.alerts": "告警",
"xpack.observability.ruleDetails.byWord": "依据",
"xpack.observability.ruleDetails.conditionsTitle": "条件",
"xpack.observability.ruleDetails.connectorsLoadError": "无法加载规则操作连接器。原因:{message}",
"xpack.observability.ruleDetails.createdWord": "创建时间",
"xpack.observability.ruleDetails.definition": "定义",
"xpack.observability.ruleDetails.deleteRule": "删除规则",
"xpack.observability.ruleDetails.description": "描述",
"xpack.observability.ruleDetails.editRule": "编辑规则",
"xpack.observability.ruleDetails.errorPromptBody": "加载规则详情时出现错误。",
"xpack.observability.ruleDetails.errorPromptTitle": "无法加载规则详情",
@ -22437,15 +22432,11 @@
"xpack.observability.ruleDetails.last24h": "(过去 24 小时)",
"xpack.observability.ruleDetails.lastRun": "上次运行",
"xpack.observability.ruleDetails.lastUpdatedMessage": "上次更新时间",
"xpack.observability.ruleDetails.noActions": "无操作",
"xpack.observability.ruleDetails.notifyWhen": "通知",
"xpack.observability.ruleDetails.onWord": "在",
"xpack.observability.ruleDetails.rule.alertsTabText": "告警",
"xpack.observability.ruleDetails.rule.eventLogTabText": "执行历史记录",
"xpack.observability.ruleDetails.ruleIs": "规则为",
"xpack.observability.ruleDetails.ruleLoadError": "无法加载规则。原因:{message}",
"xpack.observability.ruleDetails.ruleType": "规则类型",
"xpack.observability.ruleDetails.runsEvery": "运行间隔",
"xpack.observability.ruleDetails.tagsTitle": "标签",
"xpack.observability.ruleDetails.triggreAction.status": "状态",
"xpack.observability.rules.addRuleButtonLabel": "创建规则",

View file

@ -6,21 +6,31 @@
*/
import { useEffect, useState, useCallback } from 'react';
import { ActionConnector, loadAllActions } from '@kbn/triggers-actions-ui-plugin/public';
import { intersectionBy } from 'lodash';
import { FetchRuleActionConnectorsProps } from '../pages/rule_details/types';
import { ACTIONS_LOAD_ERROR } from '../pages/rule_details/translations';
import { i18n } from '@kbn/i18n';
import { ActionConnector, loadAllActions } from '../..';
import { useKibana } from '../../common/lib/kibana';
const ACTIONS_LOAD_ERROR = (errorMessage: string) =>
i18n.translate('xpack.triggersActionsUI.ruleDetails.connectorsLoadError', {
defaultMessage: 'Unable to load rule actions connectors. Reason: {message}',
values: { message: errorMessage },
});
interface FetchActionConnectors {
isLoadingActionConnectors: boolean;
actionConnectors: Array<ActionConnector<Record<string, unknown>>>;
errorActionConnectors?: string;
}
interface FetchRuleActionConnectorsProps {
ruleActions: any[];
}
export function useFetchRuleActionConnectors({ ruleActions }: FetchRuleActionConnectorsProps) {
const {
http,
notifications: { toasts },
} = useKibana().services;
export function useFetchRuleActionConnectors({
http,
ruleActions,
}: FetchRuleActionConnectorsProps) {
const [actionConnectors, setActionConnector] = useState<FetchActionConnectors>({
isLoadingActionConnectors: true,
actionConnectors: [] as Array<ActionConnector<Record<string, unknown>>>,
@ -47,15 +57,17 @@ export function useFetchRuleActionConnectors({
actionConnectors: actions,
}));
} catch (error) {
const errorMsg = ACTIONS_LOAD_ERROR(
error instanceof Error ? error.message : typeof error === 'string' ? error : ''
);
setActionConnector((oldState: FetchActionConnectors) => ({
...oldState,
isLoadingActionConnectors: false,
errorActionConnectors: ACTIONS_LOAD_ERROR(
error instanceof Error ? error.message : typeof error === 'string' ? error : ''
),
errorActionConnectors: errorMsg,
}));
toasts.addDanger({ title: errorMsg });
}
}, [http, ruleActions]);
}, [http, ruleActions, toasts]);
useEffect(() => {
fetchRuleActionConnectors();
}, [fetchRuleActionConnectors]);

View file

@ -49,6 +49,9 @@ export const RulesList = suspendedComponentWithProps(
export const RulesListNotifyBadge = suspendedComponentWithProps(
lazy(() => import('./rules_list/components/rules_list_notify_badge'))
);
export const RuleDefinition = suspendedComponentWithProps(
lazy(() => import('./rule_details/components/rule_definition'))
);
export const RuleTagBadge = suspendedComponentWithProps(
lazy(() => import('./rules_list/components/rule_tag_badge'))
);

View file

@ -0,0 +1,83 @@
/*
* 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 { mount } from 'enzyme';
import { nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { RuleActions } from './rule_actions';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { ActionConnector, ActionTypeModel, RuleAction } from '../../../../types';
import * as useFetchRuleActionConnectorsHook from '../../../hooks/use_fetch_rule_action_connectors';
const actionTypeRegistry = actionTypeRegistryMock.create();
const mockedUseFetchRuleActionConnectorsHook = jest.spyOn(
useFetchRuleActionConnectorsHook,
'useFetchRuleActionConnectors'
);
describe('Rule Actions', () => {
async function setup() {
const ruleActions = [
{
id: '1',
group: 'metrics.inventory_threshold.fired',
actionTypeId: '.server-log',
params: {},
},
{
id: '2',
group: 'metrics.inventory_threshold.fired',
actionTypeId: '.slack',
params: {},
},
] as RuleAction[];
mockedUseFetchRuleActionConnectorsHook.mockReturnValue({
isLoadingActionConnectors: false,
actionConnectors: [
{
id: 'f57cabc0-e660-11ec-8241-7deb55b17f15',
name: 'logs',
config: {},
actionTypeId: '.server-log',
},
{
id: '05b7ab30-e683-11ec-843b-213c67313f8c',
name: 'Slack',
actionTypeId: '.slack',
},
] as Array<ActionConnector<Record<string, unknown>>>,
errorActionConnectors: undefined,
reloadRuleActionConnectors: jest.fn(),
});
actionTypeRegistry.list.mockReturnValue([
{ id: '.server-log', iconClass: 'logsApp' },
{ id: '.slack', iconClass: 'logoSlack' },
{ id: '.email', iconClass: 'email' },
{ id: '.index', iconClass: 'indexOpen' },
] as ActionTypeModel[]);
const wrapper = mount(
<RuleActions ruleActions={ruleActions} actionTypeRegistry={actionTypeRegistry} />
);
await act(async () => {
await nextTick();
wrapper.update();
});
return wrapper;
}
it("renders rule action connector icons for user's selected rule actions", async () => {
const wrapper = await setup();
expect(mockedUseFetchRuleActionConnectorsHook).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-euiicon-type]').length).toBe(2);
expect(wrapper.find('[data-euiicon-type="logsApp"]').length).toBe(1);
expect(wrapper.find('[data-euiicon-type="logoSlack"]').length).toBe(1);
expect(wrapper.find('[data-euiicon-type="index"]').length).toBe(0);
expect(wrapper.find('[data-euiicon-type="email"]').length).toBe(0);
});
});

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import React from 'react';
import {
EuiText,
EuiSpacer,
@ -14,33 +14,24 @@ import {
IconType,
EuiLoadingSpinner,
} from '@elastic/eui';
import { suspendedComponentWithProps } from '@kbn/triggers-actions-ui-plugin/public';
import { i18n } from '@kbn/i18n';
import { ActionsProps } from '../types';
import { ActionTypeRegistryContract, RuleAction, suspendedComponentWithProps } from '../../../..';
import { useFetchRuleActionConnectors } from '../../../hooks/use_fetch_rule_action_connectors';
import { useKibana } from '../../../utils/kibana_react';
export function Actions({ ruleActions, actionTypeRegistry }: ActionsProps) {
const {
http,
notifications: { toasts },
} = useKibana().services;
const { isLoadingActionConnectors, actionConnectors, errorActionConnectors } =
useFetchRuleActionConnectors({
http,
ruleActions,
});
useEffect(() => {
if (errorActionConnectors) {
toasts.addDanger({ title: errorActionConnectors });
}
}, [errorActionConnectors, toasts]);
export interface RuleActionsProps {
ruleActions: RuleAction[];
actionTypeRegistry: ActionTypeRegistryContract;
}
export function RuleActions({ ruleActions, actionTypeRegistry }: RuleActionsProps) {
const { isLoadingActionConnectors, actionConnectors } = useFetchRuleActionConnectors({
ruleActions,
});
if (!actionConnectors || actionConnectors.length <= 0)
return (
<EuiFlexItem>
<EuiText size="s">
{i18n.translate('xpack.observability.ruleDetails.noActions', {
{i18n.translate('xpack.triggersActionsUI.ruleDetails.noActions', {
defaultMessage: 'No actions',
})}
</EuiText>

View file

@ -0,0 +1,222 @@
/*
* 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 { mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { nextTick } from '@kbn/test-jest-helpers';
import { RuleDefinition } from './rule_definition';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { ActionTypeModel, Rule, RuleTypeModel } from '../../../../types';
import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock';
jest.mock('./rule_actions', () => ({
RuleActions: () => {
return <></>;
},
}));
jest.mock('../../../lib/capabilities', () => ({
hasAllPrivilege: jest.fn(() => true),
hasSaveRulesCapability: jest.fn(() => true),
hasExecuteActionsCapability: jest.fn(() => true),
hasManageApiKeysCapability: jest.fn(() => true),
}));
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../..', () => ({
useLoadRuleTypes: jest.fn(),
}));
const { useLoadRuleTypes } = jest.requireMock('../../../..');
const ruleTypes = [
{
id: 'test_rule_type',
name: 'some rule type',
actionGroups: [{ id: 'default', name: 'Default' }],
recoveryActionGroup: { id: 'recovered', name: 'Recovered' },
actionVariables: { context: [], state: [] },
defaultActionGroupId: 'default',
producer: ALERTS_FEATURE_ID,
minimumLicenseRequired: 'basic',
enabledInLicense: true,
authorizedConsumers: {
[ALERTS_FEATURE_ID]: { read: true, all: false },
},
ruleTaskTimeout: '1m',
},
];
const mockedRuleTypeIndex = new Map(
Object.entries({
test_rule_type: {
enabledInLicense: true,
id: 'test_rule_type',
name: 'test rule',
},
'2': {
enabledInLicense: true,
id: '2',
name: 'test rule ok',
},
'3': {
enabledInLicense: true,
id: '3',
name: 'test rule pending',
},
})
);
describe('Rule Definition', () => {
let wrapper: ReactWrapper;
async function setup() {
const actionTypeRegistry = actionTypeRegistryMock.create();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const mockedRule = mockRule();
jest.mock('../../../lib/capabilities', () => ({
hasAllPrivilege: jest.fn(() => true),
hasSaveRulesCapability: jest.fn(() => true),
hasExecuteActionsCapability: jest.fn(() => true),
hasManageApiKeysCapability: jest.fn(() => true),
}));
ruleTypeRegistry.has.mockReturnValue(true);
const ruleTypeR: RuleTypeModel = {
id: 'my-rule-type',
iconClass: 'test',
description: 'Rule when testing',
documentationUrl: 'https://localhost.local/docs',
validate: () => {
return { errors: {} };
},
ruleParamsExpression: jest.fn(),
requiresAppContext: false,
};
ruleTypeRegistry.get.mockReturnValue(ruleTypeR);
actionTypeRegistry.list.mockReturnValue([
{ id: '.server-log', iconClass: 'logsApp' },
{ id: '.slack', iconClass: 'logoSlack' },
{ id: '.email', iconClass: 'email' },
{ id: '.index', iconClass: 'indexOpen' },
] as ActionTypeModel[]);
useLoadRuleTypes.mockReturnValue({ ruleTypes, ruleTypeIndex: mockedRuleTypeIndex });
wrapper = mount(
<RuleDefinition
rule={mockedRule}
actionTypeRegistry={actionTypeRegistry}
onEditRule={jest.fn()}
ruleTypeRegistry={ruleTypeRegistry}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
}
beforeAll(async () => await setup());
it('renders rule definition ', async () => {
expect(wrapper.find('[data-test-subj="ruleSummaryRuleDefinition"]')).toBeTruthy();
});
it('show rule type name from "useLoadRuleTypes"', async () => {
expect(useLoadRuleTypes).toHaveBeenCalledTimes(2);
const ruleType = wrapper.find('[data-test-subj="ruleSummaryRuleType"]');
expect(ruleType).toBeTruthy();
expect(ruleType.find('div.euiText').text()).toEqual(
mockedRuleTypeIndex.get(mockRule().ruleTypeId)?.name
);
});
it('show rule type description "', async () => {
const ruleDescription = wrapper.find('[data-test-subj="ruleSummaryRuleDescription"]');
expect(ruleDescription).toBeTruthy();
expect(ruleDescription.find('div.euiText').text()).toEqual('Rule when testing');
});
it('show rule conditions "', async () => {
const ruleConditions = wrapper.find('[data-test-subj="ruleSummaryRuleConditions"]');
expect(ruleConditions).toBeTruthy();
expect(ruleConditions.find('div.euiText').text()).toEqual(`0 conditions`);
});
it('show rule interval with human readable value', async () => {
const ruleInterval = wrapper.find('[data-test-subj="ruleSummaryRuleInterval"]');
expect(ruleInterval).toBeTruthy();
expect(ruleInterval.find('div.euiText').text()).toEqual('1 sec');
});
it('show edit button when user has permissions', async () => {
const editButton = wrapper.find('[data-test-subj="ruleDetailsEditButton"]');
expect(editButton).toBeTruthy();
});
it('hide edit button when user DOES NOT have permissions', async () => {
jest.mock('../../../lib/capabilities', () => ({
hasAllPrivilege: jest.fn(() => false),
hasSaveRulesCapability: jest.fn(() => true),
hasExecuteActionsCapability: jest.fn(() => true),
hasManageApiKeysCapability: jest.fn(() => true),
}));
const editButton = wrapper.find('[data-test-subj="ruleDetailsEditButton"]');
expect(editButton).toMatchObject({});
});
});
function mockRule(): Rule {
return {
id: '1',
name: 'test rule',
tags: ['tag1'],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '1s' },
actions: [],
params: { name: 'test rule type name' },
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
createdAt: new Date(),
updatedAt: new Date(),
consumer: 'alerts',
notifyWhen: 'onActiveAlert',
executionStatus: {
status: 'active',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
monitoring: {
execution: {
history: [
{
success: true,
duration: 1000000,
timestamp: 1234567,
},
{
success: true,
duration: 200000,
timestamp: 1234567,
},
{
success: false,
duration: 300000,
timestamp: 1234567,
},
],
calculated_metrics: {
success_ratio: 0.66,
p50: 200000,
p95: 300000,
p99: 300000,
},
},
},
};
}

View file

@ -0,0 +1,238 @@
/*
* 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, { useState, useEffect, useMemo } from 'react';
import {
EuiText,
EuiSpacer,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { formatDuration } from '@kbn/alerting-plugin/common';
import { RuleDefinitionProps } from '../../../../types';
import { RuleType, useLoadRuleTypes } from '../../../..';
import { useKibana } from '../../../../common/lib/kibana';
import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities';
import { NOTIFY_WHEN_OPTIONS } from '../../rule_form/rule_notify_when';
import { RuleActions } from './rule_actions';
import { RuleEdit } from '../../rule_form';
const OBSERVABILITY_SOLUTIONS = ['logs', 'uptime', 'infrastructure', 'apm'];
export const RuleDefinition: React.FunctionComponent<RuleDefinitionProps> = ({
rule,
actionTypeRegistry,
ruleTypeRegistry,
onEditRule,
}) => {
const {
application: { capabilities },
} = useKibana().services;
const [editFlyoutVisible, setEditFlyoutVisible] = useState<boolean>(false);
const [ruleType, setRuleType] = useState<RuleType>();
const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({
filteredSolutions: OBSERVABILITY_SOLUTIONS,
});
const getRuleType = useMemo(() => {
if (ruleTypes.length && rule) {
return ruleTypes.find((type) => type.id === rule.ruleTypeId);
}
}, [rule, ruleTypes]);
useEffect(() => {
setRuleType(getRuleType);
}, [getRuleType]);
const getRuleConditionsWording = () => {
const numberOfConditions = rule?.params.criteria ? (rule?.params.criteria as any[]).length : 0;
return i18n.translate('xpack.triggersActionsUI.ruleDetails.conditions', {
defaultMessage: '{numberOfConditions, plural, one {# condition} other {# conditions}}',
values: { numberOfConditions },
});
};
const getNotifyText = () =>
NOTIFY_WHEN_OPTIONS.find((options) => options.value === rule?.notifyWhen)?.inputDisplay ||
rule?.notifyWhen;
const canExecuteActions = hasExecuteActionsCapability(capabilities);
const canSaveRule =
rule &&
hasAllPrivilege(rule, ruleType) &&
// if the rule has actions, can the user save the rule's action params
(canExecuteActions || (!canExecuteActions && rule.actions.length === 0));
const hasEditButton =
// can the user save the rule
canSaveRule &&
// is this rule type editable from within Rules Management
(ruleTypeRegistry.has(rule.ruleTypeId)
? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext
: false);
return (
<EuiFlexItem data-test-subj="ruleSummaryRuleDefinition" grow={3}>
<EuiPanel color="subdued" hasBorder={false} paddingSize={'m'}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiTitle size="s">
<EuiFlexItem grow={false}>
{i18n.translate('xpack.triggersActionsUI.ruleDetails.definition', {
defaultMessage: 'Definition',
})}
</EuiFlexItem>
</EuiTitle>
{hasEditButton && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="ruleDetailsEditButton"
iconType={'pencil'}
onClick={() => setEditFlyoutVisible(true)}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem>
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.triggersActionsUI.ruleDetails.ruleType', {
defaultMessage: 'Rule type',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
data-test-subj="ruleSummaryRuleType"
itemValue={ruleTypeIndex.get(rule.ruleTypeId)?.name || rule.ruleTypeId}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="flexStart" responsive={false}>
<ItemTitleRuleSummary>
{i18n.translate('xpack.triggersActionsUI.ruleDetails.description', {
defaultMessage: 'Description',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
data-test-subj="ruleSummaryRuleDescription"
itemValue={ruleTypeRegistry.get(rule.ruleTypeId).description}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.triggersActionsUI.ruleDetails.conditionsTitle', {
defaultMessage: 'Conditions',
})}
</ItemTitleRuleSummary>
<EuiFlexItem grow={3}>
<EuiFlexGroup data-test-subj="ruleSummaryRuleConditions" alignItems="center">
{hasEditButton ? (
<EuiButtonEmpty onClick={() => setEditFlyoutVisible(true)}>
<EuiText size="s">{getRuleConditionsWording()}</EuiText>
</EuiButtonEmpty>
) : (
<EuiText size="s">{getRuleConditionsWording()}</EuiText>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.triggersActionsUI.ruleDetails.runsEvery', {
defaultMessage: 'Runs every',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary
data-test-subj="ruleSummaryRuleInterval"
itemValue={formatDuration(rule.schedule.interval)}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<ItemTitleRuleSummary>
{i18n.translate('xpack.triggersActionsUI.ruleDetails.notifyWhen', {
defaultMessage: 'Notify',
})}
</ItemTitleRuleSummary>
<ItemValueRuleSummary itemValue={String(getNotifyText())} />
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="baseline">
<ItemTitleRuleSummary>
{i18n.translate('xpack.triggersActionsUI.ruleDetails.actions', {
defaultMessage: 'Actions',
})}
</ItemTitleRuleSummary>
<EuiFlexItem grow={3}>
<RuleActions ruleActions={rule.actions} actionTypeRegistry={actionTypeRegistry} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
{editFlyoutVisible && (
<RuleEdit
onSave={() => {
setEditFlyoutVisible(false);
return onEditRule();
}}
initialRule={rule}
onClose={() => setEditFlyoutVisible(false)}
ruleTypeRegistry={ruleTypeRegistry}
actionTypeRegistry={actionTypeRegistry}
/>
)}
</EuiFlexItem>
);
};
export interface ItemTitleRuleSummaryProps {
children: string;
}
export interface ItemValueRuleSummaryProps {
itemValue: string;
extraSpace?: boolean;
}
function ItemValueRuleSummary({
itemValue,
extraSpace = true,
...otherProps
}: ItemValueRuleSummaryProps) {
return (
<EuiFlexItem grow={extraSpace ? 3 : 1} {...otherProps}>
<EuiText size="s">{itemValue}</EuiText>
</EuiFlexItem>
);
}
function ItemTitleRuleSummary({ children }: ItemTitleRuleSummaryProps) {
return (
<EuiTitle size="xxs">
<EuiFlexItem style={{ whiteSpace: 'nowrap' }} grow={1}>
{children}
</EuiFlexItem>
</EuiTitle>
);
}
// eslint-disable-next-line import/no-default-export
export { RuleDefinition as default };

View file

@ -0,0 +1,10 @@
/*
* 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 { RuleDefinition } from '../application/sections';
import { RuleDefinitionProps } from '../types';
export const getRuleDefinitionLazy = (props: RuleDefinitionProps) => <RuleDefinition {...props} />;

View file

@ -39,6 +39,7 @@ export type {
RuleEventLogListProps,
AlertTableFlyoutComponent,
GetRenderCellValue,
RuleDefinitionProps,
} from './types';
export {

View file

@ -38,6 +38,7 @@ import { CreateConnectorFlyoutProps } from './application/sections/action_connec
import { EditConnectorFlyoutProps } from './application/sections/action_connector_form/edit_connector_flyout';
import { getActionFormLazy } from './common/get_action_form';
import { ActionAccordionFormProps } from './application/sections/action_connector_form/action_form';
import { getRuleDefinitionLazy } from './common/get_rule_definition';
import { getRuleStatusPanelLazy } from './common/get_rule_status_panel';
function createStartMock(): TriggersAndActionsUIPublicPluginStart {
@ -105,6 +106,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart {
getRulesList: () => {
return getRulesListLazy({ connectorServices });
},
getRuleDefinition: (props) => {
return getRuleDefinitionLazy({ ...props, actionTypeRegistry, ruleTypeRegistry });
},
getRuleStatusPanel: (props) => {
return getRuleStatusPanelLazy(props);
},

View file

@ -62,6 +62,7 @@ import type {
CreateConnectorFlyoutProps,
EditConnectorFlyoutProps,
ConnectorServices,
RuleDefinitionProps,
} from './types';
import { TriggersActionsUiConfigType } from '../common/types';
import { registerAlertsTableConfiguration } from './application/sections/alerts_table/alerts_page/register_alerts_table_configuration';
@ -69,6 +70,7 @@ import { PLUGIN_ID } from './common/constants';
import type { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state';
import { getAlertsTableStateLazy } from './common/get_alerts_table_state';
import { ActionAccordionFormProps } from './application/sections/action_connector_form/action_form';
import { getRuleDefinitionLazy } from './common/get_rule_definition';
import { RuleStatusPanelProps } from './application/sections/rule_details/components/rule_status_panel';
export interface TriggersAndActionsUIPublicPluginSetup {
@ -109,6 +111,7 @@ export interface TriggersAndActionsUIPublicPluginStart {
props: RulesListNotifyBadgeProps
) => ReactElement<RulesListNotifyBadgeProps>;
getRulesList: () => ReactElement;
getRuleDefinition: (props: RuleDefinitionProps) => ReactElement<RuleDefinitionProps>;
getRuleStatusPanel: (props: RuleStatusPanelProps) => ReactElement<RuleStatusPanelProps>;
}
@ -323,6 +326,15 @@ export class Plugin
getRulesList: () => {
return getRulesListLazy({ connectorServices: this.connectorServices! });
},
getRuleDefinition: (
props: Omit<RuleDefinitionProps, 'actionTypeRegistry' | 'ruleTypeRegistry'>
) => {
return getRuleDefinitionLazy({
...props,
actionTypeRegistry: this.actionTypeRegistry,
ruleTypeRegistry: this.ruleTypeRegistry,
});
},
getRuleStatusPanel: (props: RuleStatusPanelProps) => {
return getRuleStatusPanelLazy(props);
},

View file

@ -343,6 +343,12 @@ export interface RuleAddProps<MetaData = Record<string, any>> {
ruleTypeIndex?: RuleTypeIndex;
filteredSolutions?: string[] | undefined;
}
export interface RuleDefinitionProps {
rule: Rule;
ruleTypeRegistry: RuleTypeRegistryContract;
actionTypeRegistry: ActionTypeRegistryContract;
onEditRule: () => Promise<void>;
}
export enum Percentiles {
P50 = 'P50',