[Synthetics] Add synthetics settings alerting default (#147339)

Co-authored-by: Alejandro Fernández Gómez <antarticonorte@gmail.com>
Fixes https://github.com/elastic/kibana/issues/145402
This commit is contained in:
Shahzad 2022-12-14 15:41:29 +01:00 committed by GitHub
parent ac6f2fb782
commit edc8624651
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1040 additions and 12 deletions

View file

@ -16,6 +16,8 @@ import {
} from '@kbn/triggers-actions-ui-plugin/public';
import { EmailActionParams } from '../../types';
const noop = () => {};
export const EmailParamsFields = ({
actionParams,
editAction,
@ -25,6 +27,7 @@ export const EmailParamsFields = ({
defaultMessage,
isLoading,
isDisabled,
onBlur = noop,
showEmailSubjectAndMessage = true,
}: ActionParamsProps<EmailActionParams>) => {
const { to, cc, bcc, subject, message } = actionParams;
@ -114,6 +117,7 @@ export const EmailParamsFields = ({
if (!to) {
editAction('to', [], index);
}
onBlur('to');
}}
/>
</EuiFormRow>
@ -156,6 +160,7 @@ export const EmailParamsFields = ({
if (!cc) {
editAction('cc', [], index);
}
onBlur('cc');
}}
/>
</EuiFormRow>
@ -199,6 +204,7 @@ export const EmailParamsFields = ({
if (!bcc) {
editAction('bcc', [], index);
}
onBlur('bcc');
}}
/>
</EuiFormRow>

View file

@ -12,4 +12,9 @@ export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettings = {
certAgeThreshold: 730,
certExpirationThreshold: 30,
defaultConnectors: [],
defaultEmail: {
to: [],
cc: [],
bcc: [],
},
};

View file

@ -0,0 +1,151 @@
/*
* 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 { journey, step, expect, before, after } from '@elastic/synthetics';
import { byTestId } from '@kbn/observability-plugin/e2e/utils';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics_app';
import { cleanSettings } from './services/settings';
journey('AlertingDefaults', async ({ page, params }) => {
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
page.setDefaultTimeout(60 * 1000);
before(async () => {
await cleanSettings(params);
});
after(async () => {
await cleanSettings(params);
});
step('Login to kibana', async () => {
await page.goto('http://localhost:5620/login?next=%2F');
await syntheticsApp.loginToKibana();
});
step('Go to Settings page', async () => {
await page.click('[aria-label="Toggle primary navigation"]');
await page.click('text=Synthetics');
await page.click('text=Settings');
});
step('Click text=Synthetics', async () => {
await page.click('text=Synthetics');
await page.click('text=Settings');
expect(page.url()).toBe('http://localhost:5620/app/synthetics/settings/alerting');
await page.click('.euiComboBox__inputWrap');
await page.click("text=There aren't any options available");
await page.click('button:has-text("Add connector")');
await page.click('p:has-text("Slack")');
await page.click('input[type="text"]');
await page.fill('input[type="text"]', 'Test slack');
await page.press('input[type="text"]', 'Tab');
});
step(
'Fill text=Webhook URLCreate a Slack Webhook URL(opens in a new tab or window) >> input[type="text"]',
async () => {
await page.fill(
'text=Webhook URLCreate a Slack Webhook URL(opens in a new tab or window) >> input[type="text"]',
'https://www.slack.com'
);
await page.click('button:has-text("Save")');
await page.click('.euiComboBox__inputWrap');
await page.click('button[role="option"]:has-text("Test slack")');
await page.click("text=You've selected all available options");
await page.click('button:has-text("Apply changes")');
await page.click('[aria-label="Remove Test slack from selection in this group"]');
await page.isDisabled('button:has-text("Discard changes")');
await page.click('button:has-text("Add connector")');
}
);
step('Click text=Email', async () => {
await page.click('text=Email');
await page.click('input[type="text"]');
await page.fill('input[type="text"]', 'Test email');
await page.press('input[type="text"]', 'Tab');
await page.selectOption('select', 'gmail');
await page.click('text=UsernamePassword >> input[type="text"]');
await page.fill('text=UsernamePassword >> input[type="text"]', 'elastic');
await page.press('text=UsernamePassword >> input[type="text"]', 'Tab');
await page.fill('input[type="password"]', 'changeme');
await page.click('button:has-text("Save")');
await page.click(
'text=Sender is required.Configure email accounts(opens in a new tab or window) >> input[type="text"]'
);
await page.fill(
'text=Sender is required.Configure email accounts(opens in a new tab or window) >> input[type="text"]',
'test@gmail.com'
);
await page.click('button:has-text("Save")');
});
step('Click .euiComboBox__inputWrap', async () => {
await page.click('.euiComboBox__inputWrap');
await page.click('button[role="option"]:has-text("Test email")');
await page.click(byTestId('toEmailAddressInput'));
await page.fill(
'text=To CcBccCombo box. Selected. Combo box input. Type some text or, to display a li >> input[role="combobox"]',
'test@gmail.com'
);
await page.keyboard.press('Enter');
await page.fill(
'text=test@gmail.comCombo box. Selected. test@gmail.com. Press Backspace to delete tes >> input[role="combobox"]',
'tesyt'
);
await page.keyboard.press('Enter');
await page.click('[aria-label="Remove tesyt from selection in this group"]');
await page.click('button:has-text("Cc")');
await page.click(byTestId('ccEmailAddressInput'));
await page.fill(`${byTestId('ccEmailAddressInput')} >> input[role="combobox"]`, 'wow');
await page.keyboard.press('Enter');
});
step('Click text=wow is not a valid email.', async () => {
await page.click('text=wow is not a valid email.');
await page.click('text=wowwow is not a valid email. >> [aria-label="Clear input"]');
await page.fill(`${byTestId('ccEmailAddressInput')} >> input[role="combobox"]`, 'list');
await page.click(
'text=Default emailEmail settings required for selected email alert connectors.To Bcct'
);
await page.click('[aria-label="Remove list from selection in this group"]');
await page.click(
'text=Default emailEmail settings required for selected email alert connectors.To Bcct'
);
await page.click('text=To Bcctest@gmail.com >> [aria-label="Clear input"]');
await page.click('.euiForm');
await page.click('text=To: Email is required for selected email connector');
});
step(
'Click .euiComboBox.euiComboBox--fullWidth.euiComboBox-isInvalid .euiFormControlLayout .euiFormControlLayout__childrenWrapper .euiComboBox__inputWrap',
async () => {
await page.click(
'.euiComboBox.euiComboBox--fullWidth.euiComboBox-isInvalid .euiFormControlLayout .euiFormControlLayout__childrenWrapper .euiComboBox__inputWrap'
);
await page.fill(
'text=To BccCombo box. Selected. Combo box input. Type some text or, to display a list >> input[role="combobox"]',
'test@gmail.com'
);
await page.isDisabled('button:has-text("Apply changes")');
await page.click('[aria-label="Account menu"]');
await page.click('text=Log out');
}
);
step('Login to kibana with readonly', async () => {
await syntheticsApp.loginToKibana('viewer', 'changeme');
});
step('Go to http://localhost:5620/app/synthetics/settings/alerting', async () => {
await page.goto('http://localhost:5620/app/synthetics/settings/alerting', {
waitUntil: 'networkidle',
});
await page.isDisabled('.euiComboBox__inputWrap');
await page.isDisabled('button:has-text("Apply changes")');
await page.isDisabled('button:has-text("Add connector")');
});
});

View file

@ -14,3 +14,4 @@ export * from './overview_sorting.journey';
export * from './overview_scrolling.journey';
export * from './overview_search.journey';
export * from './private_locations.journey';
export * from './alerting_default.journey';

View file

@ -77,9 +77,8 @@ export const cleanPrivateLocations = async (params: Record<string, any>) => {
const server = getService('kibanaServer');
try {
await server.savedObjects.clean({ types: [privateLocationsSavedObjectName] });
await server.savedObjects.clean({
types: ['ingest-agent-policies', 'ingest-package-policies'],
types: [privateLocationsSavedObjectName, 'ingest-agent-policies', 'ingest-package-policies'],
});
} catch (e) {
// eslint-disable-next-line no-console

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const cleanSettings = async (params: Record<string, any>) => {
const getService = params.getService;
const server = getService('kibanaServer');
try {
await server.savedObjects.clean({ types: ['uptime-dynamic-settings'] });
await cleanConnectors(params);
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
};
export const cleanConnectors = async (params: Record<string, any>) => {
const getService = params.getService;
const server = getService('kibanaServer');
try {
const { data } = await server.requester.request({
path: '/api/actions/connectors',
method: 'GET',
});
for (const connector of data) {
await server.requester.request({
path: `/api/actions/connector/${connector.id}`,
method: 'DELETE',
});
}
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
};

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useDispatch } from 'react-redux';
import { EuiButtonEmpty } from '@elastic/eui';
import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { getConnectorsAction } from '../../../state/settings/actions';
interface Props {
focusInput: () => void;
isDisabled: boolean;
}
interface KibanaDeps {
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
}
export const AddConnectorFlyout = ({ focusInput, isDisabled }: Props) => {
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
const {
services: {
application,
triggersActionsUi: { getAddConnectorFlyout },
},
} = useKibana<KibanaDeps>();
const canEdit: boolean = !!application?.capabilities.actions.save;
const dispatch = useDispatch();
const ConnectorAddFlyout = useMemo(
() =>
getAddConnectorFlyout({
onClose: () => {
dispatch(getConnectorsAction.get());
setAddFlyoutVisibility(false);
focusInput();
},
featureId: 'uptime',
}),
[dispatch, focusInput, getAddConnectorFlyout]
);
return (
<>
{addFlyoutVisible ? ConnectorAddFlyout : null}
<EuiButtonEmpty
data-test-subj="createConnectorButton"
onClick={() => setAddFlyoutVisibility(true)}
size="s"
isDisabled={isDisabled || !canEdit}
>
<FormattedMessage
id="xpack.synthetics.alerts.settings.addConnector"
defaultMessage="Add connector"
/>
</EuiButtonEmpty>
</>
);
};

View file

@ -0,0 +1,155 @@
/*
* 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, { useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiButtonEmpty,
EuiDescribedFormGroup,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiSpacer,
} from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import { isEmpty, isEqual } from 'lodash';
import { hasInvalidEmail } from './validation';
import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../../common/constants';
import { DefaultEmail } from './default_email';
import { selectDynamicSettings } from '../../../state/settings/selectors';
import {
getDynamicSettingsAction,
setDynamicSettingsAction,
} from '../../../state/settings/actions';
import { DefaultConnectorField } from './connector_field';
import { DynamicSettings } from '../../../../../../common/runtime_types';
import { useAlertingDefaults } from './hooks/use_alerting_defaults';
interface FormFields extends Omit<DynamicSettings, 'defaultEmail'> {
defaultEmail: Partial<DynamicSettings['defaultEmail']>;
}
export const AlertDefaultsForm = () => {
const dispatch = useDispatch();
const { settings, loading } = useSelector(selectDynamicSettings);
const [formFields, setFormFields] = useState<FormFields>(DYNAMIC_SETTINGS_DEFAULTS as FormFields);
const canEdit: boolean =
!!useKibana().services?.application?.capabilities.uptime.configureSettings || false;
const isDisabled = !canEdit;
useEffect(() => {
if (settings) {
setFormFields(settings as FormFields);
}
}, [settings]);
useEffect(() => {
dispatch(getDynamicSettingsAction.get());
}, [dispatch]);
const { connectors } = useAlertingDefaults();
const hasEmailConnector = connectors?.find(
(connector) =>
formFields.defaultConnectors?.includes(connector.id) && connector.actionTypeId === '.email'
);
const onApply = () => {
dispatch(setDynamicSettingsAction.get(formFields as DynamicSettings));
};
const isFormDirty = !isEqual(formFields, settings);
const isFormValid = () => {
if (hasEmailConnector) {
return isEmpty(hasInvalidEmail(formFields?.defaultConnectors, formFields?.defaultEmail));
}
return true;
};
return (
<EuiForm>
<EuiSpacer size="m" />
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.synthetics.settings.defaultConnectors"
defaultMessage="Default Connectors"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.synthetics.settings.defaultConnectors.description"
defaultMessage="Selector one or more connectors to be used for alerts. These settings will be applied to all synthetics based alerts."
/>
}
>
<DefaultConnectorField
isDisabled={isDisabled}
isLoading={loading}
selectedConnectors={formFields.defaultConnectors}
onChange={(value) => setFormFields({ ...formFields, defaultConnectors: value })}
/>
</EuiDescribedFormGroup>
{hasEmailConnector && (
<DefaultEmail
loading={loading}
isDisabled={isDisabled}
value={formFields.defaultEmail}
selectedConnectors={formFields.defaultConnectors}
onChange={(value) => setFormFields((prevStat) => ({ ...prevStat, defaultEmail: value }))}
/>
)}
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={() => {
setFormFields((settings ?? DYNAMIC_SETTINGS_DEFAULTS) as FormFields);
}}
flush="left"
isDisabled={!isFormDirty}
isLoading={loading}
>
{DISCARD_CHANGES}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={(evt: React.FormEvent) => {
evt.preventDefault();
onApply();
}}
fill
isLoading={loading}
isDisabled={!isFormDirty || isDisabled || !isFormValid()}
>
{APPLY_CHANGES}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
);
};
const DISCARD_CHANGES = i18n.translate('xpack.synthetics.settings.discardChanges', {
defaultMessage: 'Discard changes',
});
const APPLY_CHANGES = i18n.translate('xpack.synthetics.settings.applyChanges', {
defaultMessage: 'Apply changes',
});

View file

@ -0,0 +1,142 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { useGetUrlParams, useUrlParams } from '../../../hooks';
import { useAlertingDefaults } from './hooks/use_alerting_defaults';
import { alertFormI18n } from './translations';
import { ClientPluginsStart } from '../../../../../plugin';
import { AddConnectorFlyout } from './add_connector_flyout';
type ConnectorOption = EuiComboBoxOptionOption<string>;
export function DefaultConnectorField({
isLoading,
isDisabled,
onChange,
selectedConnectors,
}: {
isLoading: boolean;
isDisabled: boolean;
selectedConnectors: string[];
onChange: (connectors: string[]) => void;
}) {
const { actionTypeRegistry } = useKibana<ClientPluginsStart>().services.triggersActionsUi;
const { options, connectors } = useAlertingDefaults();
const renderOption = (option: ConnectorOption) => {
const { label, value } = option;
const { actionTypeId: type } = connectors?.find((dt) => dt.id === value) ?? {};
return (
<ConnectorSpan>
<EuiIcon type={actionTypeRegistry.get(type as string).iconClass} />
<span>{label}</span>
</ConnectorSpan>
);
};
const inputRef = useRef<HTMLInputElement | null>(null);
const { focusConnectorField } = useGetUrlParams();
const updateUrlParams = useUrlParams()[1];
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
if (focusConnectorField && inputRef.current && !isLoading) {
inputRef.current.focus();
}
}, [focusConnectorField, inputRef, isLoading]);
const onBlur = () => {
if (inputRef.current) {
const { value } = inputRef.current;
setError(value.length === 0 ? undefined : `"${value}" is not a valid option`);
}
if (inputRef.current && !isLoading && focusConnectorField) {
updateUrlParams({ focusConnectorField: undefined });
}
};
const onSearchChange = (value: string, hasMatchingOptions?: boolean) => {
setError(
value.length === 0 || hasMatchingOptions ? undefined : `"${value}" is not a valid option`
);
};
return (
<RowWrapper
describedByIds={['defaultConnectors']}
error={error}
fullWidth
label={
<FormattedMessage
id="xpack.synthetics.sourceConfiguration.defaultConnectors"
defaultMessage="Default connectors"
/>
}
labelAppend={
<AddConnectorFlyout
isDisabled={isDisabled}
focusInput={useCallback(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [])}
/>
}
>
<EuiComboBox
inputRef={(input) => {
inputRef.current = input;
}}
placeholder={alertFormI18n.inputPlaceHolder}
options={options}
selectedOptions={options.filter((opt) => selectedConnectors?.includes(opt.value))}
onBlur={onBlur}
isDisabled={isDisabled}
data-test-subj={`default-connectors-input-${isLoading ? 'loading' : 'loaded'}`}
renderOption={renderOption}
fullWidth
aria-label={TAGS_LABEL}
isLoading={isLoading}
onChange={(newSelectedConnectors) => {
onChange(newSelectedConnectors.map((tag) => tag.value as string));
}}
onSearchChange={onSearchChange}
/>
</RowWrapper>
);
}
const RowWrapper = styled(EuiFormRow)`
&&& > .euiFormRow__labelWrapper {
align-items: baseline;
}
`;
const ConnectorSpan = styled.span`
.euiIcon {
margin-right: 5px;
}
> img {
width: 16px;
height: 20px;
}
`;
export const TAGS_LABEL = i18n.translate('xpack.synthetics.monitorManagement.paramForm.tagsLabel', {
defaultMessage: 'Tags',
});

View file

@ -0,0 +1,71 @@
/*
* 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 } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiDescribedFormGroup } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { UptimePluginServices } from '../../../../../plugin';
import { DefaultEmail as DefaultEmailType } from '../../../../../../common/runtime_types';
import { hasInvalidEmail } from './validation';
export function DefaultEmail({
loading,
onChange,
value,
isDisabled,
selectedConnectors,
}: {
onChange: (value: Partial<DefaultEmailType>) => void;
value?: Partial<DefaultEmailType>;
isDisabled: boolean;
loading: boolean;
selectedConnectors: string[];
}) {
const { actionTypeRegistry } = useKibana<UptimePluginServices>().services.triggersActionsUi;
const emailActionType = actionTypeRegistry.get('.email');
const ActionParams = emailActionType.actionParamsFields;
const [isTouched, setIsTouched] = useState(false);
const errors = hasInvalidEmail(selectedConnectors, value, isTouched);
return (
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.synthetics.sourceConfiguration.alertConnectors.defaultEmail"
defaultMessage="Default email"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.synthetics.sourceConfiguration.defaultConnectors.description.defaultEmail"
defaultMessage="Email settings required for selected email alert connectors."
/>
}
>
<ActionParams
isLoading={loading}
actionParams={value ?? {}}
errors={errors}
editAction={(key, val, index) => {
if (key !== 'message') {
onChange({ ...(value ?? {}), [key]: val });
}
}}
showEmailSubjectAndMessage={false}
index={1}
isDisabled={isDisabled}
onBlur={() => setIsTouched(true)}
/>
</EuiDescribedFormGroup>
);
}

View file

@ -0,0 +1,38 @@
/*
* 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 { useFetcher } from '@kbn/observability-plugin/public';
import { useDispatch, useSelector } from 'react-redux';
import { useEffect } from 'react';
import { selectDynamicSettings } from '../../../../state/settings/selectors';
import { fetchActionTypes } from '../../../../state/settings/api';
import { getConnectorsAction } from '../../../../state/settings/actions';
export const useAlertingDefaults = () => {
const { data: actionTypes } = useFetcher(() => fetchActionTypes(), []);
const { connectors } = useSelector(selectDynamicSettings);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getConnectorsAction.get());
}, [dispatch]);
const options = (connectors ?? [])
.filter((action) => (actionTypes ?? []).find((type) => type.id === action.actionTypeId))
.map((connectorAction) => ({
value: connectorAction.id,
label: connectorAction.name,
'data-test-subj': connectorAction.name,
}));
return {
options,
actionTypes,
connectors,
};
};

View file

@ -0,0 +1,23 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const alertFormI18n = {
inputPlaceHolder: i18n.translate(
'xpack.synthetics.sourceConfiguration.alertDefaultForm.selectConnector',
{
defaultMessage: 'Please select one or more connectors',
}
),
emailPlaceHolder: i18n.translate(
'xpack.synthetics.sourceConfiguration.alertDefaultForm.emailConnectorPlaceHolder',
{
defaultMessage: 'To: Email for email connector',
}
),
};

View file

@ -0,0 +1,68 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { DefaultEmail as DefaultEmailType } from '../../../../../../common/runtime_types';
export const validateEmail = (email: string) => {
return email
.toLowerCase()
.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
);
};
const REQUIRED_EMAIL = i18n.translate('xpack.synthetics.settings.alertDefaultForm.requiredEmail', {
defaultMessage: 'To: Email is required for selected email connector',
});
const getInvalidEmailError = (value?: string[]) => {
if (!value) {
return;
}
const inValidEmail = value.find((val) => !validateEmail(val));
if (!inValidEmail) {
return;
}
return i18n.translate('xpack.synthetics.sourceConfiguration.alertDefaultForm.invalidEmail', {
defaultMessage: '{val} is not a valid email.',
values: { val: inValidEmail },
});
};
export const hasInvalidEmail = (
defaultConnectors?: string[],
value?: Partial<DefaultEmailType>,
isTouched?: boolean
): {
to?: string;
cc?: string;
bcc?: string;
} => {
if (!defaultConnectors || defaultConnectors.length === 0 || isTouched === false) {
return {};
}
if (!value || !value.to) {
return { to: REQUIRED_EMAIL };
}
const toError = value.to.length === 0 ? REQUIRED_EMAIL : getInvalidEmailError(value.to);
const ccError = getInvalidEmailError(value.cc);
const bccError = getInvalidEmailError(value.bcc);
if (toError || ccError || bccError) {
return {
to: toError ?? '',
cc: ccError ?? '',
bcc: bccError ?? '',
};
}
return {};
};

View file

@ -7,8 +7,10 @@
import React from 'react';
import { Redirect, useParams } from 'react-router-dom';
import { SettingsTabId } from './page_header';
import { EuiPanel } from '@elastic/eui';
import { AlertDefaultsForm } from './alerting_defaults/alert_defaults_form';
import { ProjectAPIKeys } from './project_api_keys/project_api_keys';
import { SettingsTabId } from './page_header';
import { DataRetentionTab } from './data_retention';
import { useSettingsBreadcrumbs } from './use_settings_breadcrumbs';
import { ManagePrivateLocations } from './private_locations/manage_private_locations';
@ -27,7 +29,11 @@ export const SettingsPage = () => {
case 'data-retention':
return <DataRetentionTab />;
case 'alerting':
return <div>TODO: Alerting</div>;
return (
<EuiPanel hasShadow={false} hasBorder={true}>
<AlertDefaultsForm />
</EuiPanel>
);
default:
return <Redirect to="/settings/alerting" />;
}

View file

@ -6,6 +6,11 @@
*/
import { all, fork } from 'redux-saga/effects';
import {
fetchAlertConnectorsEffect,
fetchDynamicSettingsEffect,
setDynamicSettingsEffect,
} from './settings/effects';
import { fetchAgentPoliciesEffect } from './private_locations';
import { fetchNetworkEventsEffect } from './network_events/effects';
import { fetchSyntheticsMonitorEffect } from './monitor_details';
@ -31,5 +36,9 @@ export const rootEffect = function* root(): Generator {
fork(fetchNetworkEventsEffect),
fork(fetchPingStatusesEffect),
fork(fetchAgentPoliciesEffect),
fork(fetchDynamicSettingsEffect),
fork(setDynamicSettingsEffect),
fork(fetchAgentPoliciesEffect),
fork(fetchAlertConnectorsEffect),
]);
};

View file

@ -7,6 +7,7 @@
import { combineReducers } from '@reduxjs/toolkit';
import { dynamicSettingsReducer, DynamicSettingsState } from './settings';
import { agentPoliciesReducer, AgentPoliciesState } from './private_locations';
import { networkEventsReducer, NetworkEventsState } from './network_events';
import { monitorDetailsReducer, MonitorDetailsState } from './monitor_details';
@ -32,6 +33,7 @@ export interface SyntheticsAppState {
networkEvents: NetworkEventsState;
pingStatus: PingStatusState;
agentPolicies: AgentPoliciesState;
dynamicSettings: DynamicSettingsState;
}
export const rootReducer = combineReducers<SyntheticsAppState>({
@ -46,4 +48,5 @@ export const rootReducer = combineReducers<SyntheticsAppState>({
networkEvents: networkEventsReducer,
pingStatus: pingStatusReducer,
agentPolicies: agentPoliciesReducer,
dynamicSettings: dynamicSettingsReducer,
});

View file

@ -0,0 +1,18 @@
/*
* 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 { ActionConnector } from './api';
import { DynamicSettings } from '../../../../../common/runtime_types';
import { createAsyncAction } from '../utils/actions';
export const getDynamicSettingsAction = createAsyncAction<void, DynamicSettings>(
'GET_DYNAMIC_SETTINGS'
);
export const setDynamicSettingsAction = createAsyncAction<DynamicSettings, DynamicSettings>(
'SET_DYNAMIC_SETTINGS'
);
export const getConnectorsAction = createAsyncAction<void, ActionConnector[]>('GET CONNECTORS');

View file

@ -0,0 +1,82 @@
/*
* 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 {
ActionConnector as RawActionConnector,
ActionType,
AsApiContract,
} from '@kbn/triggers-actions-ui-plugin/public';
import { apiService } from '../../../../utils/api_service';
import {
DynamicSettings,
DynamicSettingsSaveResponse,
DynamicSettingsSaveType,
DynamicSettingsType,
} from '../../../../../common/runtime_types';
import { API_URLS } from '../../../../../common/constants';
const apiPath = API_URLS.DYNAMIC_SETTINGS;
interface SaveApiRequest {
settings: DynamicSettings;
}
export const getDynamicSettings = async (): Promise<DynamicSettings> => {
return await apiService.get(apiPath, undefined, DynamicSettingsType);
};
export const setDynamicSettings = async ({
settings,
}: SaveApiRequest): Promise<DynamicSettingsSaveResponse> => {
return await apiService.post(apiPath, settings, DynamicSettingsSaveType);
};
export type ActionConnector = Omit<RawActionConnector, 'secrets'>;
export const fetchConnectors = async (): Promise<ActionConnector[]> => {
const response = (await apiService.get(API_URLS.RULE_CONNECTORS)) as Array<
AsApiContract<ActionConnector>
>;
return response.map(
({
connector_type_id: actionTypeId,
referenced_by_count: referencedByCount,
is_preconfigured: isPreconfigured,
is_deprecated: isDeprecated,
is_missing_secrets: isMissingSecrets,
...res
}) => ({
...res,
actionTypeId,
referencedByCount,
isDeprecated,
isPreconfigured,
isMissingSecrets,
})
);
};
export const fetchActionTypes = async (): Promise<ActionType[]> => {
const response = (await apiService.get(API_URLS.CONNECTOR_TYPES, {
feature_id: 'uptime',
})) as Array<AsApiContract<ActionType>>;
return response.map<ActionType>(
({
enabled_in_config: enabledInConfig,
enabled_in_license: enabledInLicense,
minimum_license_required: minimumLicenseRequired,
supported_feature_ids: supportedFeatureIds,
...res
}: AsApiContract<ActionType>) => ({
...res,
enabledInConfig,
enabledInLicense,
minimumLicenseRequired,
supportedFeatureIds,
})
);
};

View file

@ -0,0 +1,65 @@
/*
* 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.
*/
/*
* 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 { takeLeading, put, call, takeLatest } from 'redux-saga/effects';
import { Action } from 'redux-actions';
import { i18n } from '@kbn/i18n';
import { DynamicSettings } from '../../../../../common/runtime_types';
import { kibanaService } from '../../../../utils/kibana_service';
import { getConnectorsAction, getDynamicSettingsAction, setDynamicSettingsAction } from './actions';
import { fetchEffectFactory } from '../utils/fetch_effect';
import { fetchConnectors, getDynamicSettings, setDynamicSettings } from './api';
export function* fetchDynamicSettingsEffect() {
yield takeLeading(
String(getDynamicSettingsAction.get),
fetchEffectFactory(
getDynamicSettings,
getDynamicSettingsAction.success,
getDynamicSettingsAction.fail
)
);
}
export function* setDynamicSettingsEffect() {
const couldNotSaveSettingsText = i18n.translate('xpack.synthetics.settings.error.couldNotSave', {
defaultMessage: 'Could not save settings!',
});
yield takeLatest(
String(setDynamicSettingsAction.get),
function* (action: Action<DynamicSettings>) {
try {
yield call(setDynamicSettings, { settings: action.payload });
yield put(setDynamicSettingsAction.success(action.payload));
kibanaService.core.notifications.toasts.addSuccess(
i18n.translate('xpack.synthetics.settings.saveSuccess', {
defaultMessage: 'Settings saved!',
})
);
} catch (err) {
kibanaService.core.notifications.toasts.addError(err, {
title: couldNotSaveSettingsText,
});
yield put(setDynamicSettingsAction.fail(err));
}
}
);
}
export function* fetchAlertConnectorsEffect() {
yield takeLeading(
String(getConnectorsAction.get),
fetchEffectFactory(fetchConnectors, getConnectorsAction.success, getConnectorsAction.fail)
);
}

View file

@ -0,0 +1,62 @@
/*
* 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 { createReducer } from '@reduxjs/toolkit';
import { DynamicSettings } from '../../../../../common/runtime_types';
import { IHttpSerializedFetchError } from '..';
import { getConnectorsAction, getDynamicSettingsAction, setDynamicSettingsAction } from './actions';
import { ActionConnector } from './api';
export interface DynamicSettingsState {
settings?: DynamicSettings;
loadError?: IHttpSerializedFetchError;
saveError?: IHttpSerializedFetchError;
loading: boolean;
connectors?: ActionConnector[];
connectorsLoading?: boolean;
}
const initialState: DynamicSettingsState = {
loading: true,
connectors: [],
};
export const dynamicSettingsReducer = createReducer(initialState, (builder) => {
builder
.addCase(getDynamicSettingsAction.get, (state) => {
state.loading = true;
})
.addCase(getDynamicSettingsAction.success, (state, action) => {
state.settings = action.payload;
state.loading = false;
})
.addCase(getDynamicSettingsAction.fail, (state, action) => {
state.loadError = action.payload;
state.loading = false;
})
.addCase(setDynamicSettingsAction.get, (state) => {
state.loading = true;
})
.addCase(setDynamicSettingsAction.success, (state, action) => {
state.settings = action.payload;
state.loading = false;
})
.addCase(setDynamicSettingsAction.fail, (state, action) => {
state.loadError = action.payload;
state.loading = false;
})
.addCase(getConnectorsAction.get, (state) => {
state.connectorsLoading = true;
})
.addCase(getConnectorsAction.success, (state, action) => {
state.connectors = action.payload;
state.connectorsLoading = false;
})
.addCase(getConnectorsAction.fail, (state, action) => {
state.connectorsLoading = false;
});
});

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 { SyntheticsAppState } from '../root_reducer';
export const selectDynamicSettings = (state: SyntheticsAppState) => state.dynamicSettings;

View file

@ -116,6 +116,9 @@ export const mockState: SyntheticsAppState = {
error: null,
data: null,
},
dynamicSettings: {
loading: false,
},
};
function getBrowserJourneyMockSlice() {

View file

@ -5,17 +5,17 @@
* 2.0.
*/
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { FieldValues, useForm, UseFormProps } from 'react-hook-form';
export function useFormWrapped<TFieldValues extends FieldValues = FieldValues, TContext = any>(
props?: UseFormProps<TFieldValues, TContext>
) {
const { register, ...restOfForm } = useForm<TFieldValues>(props);
const form = useForm<TFieldValues>(props);
const euiRegister = useCallback(
(name, ...registerArgs) => {
const { ref, ...restOfRegister } = register(name, ...registerArgs);
const { ref, ...restOfRegister } = form.register(name, ...registerArgs);
return {
inputRef: ref,
@ -23,11 +23,12 @@ export function useFormWrapped<TFieldValues extends FieldValues = FieldValues, T
...restOfRegister,
};
},
[register]
[form]
);
const formState = form.formState;
return {
register: euiRegister,
...restOfForm,
};
return useMemo(
() => ({ ...form, register: euiRegister, formState }),
[euiRegister, form, formState]
);
}

View file

@ -210,6 +210,7 @@ export interface ActionParamsProps<TParams> {
isDisabled?: boolean;
showEmailSubjectAndMessage?: boolean;
executionMode?: ActionConnectorMode;
onBlur?: (field?: string) => void;
}
export interface Pagination {

View file

@ -45,6 +45,7 @@ export function UptimeSettingsProvider({ getService }: FtrProviderContext) {
certAgeThreshold: parseInt(age, 10),
certExpirationThreshold: parseInt(expiration, 10),
defaultConnectors: [],
defaultEmail: { to: [], cc: [], bcc: [] },
};
},
applyButtonIsDisabled: async () => {