[Alerting] Displays warning when a permanent encryption key is missing and hides alerting UI appropriately (#62772)

Removes the Security flyout and instead replaces the Alerting List, Connectors List and Alert Flyout with suitable messaging.
Verifies that a permanent Encryption Key has been configured and if it hasn't displays a suitable warning in place, or along side the TLS warning, as needed.
This commit is contained in:
Gidi Meir Morris 2020-04-08 22:36:33 +01:00 committed by GitHub
parent 578e443bdd
commit e3bd04fcb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 663 additions and 563 deletions

View file

@ -17,6 +17,7 @@ export interface ActionGroup {
export interface AlertingFrameworkHealth {
isSufficientlySecure: boolean;
hasPermanentEncryptionKey: boolean;
}
export const BASE_ALERT_API_PATH = '/api/alert';

View file

@ -190,7 +190,7 @@ export class AlertingPlugin {
unmuteAllAlertRoute(router, this.licenseState);
muteAlertInstanceRoute(router, this.licenseState);
unmuteAlertInstanceRoute(router, this.licenseState);
healthRoute(router, this.licenseState);
healthRoute(router, this.licenseState, plugins.encryptedSavedObjects);
return {
registerType: alertTypeRegistry.register.bind(alertTypeRegistry),

View file

@ -10,6 +10,7 @@ import { mockHandlerArguments } from './_mock_handler_arguments';
import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks';
import { verifyApiAccess } from '../lib/license_api_access';
import { mockLicenseState } from '../lib/license_state.mock';
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
jest.mock('../lib/license_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
@ -24,7 +25,9 @@ describe('healthRoute', () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = false;
healthRoute(router, licenseState, encryptedSavedObjects);
const [config] = router.get.mock.calls[0];
@ -35,7 +38,9 @@ describe('healthRoute', () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = false;
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
@ -58,11 +63,13 @@ describe('healthRoute', () => {
`);
});
it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => {
it('evaluates whether Encrypted Saved Objects is using an ephemeral encryption key', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = true;
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
@ -73,6 +80,31 @@ describe('healthRoute', () => {
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": false,
"isSufficientlySecure": true,
},
}
`);
});
it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = false;
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({}));
const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": true,
},
}
@ -83,7 +115,9 @@ describe('healthRoute', () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = false;
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
@ -94,6 +128,7 @@ describe('healthRoute', () => {
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": true,
},
}
@ -104,7 +139,9 @@ describe('healthRoute', () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = false;
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
@ -117,6 +154,7 @@ describe('healthRoute', () => {
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": false,
},
}
@ -127,7 +165,9 @@ describe('healthRoute', () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = false;
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
@ -140,6 +180,7 @@ describe('healthRoute', () => {
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": false,
},
}
@ -150,7 +191,9 @@ describe('healthRoute', () => {
const router: RouterMock = mockRouter.create();
const licenseState = mockLicenseState();
healthRoute(router, licenseState);
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup();
encryptedSavedObjects.usingEphemeralEncryptionKey = false;
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const elasticsearch = elasticsearchServiceMock.createSetup();
@ -163,6 +206,7 @@ describe('healthRoute', () => {
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": true,
},
}

View file

@ -14,6 +14,7 @@ import {
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { AlertingFrameworkHealth } from '../types';
import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server';
interface XPackUsageSecurity {
security?: {
@ -26,7 +27,11 @@ interface XPackUsageSecurity {
};
}
export function healthRoute(router: IRouter, licenseState: LicenseState) {
export function healthRoute(
router: IRouter,
licenseState: LicenseState,
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
) {
router.get(
{
path: '/api/alert/_health',
@ -54,6 +59,7 @@ export function healthRoute(router: IRouter, licenseState: LicenseState) {
const frameworkHealth: AlertingFrameworkHealth = {
isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled),
hasPermanentEncryptionKey: !encryptedSavedObjects.usingEphemeralEncryptionKey,
};
return res.ok({

View file

@ -15864,8 +15864,6 @@
"xpack.triggersActionsUI.common.expressionItems.threshold.andLabel": "AND",
"xpack.triggersActionsUI.common.expressionItems.threshold.descriptionLabel": "タイミング",
"xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "タイミング",
"xpack.triggersActionsUI.components.alertActionSecurityCallOut.enableTlsCta": "TLS を有効にする",
"xpack.triggersActionsUI.components.alertActionSecurityCallOut.tlsDisabledTitle": "アラート {action} を実行するには Elasticsearch と Kibana の間に TLS が必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "メールに送信",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.addVariablePopoverButton": "変数を追加",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText": "サーバーからメールを送信します。",
@ -15960,9 +15958,6 @@
"xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.viewHeadersSwitch": "HTTP ヘッダーを追加",
"xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText": "{numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}を削除できませんでした",
"xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText": "{numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}を削除しました",
"xpack.triggersActionsUI.components.securityCallOut.enableTlsCta": "TLS を有効にする",
"xpack.triggersActionsUI.components.securityCallOut.tlsDisabledDescription": "アラートは API キー に依存し、キーを使用するには Elasticsearch と Kibana の間に TLS が必要です。",
"xpack.triggersActionsUI.components.securityCallOut.tlsDisabledTitle": "トランスポートレイヤーセキュリティを有効にする",
"xpack.triggersActionsUI.connectors.breadcrumbTitle": "コネクター",
"xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.cancelButtonLabel": "キャンセル",
"xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.deleteButtonLabel": "{numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}}を削除 ",
@ -15986,8 +15981,8 @@
"xpack.triggersActionsUI.sections.actionConnectorForm.error.requiredNameText": "名前が必要です。",
"xpack.triggersActionsUI.sections.actionForm.getMoreActionsTitle": "さらにアクションを表示",
"xpack.triggersActionsUI.sections.actionsConnectorsList.addActionButtonLabel": "コネクターを作成",
"xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyBody": "Kibana でトリガーできるメール、Slack, Elasticsearch、およびサードパーティサービスを構成します。",
"xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyTitle": "初めてのコネクターを作成する",
"xpack.triggersActionsUI.components.emptyConnectorsPrompt.addActionEmptyBody": "Kibana でトリガーできるメール、Slack, Elasticsearch、およびサードパーティサービスを構成します。",
"xpack.triggersActionsUI.components.emptyConnectorsPrompt.addActionEmptyTitle": "初めてのコネクターを作成する",
"xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle": "コネクターを削除できません",
"xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteLabel": "{count} 件を削除",
"xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription": "このコネクターを削除",
@ -16037,7 +16032,6 @@
"xpack.triggersActionsUI.sections.alertAdd.saveButtonLabel": "保存",
"xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText": "アラートを作成できません。",
"xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText": "「{alertName}」 を保存しました",
"xpack.triggersActionsUI.sections.alertAdd.securityCalloutAction": "作成",
"xpack.triggersActionsUI.sections.alertAdd.selectIndex": "インデックスを選択してください。",
"xpack.triggersActionsUI.sections.alertAdd.threshold.closeIndexPopoverLabel": "閉じる",
"xpack.triggersActionsUI.sections.alertAdd.threshold.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。",
@ -16074,7 +16068,6 @@
"xpack.triggersActionsUI.sections.alertEdit.saveButtonLabel": "保存",
"xpack.triggersActionsUI.sections.alertEdit.saveErrorNotificationText": "アラートを更新できません。",
"xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText": "「{alertName}」 を更新しました",
"xpack.triggersActionsUI.sections.alertEdit.securityCalloutAction": "編集中",
"xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel": "削除",
"xpack.triggersActionsUI.sections.alertForm.actionDisabledTitle": "このアクションは無効です",
"xpack.triggersActionsUI.sections.alertForm.actionIdLabel": "{connectorInstance} コネクター",
@ -16126,9 +16119,9 @@
"xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.enableTitle": "有効にする",
"xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.muteTitle": "ミュート",
"xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.popoverButtonTitle": "アクション",
"xpack.triggersActionsUI.sections.alertsList.emptyButton": "アラートの作成",
"xpack.triggersActionsUI.sections.alertsList.emptyDesc": "トリガーが起きたときにメール、Slack、または別のコネクターを通してアラートを受信します。",
"xpack.triggersActionsUI.sections.alertsList.emptyTitle": "初めてのアラートを作成する",
"xpack.triggersActionsUI.components.emptyPrompt.emptyButton": "アラートの作成",
"xpack.triggersActionsUI.components.emptyPrompt.emptyDesc": "トリガーが起きたときにメール、Slack、または別のコネクターを通してアラートを受信します。",
"xpack.triggersActionsUI.components.emptyPrompt.emptyTitle": "初めてのアラートを作成する",
"xpack.triggersActionsUI.sections.alertsList.multipleTitle": "アラート",
"xpack.triggersActionsUI.sections.alertsList.searchPlaceholderTitle": "検索",
"xpack.triggersActionsUI.sections.alertsList.singleTitle": "アラート",

View file

@ -15868,8 +15868,6 @@
"xpack.triggersActionsUI.common.expressionItems.threshold.andLabel": "且",
"xpack.triggersActionsUI.common.expressionItems.threshold.descriptionLabel": "当",
"xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "当",
"xpack.triggersActionsUI.components.alertActionSecurityCallOut.enableTlsCta": "启用 TLS",
"xpack.triggersActionsUI.components.alertActionSecurityCallOut.tlsDisabledTitle": "告警 {action} 在 Elasticsearch 和 Kibana 之间需要 TLS。",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "发送到电子邮件",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.addVariablePopoverButton": "添加变量",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText": "从您的服务器发送电子邮件。",
@ -15964,9 +15962,6 @@
"xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.viewHeadersSwitch": "添加 HTTP 标头",
"xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText": "无法删除 {numErrors, number} 个{numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}",
"xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText": "已删除 {numSuccesses, number} 个{numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}",
"xpack.triggersActionsUI.components.securityCallOut.enableTlsCta": "启用 TLS",
"xpack.triggersActionsUI.components.securityCallOut.tlsDisabledDescription": "Alerting 依赖于在 Elasticsearch 和 Kibana 之间需要 TLS 的 API 密钥。",
"xpack.triggersActionsUI.components.securityCallOut.tlsDisabledTitle": "启用传输层安全",
"xpack.triggersActionsUI.connectors.breadcrumbTitle": "连接器",
"xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.cancelButtonLabel": "取消",
"xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.deleteButtonLabel": "删除{numIdsToDelete, plural, one {{singleTitle}} other { # 个{multipleTitle}}} ",
@ -15991,8 +15986,8 @@
"xpack.triggersActionsUI.sections.actionConnectorForm.error.requiredNameText": "名称必填。",
"xpack.triggersActionsUI.sections.actionForm.getMoreActionsTitle": "获取更多的操作",
"xpack.triggersActionsUI.sections.actionsConnectorsList.addActionButtonLabel": "创建连接器",
"xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyBody": "配置电子邮件、Slack、Elasticsearch 和 Kibana 可以触发的第三方服务。",
"xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyTitle": "创建您的首个连接器",
"xpack.triggersActionsUI.components.emptyConnectorsPrompt.addActionEmptyBody": "配置电子邮件、Slack、Elasticsearch 和 Kibana 可以触发的第三方服务。",
"xpack.triggersActionsUI.components.emptyConnectorsPrompt.addActionEmptyTitle": "创建您的首个连接器",
"xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle": "无法删除连接器",
"xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteLabel": "删除 {count} 个",
"xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription": "删除此连接器",
@ -16042,7 +16037,6 @@
"xpack.triggersActionsUI.sections.alertAdd.saveButtonLabel": "保存",
"xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText": "无法创建告警。",
"xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText": "已保存“{alertName}”",
"xpack.triggersActionsUI.sections.alertAdd.securityCalloutAction": "创建",
"xpack.triggersActionsUI.sections.alertAdd.selectIndex": "选择索引。",
"xpack.triggersActionsUI.sections.alertAdd.threshold.closeIndexPopoverLabel": "关闭",
"xpack.triggersActionsUI.sections.alertAdd.threshold.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。",
@ -16079,7 +16073,6 @@
"xpack.triggersActionsUI.sections.alertEdit.saveButtonLabel": "保存",
"xpack.triggersActionsUI.sections.alertEdit.saveErrorNotificationText": "无法更新告警。",
"xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText": "已更新“{alertName}”",
"xpack.triggersActionsUI.sections.alertEdit.securityCalloutAction": "正在编辑",
"xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel": "删除",
"xpack.triggersActionsUI.sections.alertForm.actionDisabledTitle": "此操作已禁用",
"xpack.triggersActionsUI.sections.alertForm.actionIdLabel": "{connectorInstance} 连接器",
@ -16131,9 +16124,9 @@
"xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.enableTitle": "启用",
"xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.muteTitle": "静音",
"xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.popoverButtonTitle": "操作",
"xpack.triggersActionsUI.sections.alertsList.emptyButton": "创建告警",
"xpack.triggersActionsUI.sections.alertsList.emptyDesc": "触发条件满足时通过电子邮件、Slack 或其他连接器接收告警。",
"xpack.triggersActionsUI.sections.alertsList.emptyTitle": "创建您的首个告警",
"xpack.triggersActionsUI.components.emptyPrompt.emptyButton": "创建告警",
"xpack.triggersActionsUI.components.emptyPrompt.emptyDesc": "触发条件满足时通过电子邮件、Slack 或其他连接器接收告警。",
"xpack.triggersActionsUI.components.emptyPrompt.emptyTitle": "创建您的首个告警",
"xpack.triggersActionsUI.sections.alertsList.multipleTitle": "告警",
"xpack.triggersActionsUI.sections.alertsList.searchPlaceholderTitle": "搜索",
"xpack.triggersActionsUI.sections.alertsList.singleTitle": "告警",

View file

@ -1,78 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { AlertActionSecurityCallOut } from './alert_action_security_call_out';
import { EuiCallOut, EuiButton } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' };
const http = httpServiceMock.createStartContract();
describe('alert action security call out', () => {
let useEffect: any;
const mockUseEffect = () => {
// make react execute useEffects despite shallow rendering
useEffect.mockImplementationOnce((f: Function) => f());
};
beforeEach(() => {
jest.resetAllMocks();
useEffect = jest.spyOn(React, 'useEffect');
mockUseEffect();
});
test('renders nothing while health is loading', async () => {
http.get.mockImplementationOnce(() => new Promise(() => {}));
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(
<AlertActionSecurityCallOut action="created" http={http} docLinks={docLinks} />
);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders nothing if keys are enabled', async () => {
http.get.mockResolvedValue({ isSufficientlySecure: true });
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(
<AlertActionSecurityCallOut action="created" http={http} docLinks={docLinks} />
);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders the callout if keys are disabled', async () => {
http.get.mockResolvedValue({ isSufficientlySecure: false });
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(
<AlertActionSecurityCallOut action="creation" http={http} docLinks={docLinks} />
);
});
expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot(
`"Alert creation requires TLS between Elasticsearch and Kibana."`
);
expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot(
`"elastic.co/guide/en/kibana/current/configuring-tls.html"`
);
});
});

View file

@ -1,78 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { Option, none, some, fold, filter } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DocLinksStart, HttpSetup } from 'kibana/public';
import { AlertingFrameworkHealth } from '../../types';
import { health } from '../lib/alert_api';
interface Props {
docLinks: Pick<DocLinksStart, 'ELASTIC_WEBSITE_URL' | 'DOC_LINK_VERSION'>;
action: string;
http: HttpSetup;
}
export const AlertActionSecurityCallOut: React.FunctionComponent<Props> = ({
http,
action,
docLinks,
}) => {
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
const [alertingHealth, setAlertingHealth] = React.useState<Option<AlertingFrameworkHealth>>(none);
React.useEffect(() => {
async function fetchSecurityConfigured() {
setAlertingHealth(some(await health({ http })));
}
fetchSecurityConfigured();
}, [http]);
return pipe(
alertingHealth,
filter(healthCheck => !healthCheck.isSufficientlySecure),
fold(
() => <Fragment />,
() => (
<Fragment>
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.components.alertActionSecurityCallOut.tlsDisabledTitle',
{
defaultMessage: 'Alert {action} requires TLS between Elasticsearch and Kibana.',
values: {
action,
},
}
)}
color="warning"
size="s"
iconType="iInCircle"
>
<EuiButton
color="warning"
href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`}
>
<FormattedMessage
id="xpack.triggersActionsUI.components.alertActionSecurityCallOut.enableTlsCta"
defaultMessage="Enable TLS"
/>
</EuiButton>
</EuiCallOut>
<EuiSpacer />
</Fragment>
)
)
);
};

View file

@ -0,0 +1,13 @@
@mixin padBannerWith($size) {
padding-left: $size;
padding-right: $size;
}
.alertingHealthCheck__body {
@include padBannerWith(2 * $euiSize);
}
.alertingFlyoutHealthCheck__body {
@include padBannerWith(2 * $euiSize);
margin-top: $euiSize;
}

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { HealthCheck } from './health_check';
import { act } from 'react-dom/test-utils';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' };
const http = httpServiceMock.createStartContract();
describe('health check', () => {
test('renders spinner while health is loading', async () => {
http.get.mockImplementationOnce(() => new Promise(() => {}));
const { queryByText, container } = render(
<HealthCheck http={http} docLinks={docLinks}>
<p>{'shouldnt render'}</p>
</HealthCheck>
);
await act(async () => {
// wait for useEffect to run
});
expect(container.getElementsByClassName('euiLoadingSpinner').length).toBe(1);
expect(queryByText('shouldnt render')).not.toBeInTheDocument();
});
it('renders children if keys are enabled', async () => {
http.get.mockResolvedValue({ isSufficientlySecure: true, hasPermanentEncryptionKey: true });
const { queryByText } = render(
<HealthCheck http={http} docLinks={docLinks}>
<p>{'should render'}</p>
</HealthCheck>
);
await act(async () => {
// wait for useEffect to run
});
expect(queryByText('should render')).toBeInTheDocument();
});
test('renders warning if keys are disabled', async () => {
http.get.mockImplementationOnce(async () => ({
isSufficientlySecure: false,
hasPermanentEncryptionKey: true,
}));
const { queryAllByText } = render(
<HealthCheck http={http} docLinks={docLinks}>
<p>{'should render'}</p>
</HealthCheck>
);
await act(async () => {
// wait for useEffect to run
});
const [description, action] = queryAllByText(/TLS/i);
expect(description.textContent).toMatchInlineSnapshot(
`"Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. Learn how to enable TLS."`
);
expect(action.textContent).toMatchInlineSnapshot(`"Learn how to enable TLS."`);
expect(action.getAttribute('href')).toMatchInlineSnapshot(
`"elastic.co/guide/en/kibana/current/configuring-tls.html"`
);
});
test('renders warning if encryption key is ephemeral', async () => {
http.get.mockImplementationOnce(async () => ({
isSufficientlySecure: true,
hasPermanentEncryptionKey: false,
}));
const { queryByText, queryByRole } = render(
<HealthCheck http={http} docLinks={docLinks}>
<p>{'should render'}</p>
</HealthCheck>
);
await act(async () => {
// wait for useEffect to run
});
const description = queryByRole(/banner/i);
expect(description!.textContent).toMatchInlineSnapshot(
`"To create an alert, set a value for xpack.encrypted_saved_objects.encryptionKey in your kibana.yml file. Learn how."`
);
const action = queryByText(/Learn/i);
expect(action!.textContent).toMatchInlineSnapshot(`"Learn how."`);
expect(action!.getAttribute('href')).toMatchInlineSnapshot(
`"elastic.co/guide/en/kibana/current/alert-action-settings-kb.html#general-alert-action-settings"`
);
});
test('renders warning if encryption key is ephemeral and keys are disabled', async () => {
http.get.mockImplementationOnce(async () => ({
isSufficientlySecure: false,
hasPermanentEncryptionKey: false,
}));
const { queryByText } = render(
<HealthCheck http={http} docLinks={docLinks}>
<p>{'should render'}</p>
</HealthCheck>
);
await act(async () => {
// wait for useEffect to run
});
const description = queryByText(/Transport Layer Security/i);
expect(description!.textContent).toMatchInlineSnapshot(
`"You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. Learn how"`
);
const action = queryByText(/Learn/i);
expect(action!.textContent).toMatchInlineSnapshot(`"Learn how"`);
expect(action!.getAttribute('href')).toMatchInlineSnapshot(
`"elastic.co/guide/en/kibana/current/alerting-getting-started.html#alerting-setup-prerequisites"`
);
});
});

View file

@ -0,0 +1,197 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { Option, none, some, fold } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiLink, EuiLoadingSpinner } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DocLinksStart, HttpSetup } from 'kibana/public';
import { EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import { AlertingFrameworkHealth } from '../../types';
import { health } from '../lib/alert_api';
import './health_check.scss';
interface Props {
docLinks: Pick<DocLinksStart, 'ELASTIC_WEBSITE_URL' | 'DOC_LINK_VERSION'>;
http: HttpSetup;
inFlyout?: boolean;
}
export const HealthCheck: React.FunctionComponent<Props> = ({
docLinks,
http,
children,
inFlyout = false,
}) => {
const [alertingHealth, setAlertingHealth] = React.useState<Option<AlertingFrameworkHealth>>(none);
React.useEffect(() => {
(async function() {
setAlertingHealth(some(await health({ http })));
})();
}, [http]);
const className = inFlyout ? 'alertingFlyoutHealthCheck' : 'alertingHealthCheck';
return pipe(
alertingHealth,
fold(
() => <EuiLoadingSpinner size="m" />,
healthCheck => {
return healthCheck?.isSufficientlySecure && healthCheck?.hasPermanentEncryptionKey ? (
<Fragment>{children}</Fragment>
) : !healthCheck.isSufficientlySecure && !healthCheck.hasPermanentEncryptionKey ? (
<TlsAndEncryptionError docLinks={docLinks} className={className} />
) : !healthCheck.hasPermanentEncryptionKey ? (
<EncryptionError docLinks={docLinks} className={className} />
) : (
<TlsError docLinks={docLinks} className={className} />
);
}
)
);
};
type PromptErrorProps = Pick<Props, 'docLinks'> & {
className?: string;
};
const TlsAndEncryptionError = ({
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
className,
}: PromptErrorProps) => (
<EuiEmptyPrompt
iconType="watchesApp"
data-test-subj="actionNeededEmptyPrompt"
className={className}
titleSize="xs"
title={
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle"
defaultMessage="Additional setup required"
/>
</h2>
}
body={
<div className={`${className}__body`}>
<p role="banner">
{i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError', {
defaultMessage:
'You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. ',
})}
<EuiLink
href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alerting-getting-started.html#alerting-setup-prerequisites`}
external
target="_blank"
>
{i18n.translate(
'xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction',
{
defaultMessage: 'Learn how',
}
)}
</EuiLink>
</p>
</div>
}
/>
);
const EncryptionError = ({
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
className,
}: PromptErrorProps) => (
<EuiEmptyPrompt
iconType="watchesApp"
data-test-subj="actionNeededEmptyPrompt"
className={className}
titleSize="xs"
title={
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle"
defaultMessage="You must set an encryption key"
/>
</h2>
}
body={
<div className={`${className}__body`}>
<p role="banner">
{i18n.translate(
'xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey',
{
defaultMessage: 'To create an alert, set a value for ',
}
)}
<EuiCode>{'xpack.encrypted_saved_objects.encryptionKey'}</EuiCode>
{i18n.translate(
'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey',
{
defaultMessage: ' in your kibana.yml file. ',
}
)}
<EuiLink
href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`}
external
target="_blank"
>
{i18n.translate(
'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction',
{
defaultMessage: 'Learn how.',
}
)}
</EuiLink>
</p>
</div>
}
/>
);
const TlsError = ({
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
className,
}: PromptErrorProps) => (
<EuiEmptyPrompt
iconType="watchesApp"
data-test-subj="actionNeededEmptyPrompt"
className={className}
titleSize="xs"
title={
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle"
defaultMessage="You must enable Transport Layer Security"
/>
</h2>
}
body={
<div className={`${className}__body`}>
<p role="banner">
{i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsError', {
defaultMessage:
'Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. ',
})}
<EuiLink
href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`}
external
target="_blank"
>
{i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsErrorAction', {
defaultMessage: 'Learn how to enable TLS.',
})}
</EuiLink>
</p>
</div>
}
/>
);

View file

@ -0,0 +1,3 @@
.actEmptyConnectorsPrompt__logo + .actEmptyConnectorsPrompt__logo {
margin-left: $euiSize;
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Fragment } from 'react';
import { EuiButton, EuiEmptyPrompt, EuiIcon, EuiSpacer, EuiTitle } from '@elastic/eui';
import './empty_connectors_prompt.scss';
export const EmptyConnectorsPrompt = ({ onCTAClicked }: { onCTAClicked: () => void }) => (
<EuiEmptyPrompt
data-test-subj="createFirstConnectorEmptyPrompt"
title={
<Fragment>
<EuiIcon type="logoSlack" size="xl" className="actEmptyConnectorsPrompt__logo" />
<EuiIcon type="logoGmail" size="xl" className="actEmptyConnectorsPrompt__logo" />
<EuiIcon type="logoWebhook" size="xl" className="actEmptyConnectorsPrompt__logo" />
<EuiSpacer size="s" />
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.components.emptyConnectorsPrompt.addActionEmptyTitle"
defaultMessage="Create your first connector"
/>
</h2>
</EuiTitle>
</Fragment>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.components.emptyConnectorsPrompt.addActionEmptyBody"
defaultMessage="Configure email, Slack, Elasticsearch, and third-party services that Kibana can trigger."
/>
</p>
}
actions={
<EuiButton
data-test-subj="createFirstActionButton"
key="create-action"
fill
iconType="plusInCircle"
iconSide="left"
onClick={onCTAClicked}
>
<FormattedMessage
id="xpack.triggersActionsUI.components.emptyConnectorsPrompt.addActionButtonLabel"
defaultMessage="Create connector"
/>
</EuiButton>
}
/>
);

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
export const EmptyPrompt = ({ onCTAClicked }: { onCTAClicked: () => void }) => (
<EuiEmptyPrompt
iconType="watchesApp"
data-test-subj="createFirstAlertEmptyPrompt"
title={
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.components.emptyPrompt.emptyTitle"
defaultMessage="Create your first alert"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.components.emptyPrompt.emptyDesc"
defaultMessage="Receive an alert through email, Slack, or another connector when a trigger is hit."
/>
</p>
}
actions={
<EuiButton
data-test-subj="createFirstAlertButton"
key="create-action"
fill
iconType="plusInCircle"
iconSide="left"
onClick={onCTAClicked}
>
<FormattedMessage
id="xpack.triggersActionsUI.components.emptyPrompt.emptyButton"
defaultMessage="Create alert"
/>
</EuiButton>
}
/>
);

View file

@ -1,72 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { SecurityEnabledCallOut } from './security_call_out';
import { EuiCallOut, EuiButton } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' };
const http = httpServiceMock.createStartContract();
describe('security call out', () => {
let useEffect: any;
const mockUseEffect = () => {
// make react execute useEffects despite shallow rendering
useEffect.mockImplementationOnce((f: Function) => f());
};
beforeEach(() => {
jest.resetAllMocks();
useEffect = jest.spyOn(React, 'useEffect');
mockUseEffect();
});
test('renders nothing while health is loading', async () => {
http.get.mockImplementationOnce(() => new Promise(() => {}));
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(<SecurityEnabledCallOut http={http} docLinks={docLinks} />);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders nothing if keys are enabled', async () => {
http.get.mockResolvedValue({ isSufficientlySecure: true });
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(<SecurityEnabledCallOut http={http} docLinks={docLinks} />);
});
expect(component?.is(Fragment)).toBeTruthy();
expect(component?.html()).toBe('');
});
test('renders the callout if keys are disabled', async () => {
http.get.mockImplementationOnce(async () => ({ isSufficientlySecure: false }));
let component: ShallowWrapper | undefined;
await act(async () => {
component = shallow(<SecurityEnabledCallOut http={http} docLinks={docLinks} />);
});
expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot(
`"Enable Transport Layer Security"`
);
expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot(
`"elastic.co/guide/en/kibana/current/configuring-tls.html"`
);
});
});

View file

@ -1,75 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { Option, none, some, fold, filter } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DocLinksStart, HttpSetup } from 'kibana/public';
import { AlertingFrameworkHealth } from '../../types';
import { health } from '../lib/alert_api';
interface Props {
docLinks: Pick<DocLinksStart, 'ELASTIC_WEBSITE_URL' | 'DOC_LINK_VERSION'>;
http: HttpSetup;
}
export const SecurityEnabledCallOut: React.FunctionComponent<Props> = ({ docLinks, http }) => {
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
const [alertingHealth, setAlertingHealth] = React.useState<Option<AlertingFrameworkHealth>>(none);
React.useEffect(() => {
async function fetchSecurityConfigured() {
setAlertingHealth(some(await health({ http })));
}
fetchSecurityConfigured();
}, [http]);
return pipe(
alertingHealth,
filter(healthCheck => !healthCheck?.isSufficientlySecure),
fold(
() => <Fragment />,
() => (
<Fragment>
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.components.securityCallOut.tlsDisabledTitle',
{
defaultMessage: 'Enable Transport Layer Security',
}
)}
color="primary"
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.triggersActionsUI.components.securityCallOut.tlsDisabledDescription"
defaultMessage="Alerting relies on API keys, which require TLS between Elasticsearch and Kibana."
/>
</p>
<EuiButton
href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`}
>
<FormattedMessage
id="xpack.triggersActionsUI.components.securityCallOut.enableTlsCta"
defaultMessage="Enable TLS"
/>
</EuiButton>
</EuiCallOut>
<EuiSpacer />
</Fragment>
)
)
);
};

View file

@ -29,8 +29,8 @@ import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabil
import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list';
import { AlertsList } from './sections/alerts_list/components/alerts_list';
import { SecurityEnabledCallOut } from './components/security_call_out';
import { PLUGIN } from './constants/plugin';
import { HealthCheck } from './components/health_check';
interface MatchParams {
section: Section;
@ -88,7 +88,6 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
return (
<EuiPageBody>
<EuiPageContent>
<SecurityEnabledCallOut docLinks={docLinks} http={http} />
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="m">
@ -142,9 +141,27 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
<Switch>
{canShowActions && (
<Route exact path={routeToConnectors} component={ActionsConnectorsList} />
<Route
exact
path={routeToConnectors}
component={() => (
<HealthCheck docLinks={docLinks} http={http}>
<ActionsConnectorsList />
</HealthCheck>
)}
/>
)}
{canShowAlerts && (
<Route
exact
path={routeToAlerts}
component={() => (
<HealthCheck docLinks={docLinks} http={http}>
<AlertsList />
</HealthCheck>
)}
/>
)}
{canShowAlerts && <Route exact path={routeToAlerts} component={AlertsList} />}
</Switch>
</EuiPageContent>
</EuiPageBody>

View file

@ -1,7 +1,3 @@
.actConnectorsList__logo + .actConnectorsList__logo {
margin-left: $euiSize;
}
.actConnectorsList__tableRowDisabled {
background-color: $euiColorLightestShade;

View file

@ -10,9 +10,6 @@ import {
EuiInMemoryTable,
EuiSpacer,
EuiButton,
EuiIcon,
EuiEmptyPrompt,
EuiTitle,
EuiLink,
EuiLoadingSpinner,
EuiIconTip,
@ -30,6 +27,7 @@ import { ActionsConnectorsContextProvider } from '../../../context/actions_conne
import { checkActionTypeEnabled } from '../../../lib/check_action_type_enabled';
import './actions_connectors_list.scss';
import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types';
import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt';
export const ActionsConnectorsList: React.FunctionComponent = () => {
const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies();
@ -324,51 +322,6 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
/>
);
const emptyPrompt = (
<EuiEmptyPrompt
data-test-subj="createFirstConnectorEmptyPrompt"
title={
<Fragment>
<EuiIcon type="logoSlack" size="xl" className="actConnectorsList__logo" />
<EuiIcon type="logoGmail" size="xl" className="actConnectorsList__logo" />
<EuiIcon type="logoWebhook" size="xl" className="actConnectorsList__logo" />
<EuiSpacer size="s" />
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyTitle"
defaultMessage="Create your first connector"
/>
</h2>
</EuiTitle>
</Fragment>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyBody"
defaultMessage="Configure email, Slack, Elasticsearch, and third-party services that Kibana can trigger."
/>
</p>
}
actions={
<EuiButton
data-test-subj="createFirstActionButton"
key="create-action"
fill
iconType="plusInCircle"
iconSide="left"
onClick={() => setAddFlyoutVisibility(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionButtonLabel"
defaultMessage="Create connector"
/>
</EuiButton>
}
/>
);
const noPermissionPrompt = (
<h2>
<FormattedMessage
@ -420,7 +373,9 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
</EuiFlexGroup>
)}
{data.length !== 0 && table}
{data.length === 0 && canSave && !isLoadingActions && !isLoadingActionTypes && emptyPrompt}
{data.length === 0 && canSave && !isLoadingActions && !isLoadingActionTypes && (
<EmptyConnectorsPrompt onCTAClicked={() => setAddFlyoutVisibility(true)} />
)}
{data.length === 0 && !canSave && noPermissionPrompt}
<ActionsConnectorsContextProvider
value={{

View file

@ -53,7 +53,10 @@ describe('alert_add', () => {
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' },
};
mockes.http.get.mockResolvedValue({ isSufficientlySecure: true });
mockes.http.get.mockResolvedValue({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
});
const alertType = {
id: 'my-alert-type',

View file

@ -25,7 +25,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types';
import { AlertForm, validateBaseProperties } from './alert_form';
import { alertReducer } from './alert_reducer';
import { createAlert } from '../../lib/alert_api';
import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out';
import { HealthCheck } from '../../components/health_check';
import { PLUGIN } from '../../constants/plugin';
interface AlertAddProps {
@ -154,62 +154,54 @@ export const AlertAdd = ({
</h3>
</EuiTitle>
</EuiFlyoutHeader>
<AlertActionSecurityCallOut
docLinks={docLinks}
action={i18n.translate(
'xpack.triggersActionsUI.sections.alertAdd.securityCalloutAction',
{
defaultMessage: 'creation',
}
)}
http={http}
/>
<EuiFlyoutBody>
<AlertForm
alert={alert}
dispatch={dispatch}
errors={errors}
canChangeTrigger={canChangeTrigger}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelSaveAlertButton" onClick={closeFlyout}>
{i18n.translate('xpack.triggersActionsUI.sections.alertAdd.cancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveAlertButton"
type="submit"
iconType="check"
isDisabled={hasErrors || hasActionErrors}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);
const savedAlert = await onSaveAlert();
setIsSaving(false);
if (savedAlert) {
closeFlyout();
if (reloadAlerts) {
reloadAlerts();
<HealthCheck docLinks={docLinks} http={http} inFlyout={true}>
<EuiFlyoutBody>
<AlertForm
alert={alert}
dispatch={dispatch}
errors={errors}
canChangeTrigger={canChangeTrigger}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelSaveAlertButton" onClick={closeFlyout}>
{i18n.translate('xpack.triggersActionsUI.sections.alertAdd.cancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveAlertButton"
type="submit"
iconType="check"
isDisabled={hasErrors || hasActionErrors}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);
const savedAlert = await onSaveAlert();
setIsSaving(false);
if (savedAlert) {
closeFlyout();
if (reloadAlerts) {
reloadAlerts();
}
}
}
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</HealthCheck>
</EuiFlyout>
</EuiPortal>
);

View file

@ -36,7 +36,10 @@ describe('alert_edit', () => {
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' },
};
mockedCoreSetup.http.get.mockResolvedValue({ isSufficientlySecure: true });
mockedCoreSetup.http.get.mockResolvedValue({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
});
const alertType = {
id: 'my-alert-type',

View file

@ -26,7 +26,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types';
import { AlertForm, validateBaseProperties } from './alert_form';
import { alertReducer } from './alert_reducer';
import { updateAlert } from '../../lib/alert_api';
import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out';
import { HealthCheck } from '../../components/health_check';
import { PLUGIN } from '../../constants/plugin';
interface AlertEditProps {
@ -137,77 +137,69 @@ export const AlertEdit = ({
</h3>
</EuiTitle>
</EuiFlyoutHeader>
<AlertActionSecurityCallOut
docLinks={docLinks}
action={i18n.translate(
'xpack.triggersActionsUI.sections.alertEdit.securityCalloutAction',
{
defaultMessage: 'editing',
}
)}
http={http}
/>
<EuiFlyoutBody>
{hasActionsDisabled && (
<Fragment>
<EuiCallOut
size="s"
color="danger"
iconType="alert"
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertEdit.disabledActionsWarningTitle',
{ defaultMessage: 'This alert has actions that are disabled' }
)}
/>
<EuiSpacer />
</Fragment>
)}
<AlertForm
alert={alert}
dispatch={dispatch}
errors={errors}
canChangeTrigger={false}
setHasActionsDisabled={setHasActionsDisabled}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelSaveEditedAlertButton" onClick={closeFlyout}>
{i18n.translate('xpack.triggersActionsUI.sections.alertEdit.cancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveEditedAlertButton"
type="submit"
iconType="check"
isDisabled={hasErrors || hasActionErrors}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);
const savedAlert = await onSaveAlert();
setIsSaving(false);
if (savedAlert) {
closeFlyout();
if (reloadAlerts) {
reloadAlerts();
}
}
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertEdit.saveButtonLabel"
defaultMessage="Save"
<HealthCheck docLinks={docLinks} http={http} inFlyout={true}>
<EuiFlyoutBody>
{hasActionsDisabled && (
<Fragment>
<EuiCallOut
size="s"
color="danger"
iconType="alert"
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertEdit.disabledActionsWarningTitle',
{ defaultMessage: 'This alert has actions that are disabled' }
)}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
<EuiSpacer />
</Fragment>
)}
<AlertForm
alert={alert}
dispatch={dispatch}
errors={errors}
canChangeTrigger={false}
setHasActionsDisabled={setHasActionsDisabled}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelSaveEditedAlertButton" onClick={closeFlyout}>
{i18n.translate('xpack.triggersActionsUI.sections.alertEdit.cancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveEditedAlertButton"
type="submit"
iconType="check"
isDisabled={hasErrors || hasActionErrors}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);
const savedAlert = await onSaveAlert();
setIsSaving(false);
if (savedAlert) {
closeFlyout();
if (reloadAlerts) {
reloadAlerts();
}
}
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertEdit.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</HealthCheck>
</EuiFlyout>
</EuiPortal>
);

View file

@ -15,7 +15,6 @@ import {
EuiFlexItem,
EuiIcon,
EuiSpacer,
EuiEmptyPrompt,
EuiLink,
EuiLoadingSpinner,
} from '@elastic/eui';
@ -36,6 +35,7 @@ import { loadActionTypes } from '../../../lib/action_connector_api';
import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities';
import { routeToAlertDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants';
import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation';
import { EmptyPrompt } from '../../../components/prompts/empty_prompt';
const ENTER_KEY = 13;
@ -292,44 +292,6 @@ export const AlertsList: React.FunctionComponent = () => {
);
}
const emptyPrompt = (
<EuiEmptyPrompt
iconType="watchesApp"
data-test-subj="createFirstAlertEmptyPrompt"
title={
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.emptyTitle"
defaultMessage="Create your first alert"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.emptyDesc"
defaultMessage="Receive an alert through email, Slack, or another connector when a trigger is hit."
/>
</p>
}
actions={
<EuiButton
data-test-subj="createFirstAlertButton"
key="create-action"
fill
iconType="plusInCircle"
iconSide="left"
onClick={() => setAlertFlyoutVisibility(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.emptyButton"
defaultMessage="Create alert"
/>
</EuiButton>
}
/>
);
const table = (
<Fragment>
<EuiFlexGroup gutterSize="s">
@ -473,7 +435,7 @@ export const AlertsList: React.FunctionComponent = () => {
</EuiFlexItem>
</EuiFlexGroup>
) : (
emptyPrompt
<EmptyPrompt onCTAClicked={() => setAlertFlyoutVisibility(true)} />
)}
<AlertsContextProvider
value={{