[Synthetics UI] Add or edit a monitor (#132204)

* add basic form setup

* add validation

* add edit flow

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Delete context.tsx

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* add locations

* update schedule field

* adjust script recorder fields

* adjust disclaimer to only show when using a service location

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* adjust edit flow

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* add Kibana space

* adjust Kibana space logic

* add throttling

* update content

* adjust hrefs

* adjust content

* adjust types

* add editing

* adjust monitor validation tests

* fix integration tests

* adjust tests

* adjust content

* add source field tests

* add tests

* add legacy validation

* rename file

* adjust legacy components

* add tests

* update tests

* update content

* adjust types

* adjust routes

* remove console logs

* adjust types

* update tests

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* add Playwright link

* add placeholder

* update getting started page link

* add monitor details link

* adjust content

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/uploader.tsx

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Apply suggestions from code review

* Update x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx

* Update x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx

* fix jest

* adjust tests

* renaming

* move exported type

* move hook

* adjust file structure

* adjust test

* move hook

* adjust test

* add script recorder button

* adjust tests

* adjust tests

* adjust validation

* add ui keys to skip

* i18n misses

* move text assertion box down

* adjust content

* adjust validation

* adjust helpers

* align connection profile field baseline

* do not clear out url for single step monitors

* adjust defaults

* adjust types

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/uploader.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/step_config.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/step_config.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/step_config.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/step_config.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* Update x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx

Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>

* merge edited monitor

* add Loading spinners

* update content

* adjust errors

* adjust types

* adjust tests

* adjust types

* merge edited monitor settings

* adjust tests

* adjust types

* subscribe to dirty fields

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: shahzad31 <shahzad.muhammad@elastic.co>
Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>
This commit is contained in:
Dominique Clarke 2022-08-23 23:16:39 -04:00 committed by GitHub
parent 6ad1d1da86
commit 7a107392b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 6228 additions and 125 deletions

View file

@ -9,6 +9,7 @@ import {
BrowserSimpleFields,
CommonFields,
DataStream,
FormMonitorType,
HTTPAdvancedFields,
HTTPMethod,
HTTPSimpleFields,
@ -31,6 +32,7 @@ export const DEFAULT_NAMESPACE_STRING = 'default';
export const DEFAULT_COMMON_FIELDS: CommonFields = {
[ConfigKey.MONITOR_TYPE]: DataStream.HTTP,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
[ConfigKey.ENABLED]: true,
[ConfigKey.SCHEDULE]: {
number: '3',
@ -85,6 +87,7 @@ export const DEFAULT_BROWSER_SIMPLE_FIELDS: BrowserSimpleFields = {
[ConfigKey.SOURCE_ZIP_PASSWORD]: '',
[ConfigKey.SOURCE_ZIP_FOLDER]: '',
[ConfigKey.SOURCE_ZIP_PROXY_URL]: '',
[ConfigKey.TEXT_ASSERTION]: '',
[ConfigKey.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES]: undefined,
[ConfigKey.ZIP_URL_TLS_CERTIFICATE]: undefined,
[ConfigKey.ZIP_URL_TLS_KEY]: undefined,
@ -92,6 +95,7 @@ export const DEFAULT_BROWSER_SIMPLE_FIELDS: BrowserSimpleFields = {
[ConfigKey.ZIP_URL_TLS_VERIFICATION_MODE]: undefined,
[ConfigKey.ZIP_URL_TLS_VERSION]: undefined,
[ConfigKey.URLS]: '',
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
};
export const DEFAULT_HTTP_SIMPLE_FIELDS: HTTPSimpleFields = {
@ -102,6 +106,7 @@ export const DEFAULT_HTTP_SIMPLE_FIELDS: HTTPSimpleFields = {
[ConfigKey.URLS]: '',
[ConfigKey.MAX_REDIRECTS]: '0',
[ConfigKey.MONITOR_TYPE]: DataStream.HTTP,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.HTTP,
};
export const DEFAULT_HTTP_ADVANCED_FIELDS: HTTPAdvancedFields = {
@ -127,6 +132,7 @@ export const DEFAULT_ICMP_SIMPLE_FIELDS: ICMPSimpleFields = {
[ConfigKey.HOSTS]: '',
[ConfigKey.MONITOR_TYPE]: DataStream.ICMP,
[ConfigKey.WAIT]: '1',
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.ICMP,
};
export const DEFAULT_TCP_SIMPLE_FIELDS: TCPSimpleFields = {
@ -136,6 +142,7 @@ export const DEFAULT_TCP_SIMPLE_FIELDS: TCPSimpleFields = {
},
[ConfigKey.HOSTS]: '',
[ConfigKey.MONITOR_TYPE]: DataStream.TCP,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.TCP,
};
export const DEFAULT_TCP_ADVANCED_FIELDS: TCPAdvancedFields = {
@ -165,7 +172,9 @@ export const DEFAULT_FIELDS: MonitorDefaults = {
...DEFAULT_TCP_ADVANCED_FIELDS,
...DEFAULT_TLS_FIELDS,
},
[DataStream.ICMP]: DEFAULT_ICMP_SIMPLE_FIELDS,
[DataStream.ICMP]: {
...DEFAULT_ICMP_SIMPLE_FIELDS,
},
[DataStream.BROWSER]: {
...DEFAULT_BROWSER_SIMPLE_FIELDS,
...DEFAULT_BROWSER_ADVANCED_FIELDS,

View file

@ -11,6 +11,7 @@ export enum ConfigKey {
CUSTOM_HEARTBEAT_ID = 'custom_heartbeat_id',
CONFIG_ID = 'config_id',
ENABLED = 'enabled',
FORM_MONITOR_TYPE = 'form_monitor_type',
HOSTS = 'hosts',
IGNORE_HTTPS_ERRORS = 'ignore_https_errors',
MONITOR_SOURCE_TYPE = 'origin',
@ -53,6 +54,7 @@ export enum ConfigKey {
SOURCE_ZIP_PROXY_URL = 'source.zip_url.proxy_url',
PROJECT_ID = 'project_id',
SYNTHETICS_ARGS = 'synthetics_args',
TEXT_ASSERTION = 'playwright_text_assertion',
TLS_CERTIFICATE_AUTHORITIES = 'ssl.certificate_authorities',
TLS_CERTIFICATE = 'ssl.certificate',
TLS_KEY = 'ssl.key',

View file

@ -77,5 +77,6 @@ export const browserFormatters: BrowserFormatMap = {
[ConfigKey.PLAYWRIGHT_OPTIONS]: null,
[ConfigKey.CUSTOM_HEARTBEAT_ID]: null,
[ConfigKey.ORIGINAL_SPACE]: null,
[ConfigKey.TEXT_ASSERTION]: null,
...commonFormatters,
};

View file

@ -27,6 +27,7 @@ export const commonFormatters: CommonFormatMap = {
[ConfigKey.NAMESPACE]: null,
[ConfigKey.REVISION]: null,
[ConfigKey.MONITOR_SOURCE_TYPE]: null,
[ConfigKey.FORM_MONITOR_TYPE]: null,
};
export const arrayToJsonFormatter = (value: string[] = []) =>

View file

@ -126,3 +126,13 @@ export enum SourceType {
}
export const SourceTypeCodec = tEnum<SourceType>('SourceType', SourceType);
export enum FormMonitorType {
SINGLE = 'single',
MULTISTEP = 'multistep',
HTTP = 'http',
TCP = 'tcp',
ICMP = 'icmp',
}
export const FormMonitorTypeCodec = tEnum<FormMonitorType>('FormMonitorType', FormMonitorType);

View file

@ -16,6 +16,7 @@ import {
import {
DataStream,
DataStreamCodec,
FormMonitorTypeCodec,
ModeCodec,
ResponseBodyIndexPolicyCodec,
ScheduleUnitCodec,
@ -79,6 +80,7 @@ export const CommonFieldsCodec = t.intersection([
[ConfigKey.LOCATIONS]: MonitorServiceLocationsCodec,
}),
t.partial({
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorTypeCodec,
[ConfigKey.TIMEOUT]: t.union([t.string, t.null]),
[ConfigKey.REVISION]: t.number,
[ConfigKey.MONITOR_SOURCE_TYPE]: SourceTypeCodec,
@ -220,6 +222,7 @@ export const EncryptedBrowserSimpleFieldsCodec = t.intersection([
[ConfigKey.PROJECT_ID]: t.string,
[ConfigKey.ORIGINAL_SPACE]: t.string,
[ConfigKey.CUSTOM_HEARTBEAT_ID]: t.string,
[ConfigKey.TEXT_ASSERTION]: t.string,
}),
]),
ZipUrlTLSFieldsCodec,

View file

@ -4,8 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './alerts';
export * from './synthetics';
export * from './alerts';
export * from './data_view_permissions';
export * from './uptime.journey';
export * from './step_duration.journey';

View file

@ -0,0 +1,211 @@
/*
* 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 uuid from 'uuid';
import { journey, step, expect, Page } from '@elastic/synthetics';
import { FormMonitorType } from '../../../common/runtime_types/monitor_management';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics_app';
const customLocation = process.env.SYNTHETICS_TEST_LOCATION;
const basicMonitorDetails = {
location: customLocation || 'US Central',
schedule: '3',
};
const httpName = `http monitor ${uuid.v4()}`;
const icmpName = `icmp monitor ${uuid.v4()}`;
const tcpName = `tcp monitor ${uuid.v4()}`;
const browserName = `browser monitor ${uuid.v4()}`;
const browserRecorderName = `browser monitor recorder ${uuid.v4()}`;
const apmServiceName = 'apmServiceName';
const configuration = {
[FormMonitorType.HTTP]: {
monitorType: FormMonitorType.HTTP,
monitorConfig: {
...basicMonitorDetails,
name: httpName,
url: 'https://elastic.co',
locations: [basicMonitorDetails.location],
apmServiceName,
},
monitorListDetails: {
...basicMonitorDetails,
name: httpName,
},
monitorEditDetails: [
['[data-test-subj=syntheticsMonitorConfigSchedule]', '3'],
['[data-test-subj=syntheticsMonitorConfigName]', httpName],
['[data-test-subj=syntheticsMonitorConfigURL]', 'https://elastic.co'],
['[data-test-subj=syntheticsMonitorConfigAPMServiceName]', apmServiceName],
],
},
[FormMonitorType.TCP]: {
monitorType: FormMonitorType.TCP,
monitorConfig: {
...basicMonitorDetails,
name: tcpName,
host: 'smtp.gmail.com:587',
locations: [basicMonitorDetails.location],
apmServiceName,
},
monitorListDetails: {
...basicMonitorDetails,
name: tcpName,
},
monitorEditDetails: [
['[data-test-subj=syntheticsMonitorConfigSchedule]', '3'],
['[data-test-subj=syntheticsMonitorConfigName]', tcpName],
['[data-test-subj=syntheticsMonitorConfigHost]', 'smtp.gmail.com:587'],
['[data-test-subj=syntheticsMonitorConfigAPMServiceName]', apmServiceName],
],
},
[FormMonitorType.ICMP]: {
monitorType: FormMonitorType.ICMP,
monitorConfig: {
...basicMonitorDetails,
name: icmpName,
host: '1.1.1.1',
locations: [basicMonitorDetails.location],
apmServiceName,
},
monitorListDetails: {
...basicMonitorDetails,
name: icmpName,
},
monitorEditDetails: [
['[data-test-subj=syntheticsMonitorConfigSchedule]', '3'],
['[data-test-subj=syntheticsMonitorConfigName]', icmpName],
['[data-test-subj=syntheticsMonitorConfigHost]', '1.1.1.1'],
['[data-test-subj=syntheticsMonitorConfigAPMServiceName]', apmServiceName],
// name: httpName,
],
},
[FormMonitorType.MULTISTEP]: {
monitorType: FormMonitorType.MULTISTEP,
monitorConfig: {
...basicMonitorDetails,
schedule: '10',
name: browserName,
inlineScript: 'step("test step", () => {})',
locations: [basicMonitorDetails.location],
apmServiceName,
},
monitorListDetails: {
...basicMonitorDetails,
schedule: '10',
name: browserName,
},
monitorEditDetails: [
['[data-test-subj=syntheticsMonitorConfigSchedule]', '10'],
['[data-test-subj=syntheticsMonitorConfigName]', browserName],
['[data-test-subj=codeEditorContainer] textarea', 'step("test step", () => {})'],
['[data-test-subj=syntheticsMonitorConfigAPMServiceName]', apmServiceName],
],
},
[`${FormMonitorType.MULTISTEP}__recorder`]: {
monitorType: FormMonitorType.MULTISTEP,
monitorConfig: {
...basicMonitorDetails,
schedule: '10',
name: browserRecorderName,
recorderScript: 'step("test step", () => {})',
locations: [basicMonitorDetails.location],
apmServiceName: 'Sample APM Service',
},
monitorListDetails: {
...basicMonitorDetails,
schedule: '10',
name: browserRecorderName,
},
monitorEditDetails: [
['[data-test-subj=syntheticsMonitorConfigSchedule]', '10'],
['[data-test-subj=syntheticsMonitorConfigName]', browserRecorderName],
['[data-test-subj=codeEditorContainer] textarea', 'step("test step", () => {})'],
// name: httpName,
],
},
};
const createMonitorJourney = ({
monitorName,
monitorType,
monitorConfig,
monitorListDetails,
monitorEditDetails,
}: {
monitorName: string;
monitorType: FormMonitorType;
monitorConfig: Record<string, string | string[]>;
monitorListDetails: Record<string, string>;
monitorEditDetails: Array<[string, string]>;
}) => {
journey(
`Synthetics - add monitor - ${monitorName}`,
async ({ page, params }: { page: Page; params: any }) => {
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
step('Go to monitor management', async () => {
await syntheticsApp.navigateToMonitorManagement();
});
step('login to Kibana', async () => {
await syntheticsApp.loginToKibana();
const invalid = await page.locator(
`text=Username or password is incorrect. Please try again.`
);
expect(await invalid.isVisible()).toBeFalsy();
});
step('Ensure all montiors are deleted', async () => {
await syntheticsApp.navigateToMonitorManagement();
await syntheticsApp.waitForLoadingToFinish();
const isSuccessful = await syntheticsApp.deleteMonitors();
expect(isSuccessful).toBeTruthy();
});
step(`create ${monitorName}`, async () => {
await syntheticsApp.navigateToAddMonitor();
await syntheticsApp.createMonitor({ monitorConfig, monitorType });
const isSuccessful = await syntheticsApp.confirmAndSave();
expect(isSuccessful).toBeTruthy();
});
step(`view ${monitorName} details in Monitor Management UI`, async () => {
await syntheticsApp.navigateToMonitorManagement();
const hasFailure = await syntheticsApp.findMonitorConfiguration(monitorListDetails);
expect(hasFailure).toBeFalsy();
});
step(`edit ${monitorName}`, async () => {
await syntheticsApp.navigateToEditMonitor();
await syntheticsApp.findByText(monitorListDetails.location);
const hasFailure = await syntheticsApp.findEditMonitorConfiguration(
monitorEditDetails,
monitorType
);
expect(hasFailure).toBeFalsy();
});
step('delete monitor', async () => {
await syntheticsApp.navigateToMonitorManagement();
await syntheticsApp.findByText('Monitor name');
const isSuccessful = await syntheticsApp.deleteMonitors();
expect(isSuccessful).toBeTruthy();
});
}
);
};
Object.values(configuration).forEach((config) => {
createMonitorJourney({
monitorType: config.monitorType,
monitorName: `${config.monitorConfig.name} monitor`,
monitorConfig: config.monitorConfig,
monitorListDetails: config.monitorListDetails,
monitorEditDetails: config.monitorEditDetails as Array<[string, string]>,
});
});

View file

@ -6,3 +6,4 @@
*/
export * from './getting_started.journey';
export * from './add_monitor.journey';

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Page } from '@elastic/synthetics';
import { expect, Page } from '@elastic/synthetics';
import { getQuerystring } from '@kbn/observability-plugin/e2e/utils';
import { DataStream } from '../../common/runtime_types/monitor_management';
import { loginPageProvider } from './login';
@ -107,6 +107,8 @@ export function monitorManagementPageProvider({
},
async clickAddMonitor() {
const isEnabled = await this.checkIsEnabled();
expect(isEnabled).toBe(true);
await page.click('text=Add monitor');
},

View file

@ -4,7 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Page } from '@elastic/synthetics';
import { expect, Page } from '@elastic/synthetics';
import { FormMonitorType } from '../../common/runtime_types/monitor_management';
import { loginPageProvider } from './login';
import { utilsPageProvider } from './utils';
@ -19,8 +20,7 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
const isRemote = Boolean(process.env.SYNTHETICS_REMOTE_ENABLED);
const basePath = isRemote ? remoteKibanaUrl : kibanaUrl;
const monitorManagement = `${basePath}/app/synthetics/monitors`;
const addMonitor = `${basePath}/app/uptime/add-monitor`;
const addMonitor = `${basePath}/app/synthetics/add-monitor`;
return {
...loginPageProvider({
page,
@ -49,25 +49,43 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
},
async navigateToAddMonitor() {
await page.goto(addMonitor, {
waitUntil: 'networkidle',
});
await page.goto(addMonitor);
},
async ensureIsOnMonitorConfigPage() {
await page.isVisible('[data-test-subj=monitorSettingsSection]');
},
async confirmAndSave(isEditPage?: boolean) {
async confirmAndSave() {
await this.ensureIsOnMonitorConfigPage();
if (isEditPage) {
await page.click('text=Update monitor');
} else {
await page.click('text=Create monitor');
}
await this.clickByTestSubj('syntheticsMonitorConfigSubmitButton');
return await this.findByText('Monitor added successfully.');
},
async deleteMonitors() {
let isSuccessful: boolean = false;
while (true) {
if ((await page.$(this.byTestId('syntheticsMonitorListActions'))) === null) {
isSuccessful = true;
break;
}
await page.click(this.byTestId('syntheticsMonitorListActions'), { delay: 800 });
await page.click('text=delete', { delay: 800 });
await page.waitForSelector('[data-test-subj="confirmModalTitleText"]');
await this.clickByTestSubj('confirmModalConfirmButton');
isSuccessful = Boolean(await this.findByTestSubj('uptimeDeleteMonitorSuccess'));
await this.navigateToMonitorManagement();
await page.waitForTimeout(5 * 1000);
}
return isSuccessful;
},
async navigateToEditMonitor() {
await this.clickByTestSubj('syntheticsMonitorListActions');
await page.click('text=Edit');
await this.findByText('Edit monitor');
},
async selectLocations({ locations }: { locations: string[] }) {
for (let i = 0; i < locations.length; i++) {
await page.click(
@ -77,6 +95,13 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
}
},
async selectLocationsAddEdit({ locations }: { locations: string[] }) {
for (let i = 0; i < locations.length; i++) {
await page.click(this.byTestId('syntheticsMonitorConfigLocations'));
await page.click(`text=${locations[i]}`);
}
},
async fillFirstMonitorDetails({ url, locations }: { url: string; locations: string[] }) {
await this.fillByTestSubj('urls-input', url);
await page.click(this.byTestId('comboBoxInput'));
@ -84,6 +109,162 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
await page.click(this.byTestId('urls-input'));
},
async selectMonitorType(monitorType: string) {
await this.clickByTestSubj(monitorType);
},
async findMonitorConfiguration(monitorConfig: Record<string, string>) {
const values = Object.values(monitorConfig);
for (let i = 0; i < values.length; i++) {
await this.findByText(values[i]);
}
},
async findEditMonitorConfiguration(
monitorConfig: Array<[string, string]>,
monitorType: FormMonitorType
) {
await page.click('text="Advanced options"');
for (let i = 0; i < monitorConfig.length; i++) {
const [selector, expected] = monitorConfig[i];
const actual = await page.inputValue(selector);
expect(actual).toEqual(expected);
}
},
async fillCodeEditor(value: string) {
await page.fill('[data-test-subj=codeEditorContainer] textarea', value);
},
async createBasicHTTPMonitorDetails({
name,
url,
apmServiceName,
locations,
}: {
name: string;
url: string;
apmServiceName: string;
locations: string[];
}) {
await this.selectMonitorType('syntheticsMonitorTypeHTTP');
await this.createBasicMonitorDetails({ name, apmServiceName, locations });
await this.fillByTestSubj('syntheticsMonitorConfigURL', url);
},
async createBasicTCPMonitorDetails({
name,
host,
apmServiceName,
locations,
}: {
name: string;
host: string;
apmServiceName: string;
locations: string[];
}) {
await this.selectMonitorType('syntheticsMonitorTypeTCP');
await this.createBasicMonitorDetails({ name, apmServiceName, locations });
await this.fillByTestSubj('syntheticsMonitorConfigHost', host);
},
async createBasicICMPMonitorDetails({
name,
host,
apmServiceName,
locations,
}: {
name: string;
host: string;
apmServiceName: string;
locations: string[];
}) {
await this.selectMonitorType('syntheticsMonitorTypeICMP');
await this.createBasicMonitorDetails({ name, apmServiceName, locations });
await this.fillByTestSubj('syntheticsMonitorConfigHost', host);
},
async createBasicBrowserMonitorDetails({
name,
inlineScript,
recorderScript,
params,
username,
password,
apmServiceName,
locations,
}: {
name: string;
inlineScript?: string;
recorderScript?: string;
params?: string;
username?: string;
password?: string;
apmServiceName: string;
locations: string[];
}) {
await this.createBasicMonitorDetails({ name, apmServiceName, locations });
if (inlineScript) {
await this.clickByTestSubj('syntheticsSourceTab__inline');
await this.fillCodeEditor(inlineScript);
return;
}
if (recorderScript) {
// Upload buffer from memory
await page.setInputFiles('input[data-test-subj=syntheticsFleetScriptRecorderUploader]', {
name: 'file.js',
mimeType: 'text/javascript',
buffer: Buffer.from(recorderScript),
});
}
},
async createBasicMonitorDetails({
name,
apmServiceName,
locations,
}: {
name: string;
apmServiceName: string;
locations: string[];
}) {
await page.click('text="Advanced options"');
await this.fillByTestSubj('syntheticsMonitorConfigName', name);
await this.fillByTestSubj('syntheticsMonitorConfigAPMServiceName', apmServiceName);
await this.selectLocationsAddEdit({ locations });
},
async createMonitor({
monitorConfig,
monitorType,
}: {
monitorConfig: Record<string, string | string[]>;
monitorType: FormMonitorType;
}) {
switch (monitorType) {
case FormMonitorType.HTTP:
// @ts-ignore
await this.createBasicHTTPMonitorDetails(monitorConfig);
break;
case FormMonitorType.TCP:
// @ts-ignore
await this.createBasicTCPMonitorDetails(monitorConfig);
break;
case FormMonitorType.ICMP:
// @ts-ignore
await this.createBasicICMPMonitorDetails(monitorConfig);
break;
case FormMonitorType.MULTISTEP:
// @ts-ignore
await this.createBasicBrowserMonitorDetails(monitorConfig);
break;
default:
break;
}
},
async enableMonitorManagement(shouldEnable: boolean = true) {
const isEnabled = await this.checkIsEnabled();
if (isEnabled === shouldEnable) {

View file

@ -8,13 +8,17 @@
import React, { useEffect } from 'react';
import { EuiEmptyPrompt, EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { useBreadcrumbs } from '../../hooks';
import { getServiceLocations } from '../../state';
import { MONITOR_ADD_ROUTE } from '../../../../../common/constants/ui';
import { SimpleMonitorForm } from './simple_monitor_form';
export const GettingStartedPage = () => {
const dispatch = useDispatch();
const history = useHistory();
useEffect(() => {
dispatch(getServiceLocations());
@ -32,7 +36,13 @@ export const GettingStartedPage = () => {
<>
<EuiText size="s">
{OR_LABEL}{' '}
<EuiLink href="/synthetics/monitors/add-new">{SELECT_DIFFERENT_MONITOR}</EuiLink>
<EuiLink
href={history.createHref({
pathname: MONITOR_ADD_ROUTE,
})}
>
{SELECT_DIFFERENT_MONITOR}
</EuiLink>
{i18n.translate('xpack.synthetics.gettingStarted.createSingle.description', {
defaultMessage: ' to get started with Elastic Synthetics Monitoring',
})}

View file

@ -77,7 +77,13 @@ export const SimpleMonitorForm = () => {
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton type="submit" fill iconType="plusInCircleFilled" isLoading={loading}>
<EuiButton
type="submit"
fill
iconType="plusInCircleFilled"
isLoading={loading}
data-test-subj="syntheticsMonitorConfigSubmitButton"
>
{CREATE_MONITOR_LABEL}
</EuiButton>
</EuiFlexItem>

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescribedFormGroup, EuiPanel, EuiSpacer } from '@elastic/eui';
import { useFormContext, FieldError } from 'react-hook-form';
import { FORM_CONFIG } from '../form/form_config';
import { Field } from '../form/field';
import { ConfigKey, FormMonitorType } from '../types';
export const AdvancedConfig = () => {
const {
watch,
formState: { errors },
} = useFormContext();
const [type]: [FormMonitorType] = watch([ConfigKey.FORM_MONITOR_TYPE]);
return FORM_CONFIG[type]?.advanced ? (
<EuiPanel hasBorder>
<EuiAccordion
id="syntheticsAdvancedPanel"
buttonContent={i18n.translate('xpack.synthetics.monitorConfig.advancedOptions.title', {
defaultMessage: 'Advanced options',
})}
>
<EuiSpacer />
{FORM_CONFIG[type].advanced?.map((configGroup) => {
return (
<EuiDescribedFormGroup
description={configGroup.description}
title={<h4>{configGroup.title}</h4>}
fullWidth
key={configGroup.title}
>
{configGroup.components.map((field) => {
return (
<Field
{...field}
key={field.fieldKey}
fieldError={errors[field.fieldKey] as FieldError}
/>
);
})}
</EuiDescribedFormGroup>
);
})}
</EuiAccordion>
</EuiPanel>
) : null;
};

View file

@ -0,0 +1,9 @@
/*
* 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 * from '../../../../../common/constants/monitor_management';
export * from '../../../../../common/constants/monitor_defaults';

View file

@ -0,0 +1,60 @@
/*
* 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 styled from 'styled-components';
import { EuiPanel } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { CodeEditor as MonacoCodeEditor } from '@kbn/kibana-react-plugin/public';
import { MonacoEditorLangId } from '../types';
const CodeEditorContainer = styled(EuiPanel)`
padding: 0;
`;
interface Props {
ariaLabel: string;
id: string;
languageId: MonacoEditorLangId;
onChange: (value: string) => void;
value: string;
placeholder?: string;
}
export const CodeEditor = ({ ariaLabel, id, languageId, onChange, value, placeholder }: Props) => {
return (
<CodeEditorContainer borderRadius="none" hasShadow={false} hasBorder={true}>
<MonacoCodeContainer
id={`${id}-editor`}
aria-label={ariaLabel}
data-test-subj="codeEditorContainer"
>
<MonacoCodeEditor
languageId={languageId}
width="100%"
height="250px"
value={value}
onChange={onChange}
options={{
renderValidationDecorations: value ? 'on' : 'off',
}}
isCopyable={true}
allowFullScreen={true}
placeholder={placeholder}
/>
</MonacoCodeContainer>
</CodeEditorContainer>
);
};
const MonacoCodeContainer = euiStyled.div`
& > .kibanaCodeEditor {
z-index: 0;
}
`;

View file

@ -0,0 +1,37 @@
/*
* 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 { fireEvent } from '@testing-library/react';
import React from 'react';
import { render } from '../../../utils/testing/rtl_helpers';
import { ComboBox } from './combo_box';
describe('<ComboBox />', () => {
const onChange = jest.fn();
const selectedOptions: string[] = [];
it('renders ComboBox', () => {
const { getByTestId } = render(
<ComboBox selectedOptions={selectedOptions} onChange={onChange} />
);
expect(getByTestId('syntheticsFleetComboBox')).toBeInTheDocument();
});
it('calls onBlur', () => {
const onBlur = jest.fn();
const { getByTestId } = render(
<ComboBox selectedOptions={selectedOptions} onChange={onChange} onBlur={onBlur} />
);
const combobox = getByTestId('syntheticsFleetComboBox');
fireEvent.focus(combobox);
fireEvent.blur(combobox);
expect(onBlur).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,79 @@
/*
* 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, useCallback } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
export interface Props {
onChange: (value: string[]) => void;
onBlur?: () => void;
selectedOptions: string[];
}
export const ComboBox = ({ onChange, onBlur, selectedOptions, ...props }: Props) => {
const [formattedSelectedOptions, setSelectedOptions] = useState<
Array<EuiComboBoxOptionOption<string>>
>(selectedOptions.map((option) => ({ label: option, key: option })));
const [isInvalid, setInvalid] = useState(false);
const onOptionsChange = useCallback(
(options: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedOptions(options);
const formattedTags = options.map((option) => option.label);
onChange(formattedTags);
setInvalid(false);
},
[onChange, setSelectedOptions, setInvalid]
);
const onCreateOption = useCallback(
(tag: string) => {
const formattedTag = tag.trim();
const newOption = {
label: formattedTag,
};
onChange([...selectedOptions, formattedTag]);
// Select the option.
setSelectedOptions([...formattedSelectedOptions, newOption]);
},
[onChange, formattedSelectedOptions, selectedOptions, setSelectedOptions]
);
const onSearchChange = useCallback(
(searchValue: string) => {
if (!searchValue) {
setInvalid(false);
return;
}
setInvalid(!isValid(searchValue));
},
[setInvalid]
);
return (
<EuiComboBox<string>
data-test-subj="syntheticsFleetComboBox"
noSuggestions
selectedOptions={formattedSelectedOptions}
onCreateOption={onCreateOption}
onChange={onOptionsChange}
onBlur={() => onBlur?.()}
onSearchChange={onSearchChange}
isInvalid={isInvalid}
{...props}
/>
);
};
const isValid = (value: string) => {
// Ensure that the tag is more than whitespace
return value.match(/\S+/) !== null;
};

View file

@ -0,0 +1,109 @@
/*
* 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 { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../utils/testing/rtl_helpers';
import { HeaderField, contentTypes } from './header_field';
import { Mode } from '../types';
describe('<HeaderField />', () => {
const onChange = jest.fn();
const onBlur = jest.fn();
const defaultValue = {};
afterEach(() => {
jest.resetAllMocks();
});
it('renders HeaderField', () => {
const { getByText, getByTestId } = render(
<HeaderField defaultValue={{ sample: 'header' }} onChange={onChange} />
);
expect(getByText('Key')).toBeInTheDocument();
expect(getByText('Value')).toBeInTheDocument();
const key = getByTestId('keyValuePairsKey0') as HTMLInputElement;
const value = getByTestId('keyValuePairsValue0') as HTMLInputElement;
expect(key.value).toEqual('sample');
expect(value.value).toEqual('header');
});
it('calls onBlur', () => {
const { getByTestId } = render(
<HeaderField defaultValue={{ sample: 'header' }} onChange={onChange} onBlur={onBlur} />
);
const key = getByTestId('keyValuePairsKey0') as HTMLInputElement;
const value = getByTestId('keyValuePairsValue0') as HTMLInputElement;
fireEvent.blur(key);
fireEvent.blur(value);
expect(onBlur).toHaveBeenCalledTimes(2);
});
it('formats headers and handles onChange', async () => {
const { getByTestId, getByText } = render(
<HeaderField defaultValue={defaultValue} onChange={onChange} />
);
const addHeader = getByText('Add header');
fireEvent.click(addHeader);
const key = getByTestId('keyValuePairsKey0') as HTMLInputElement;
const value = getByTestId('keyValuePairsValue0') as HTMLInputElement;
const newKey = 'sampleKey';
const newValue = 'sampleValue';
fireEvent.change(key, { target: { value: newKey } });
fireEvent.change(value, { target: { value: newValue } });
await waitFor(() => {
expect(onChange).toBeCalledWith({
[newKey]: newValue,
});
});
});
it('handles deleting headers', async () => {
const { getByTestId, getByText, getByLabelText } = render(
<HeaderField defaultValue={{ sampleKey: 'sampleValue' }} onChange={onChange} />
);
const addHeader = getByText('Add header');
fireEvent.click(addHeader);
const key = getByTestId('keyValuePairsKey0') as HTMLInputElement;
const value = getByTestId('keyValuePairsValue0') as HTMLInputElement;
const newKey = 'sampleKey';
const newValue = 'sampleValue';
fireEvent.change(key, { target: { value: newKey } });
fireEvent.change(value, { target: { value: newValue } });
await waitFor(() => {
expect(onChange).toBeCalledWith({
[newKey]: newValue,
});
});
const deleteBtn = getByLabelText('Delete item number 2, sampleKey:sampleValue');
// uncheck
fireEvent.click(deleteBtn);
});
it('handles content mode', async () => {
const contentMode: Mode = Mode.PLAINTEXT;
render(
<HeaderField defaultValue={defaultValue} onChange={onChange} contentMode={contentMode} />
);
await waitFor(() => {
expect(onChange).toBeCalledWith({
'Content-Type': contentTypes[Mode.PLAINTEXT],
});
});
});
});

View file

@ -0,0 +1,77 @@
/*
* 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 { ContentType, Mode } from '../types';
import { KeyValuePairsField, Pair } from './key_value_field';
interface Props {
contentMode?: Mode;
defaultValue: Record<string, string>;
onChange: (value: Record<string, string>) => void;
onBlur?: () => void;
'data-test-subj'?: string;
}
export const HeaderField = ({
contentMode,
defaultValue,
onChange,
onBlur,
'data-test-subj': dataTestSubj,
}: Props) => {
const defaultValueKeys = Object.keys(defaultValue).filter((key) => key !== 'Content-Type'); // Content-Type is a secret header we hide from the user
const formattedDefaultValues: Pair[] = [
...defaultValueKeys.map<Pair>((key) => {
return [key || '', defaultValue[key] || '']; // key, value
}),
];
const [headers, setHeaders] = useState<Pair[]>(formattedDefaultValues);
useEffect(() => {
const formattedHeaders = headers.reduce((acc: Record<string, string>, header) => {
const [key, value] = header;
if (key) {
return {
...acc,
[key]: value,
};
}
return acc;
}, {});
if (contentMode) {
onChange({ 'Content-Type': contentTypes[contentMode], ...formattedHeaders });
} else {
onChange(formattedHeaders);
}
}, [contentMode, headers, onChange]);
return (
<KeyValuePairsField
addPairControlLabel={
<FormattedMessage
id="xpack.synthetics.createPackagePolicy.stepConfigure.headerField.addHeader.label"
defaultMessage="Add header"
/>
}
defaultPairs={headers}
onChange={setHeaders}
onBlur={() => onBlur?.()}
data-test-subj={dataTestSubj}
/>
);
};
export const contentTypes: Record<Mode, ContentType> = {
[Mode.JSON]: ContentType.JSON,
[Mode.PLAINTEXT]: ContentType.TEXT,
[Mode.XML]: ContentType.XML,
[Mode.FORM]: ContentType.FORM,
};

View file

@ -0,0 +1,115 @@
/*
* 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 { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../utils/testing/rtl_helpers';
import { ResponseBodyIndexField } from './index_response_body_field';
import { ResponseBodyIndexPolicy } from '../types';
describe('<ResponseBodyIndexField/>', () => {
const defaultDefaultValue = ResponseBodyIndexPolicy.ON_ERROR;
const onChange = jest.fn();
const onBlur = jest.fn();
const WrappedComponent = ({ defaultValue = defaultDefaultValue }) => {
return (
<ResponseBodyIndexField defaultValue={defaultValue} onChange={onChange} onBlur={onBlur} />
);
};
afterEach(() => {
jest.resetAllMocks();
});
it('renders ResponseBodyIndexField', () => {
const { getByText, getByTestId } = render(<WrappedComponent />);
const select = getByTestId('indexResponseBodyFieldSelect') as HTMLInputElement;
expect(select.value).toEqual(defaultDefaultValue);
expect(getByText('On error')).toBeInTheDocument();
expect(getByText('Index response body')).toBeInTheDocument();
});
it('handles select change', async () => {
const { getByText, getByTestId } = render(<WrappedComponent />);
const select = getByTestId('indexResponseBodyFieldSelect') as HTMLInputElement;
const newPolicy = ResponseBodyIndexPolicy.ALWAYS;
expect(select.value).toEqual(defaultDefaultValue);
fireEvent.change(select, { target: { value: newPolicy } });
await waitFor(() => {
expect(select.value).toBe(newPolicy);
expect(getByText('Always')).toBeInTheDocument();
expect(onChange).toBeCalledWith(newPolicy);
});
});
it('calls onBlur', async () => {
const { getByTestId } = render(<WrappedComponent />);
const select = getByTestId('indexResponseBodyFieldSelect') as HTMLInputElement;
const newPolicy = ResponseBodyIndexPolicy.ALWAYS;
fireEvent.change(select, { target: { value: newPolicy } });
fireEvent.blur(select);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('handles checkbox change', async () => {
const { getByTestId, getByLabelText } = render(<WrappedComponent />);
const checkbox = getByLabelText('Index response body') as HTMLInputElement;
const select = getByTestId('indexResponseBodyFieldSelect') as HTMLInputElement;
const newPolicy = ResponseBodyIndexPolicy.NEVER;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
await waitFor(() => {
expect(checkbox.checked).toBe(false);
expect(select).not.toBeInTheDocument();
expect(onChange).toBeCalledWith(newPolicy);
});
fireEvent.click(checkbox);
await waitFor(() => {
expect(checkbox.checked).toBe(true);
expect(select).not.toBeInTheDocument();
expect(onChange).toBeCalledWith(defaultDefaultValue);
});
});
it('handles ResponseBodyIndexPolicy.NEVER as a default value', async () => {
const { queryByTestId, getByTestId, getByLabelText } = render(
<WrappedComponent defaultValue={ResponseBodyIndexPolicy.NEVER} />
);
const checkbox = getByLabelText('Index response body') as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(
queryByTestId('indexResponseBodyFieldSelect') as HTMLInputElement
).not.toBeInTheDocument();
fireEvent.click(checkbox);
const select = getByTestId('indexResponseBodyFieldSelect') as HTMLInputElement;
await waitFor(() => {
expect(checkbox.checked).toBe(true);
expect(select).toBeInTheDocument();
expect(select.value).toEqual(ResponseBodyIndexPolicy.ON_ERROR);
// switches back to on error policy when checkbox is checked
expect(onChange).toBeCalledWith(ResponseBodyIndexPolicy.ON_ERROR);
});
const newPolicy = ResponseBodyIndexPolicy.ALWAYS;
fireEvent.change(select, { target: { value: newPolicy } });
await waitFor(() => {
expect(select.value).toEqual(newPolicy);
expect(onChange).toBeCalledWith(newPolicy);
});
});
});

View file

@ -0,0 +1,101 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCheckbox, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui';
import { ResponseBodyIndexPolicy } from '../types';
interface Props {
defaultValue: ResponseBodyIndexPolicy;
onChange: (responseBodyIndexPolicy: ResponseBodyIndexPolicy) => void;
onBlur?: () => void;
}
export const ResponseBodyIndexField = ({ defaultValue, onChange, onBlur }: Props) => {
const [policy, setPolicy] = useState<ResponseBodyIndexPolicy>(
defaultValue !== ResponseBodyIndexPolicy.NEVER ? defaultValue : ResponseBodyIndexPolicy.ON_ERROR
);
const [checked, setChecked] = useState<boolean>(defaultValue !== ResponseBodyIndexPolicy.NEVER);
useEffect(() => {
if (checked) {
setPolicy(policy);
onChange(policy);
} else {
onChange(ResponseBodyIndexPolicy.NEVER);
}
}, [checked, policy, setPolicy, onChange]);
useEffect(() => {
onChange(policy);
}, [onChange, policy]);
return (
<EuiFlexGroup>
<EuiFlexItem data-test-subj="syntheticsIndexResponseBody">
<EuiCheckbox
id="uptimeFleetIndexResponseBody"
checked={checked}
label={
<FormattedMessage
id="xpack.synthetics.createPackagePolicy.stepConfigure.httpAdvancedOptions.responseConfig.indexResponseBody"
defaultMessage="Index response body"
/>
}
onChange={(event) => {
const checkedEvent = event.target.checked;
setChecked(checkedEvent);
}}
onBlur={() => onBlur?.()}
/>
</EuiFlexItem>
{checked && (
<EuiFlexItem>
<EuiSelect
aria-label={i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.httpAdvancedOptions.responseConfig.responseBodyIndexPolicy',
{
defaultMessage: 'Response body index policy',
}
)}
data-test-subj="indexResponseBodyFieldSelect"
options={responseBodyIndexPolicyOptions}
value={policy}
onChange={(event) => {
setPolicy(event.target.value as ResponseBodyIndexPolicy);
}}
onBlur={() => onBlur?.()}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
const responseBodyIndexPolicyOptions = [
{
value: ResponseBodyIndexPolicy.ALWAYS,
text: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.always',
{
defaultMessage: 'Always',
}
),
},
{
value: ResponseBodyIndexPolicy.ON_ERROR,
text: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.onError',
{
defaultMessage: 'On error',
}
),
},
];

View file

@ -0,0 +1,89 @@
/*
* 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 userEvent from '@testing-library/user-event';
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../utils/testing/rtl_helpers';
import { KeyValuePairsField, Pair } from './key_value_field';
describe('<KeyValuePairsField />', () => {
const onChange = jest.fn();
const onBlur = jest.fn();
const defaultDefaultValue = [['', '']] as Pair[];
const WrappedComponent = ({
defaultValue = defaultDefaultValue,
addPairControlLabel = 'Add pair',
}) => {
return (
<KeyValuePairsField
defaultPairs={defaultValue}
onChange={onChange}
onBlur={onBlur}
addPairControlLabel={addPairControlLabel}
/>
);
};
afterEach(() => {
jest.resetAllMocks();
});
it('renders KeyValuePairsField', () => {
const { getByText } = render(<WrappedComponent />);
expect(getByText('Key')).toBeInTheDocument();
expect(getByText('Value')).toBeInTheDocument();
expect(getByText('Add pair')).toBeInTheDocument();
});
it('calls onBlur', () => {
const { getByText, getByTestId } = render(<WrappedComponent />);
const addPair = getByText('Add pair');
fireEvent.click(addPair);
const keyInput = getByTestId('keyValuePairsKey0') as HTMLInputElement;
const valueInput = getByTestId('keyValuePairsValue0') as HTMLInputElement;
userEvent.type(keyInput, 'some-key');
userEvent.type(valueInput, 'some-value');
fireEvent.blur(valueInput);
expect(onBlur).toHaveBeenCalledTimes(2);
});
it('handles adding and editing a new row', async () => {
const { getByTestId, queryByTestId, getByText } = render(
<WrappedComponent defaultValue={[]} />
);
expect(queryByTestId('keyValuePairsKey0')).not.toBeInTheDocument();
expect(queryByTestId('keyValuePairsValue0')).not.toBeInTheDocument(); // check that only one row exists
const addPair = getByText('Add pair');
fireEvent.click(addPair);
const newRowKey = getByTestId('keyValuePairsKey0') as HTMLInputElement;
const newRowValue = getByTestId('keyValuePairsValue0') as HTMLInputElement;
await waitFor(() => {
expect(newRowKey.value).toEqual('');
expect(newRowValue.value).toEqual('');
expect(onChange).toBeCalledWith([[newRowKey.value, newRowValue.value]]);
});
fireEvent.change(newRowKey, { target: { value: 'newKey' } });
fireEvent.change(newRowValue, { target: { value: 'newValue' } });
await waitFor(() => {
expect(newRowKey.value).toEqual('newKey');
expect(newRowValue.value).toEqual('newValue');
expect(onChange).toBeCalledWith([[newRowKey.value, newRowValue.value]]);
});
});
});

View file

@ -0,0 +1,201 @@
/*
* 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, { Fragment, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiButtonIcon,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormControlLayoutDelimited,
EuiFormLabel,
EuiFormFieldset,
EuiSpacer,
} from '@elastic/eui';
const StyledFieldset = styled(EuiFormFieldset)`
&&& {
legend {
width: calc(100% - 52px); // right margin + flex item padding
margin-right: 40px;
}
.euiFlexGroup {
margin-left: 0;
}
.euiFlexItem {
margin-left: 0;
padding-left: 12px;
}
}
`;
const StyledField = styled(EuiFieldText)`
text-align: left;
`;
export type Pair = [
string, // key
string // value
];
interface Props {
addPairControlLabel: string | React.ReactElement;
defaultPairs: Pair[];
onChange: (pairs: Pair[]) => void;
onBlur?: () => void;
'data-test-subj'?: string;
}
export const KeyValuePairsField = ({
addPairControlLabel,
defaultPairs,
onChange,
onBlur,
'data-test-subj': dataTestSubj,
}: Props) => {
const [pairs, setPairs] = useState<Pair[]>(defaultPairs);
const handleOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>, index: number, isKey: boolean) => {
const targetValue = event.target.value;
setPairs((prevPairs) => {
const newPairs = [...prevPairs];
const [prevKey, prevValue] = prevPairs[index];
newPairs[index] = isKey ? [targetValue, prevValue] : [prevKey, targetValue];
return newPairs;
});
},
[setPairs]
);
const handleAddPair = useCallback(() => {
setPairs((prevPairs) => [['', ''], ...prevPairs]);
}, [setPairs]);
const handleDeletePair = useCallback(
(index: number) => {
setPairs((prevPairs) => {
const newPairs = [...prevPairs];
newPairs.splice(index, 1);
return [...newPairs];
});
},
[setPairs]
);
useEffect(() => {
onChange(pairs);
}, [onChange, pairs]);
return (
<div data-test-subj={dataTestSubj}>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
iconType="plus"
onClick={handleAddPair}
data-test-subj={`${dataTestSubj}__button`}
>
{addPairControlLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<StyledFieldset
legend={
!!pairs.length
? {
children: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem>
{
<FormattedMessage
id="xpack.synthetics.keyValuePairsField.key.label"
defaultMessage="Key"
/>
}
</EuiFlexItem>
<EuiFlexItem>
{
<FormattedMessage
id="xpack.synthetics.keyValuePairsField.value.label"
defaultMessage="Value"
/>
}
</EuiFlexItem>
</EuiFlexGroup>
),
}
: undefined
}
>
{pairs.map((pair, index) => {
const [key, value] = pair;
return (
<Fragment key={index}>
<EuiSpacer size="xs" />
<EuiFormControlLayoutDelimited
fullWidth
append={
<EuiFormLabel>
<EuiButtonIcon
iconType="trash"
aria-label={i18n.translate(
'xpack.synthetics.keyValuePairsField.deleteItem.label',
{
defaultMessage: 'Delete item number {index}, {key}:{value}',
values: { index: index + 1, key, value },
}
)}
onClick={() => handleDeletePair(index)}
/>
</EuiFormLabel>
}
startControl={
<StyledField
aria-label={i18n.translate(
'xpack.synthetics.keyValuePairsField.key.ariaLabel',
{
defaultMessage: 'Key',
}
)}
data-test-subj={`keyValuePairsKey${index}`}
value={key}
onChange={(event) => handleOnChange(event, index, true)}
onBlur={() => onBlur?.()}
/>
}
endControl={
<StyledField
aria-label={i18n.translate(
'xpack.synthetics.keyValuePairsField.value.ariaLabel',
{
defaultMessage: 'Value',
}
)}
data-test-subj={`keyValuePairsValue${index}`}
value={value}
onChange={(event) => handleOnChange(event, index, false)}
onBlur={() => onBlur?.()}
/>
}
delimiter=":"
/>
<EuiSpacer size="xs" />
</Fragment>
);
})}
</StyledFieldset>
</div>
);
};

View file

@ -0,0 +1,109 @@
/*
* 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 { i18n } from '@kbn/i18n';
import {
EuiPanel,
EuiText,
EuiLink,
EuiSpacer,
EuiKeyPadMenu,
EuiKeyPadMenuItem,
EuiIcon,
EuiKeyPadMenuItemProps,
} from '@elastic/eui';
export type MonitorTypeRadioOption = EuiKeyPadMenuItemProps & {
icon: string;
description: string;
descriptionTitle: string;
link: string;
value: string;
label: React.ReactNode;
onChange: (id: string, value: string) => void;
name: string;
'data-test-subj': string;
};
export const MonitorType = ({
id,
value,
label,
icon,
onChange,
name,
isSelected,
'data-test-subj': dataTestSubj,
}: MonitorTypeRadioOption) => {
return (
<EuiKeyPadMenuItem
checkable="single"
label={label}
id={id}
value={value}
onChange={onChange}
name={name}
isSelected={isSelected}
data-test-subj={dataTestSubj}
>
<EuiIcon type={icon} />
</EuiKeyPadMenuItem>
);
};
export const MonitorTypeRadioGroup = ({
options,
value,
name,
onChange,
ariaLegend,
...props
}: EuiKeyPadMenuItemProps & {
options: MonitorTypeRadioOption[];
onChange: React.ChangeEvent<HTMLInputElement>;
name: string;
value: string;
ariaLegend: string;
}) => {
const selectedOption = options.find((radio) => radio.value === value);
return (
<>
<EuiKeyPadMenu checkable={{ ariaLegend }} css={{ width: '100%' }}>
{options.map((radio) => {
return (
<MonitorType
{...props}
{...radio}
name={name}
onChange={onChange}
isSelected={radio.value === value}
data-test-subj={radio['data-test-subj']}
/>
);
})}
</EuiKeyPadMenu>
<EuiSpacer />
{selectedOption && (
<EuiPanel color="primary">
<EuiText size="s">
<h4>{selectedOption.descriptionTitle}</h4>
</EuiText>
<EuiText size="s" color="subdued">
<span>{`${selectedOption.description} `}</span>
<EuiLink href={selectedOption.link} target="_blank">
{i18n.translate('xpack.synthetics.monitorConfig.monitorType.learnMoreLink', {
defaultMessage: 'Learn more',
})}
</EuiLink>
</EuiText>
<EuiSpacer size="xs" />
</EuiPanel>
)}
</>
);
};

View file

@ -0,0 +1,20 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import { EuiText } from '@elastic/eui';
export const OptionalLabel = () => {
return (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.synthetics.createPackagePolicy.stepConfigure.inputVarFieldOptionalLabel"
defaultMessage="Optional"
/>
</EuiText>
);
};

View file

@ -0,0 +1,88 @@
/*
* 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 'jest-canvas-mock';
import React, { useState, useCallback } from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../utils/testing/rtl_helpers';
import { RequestBodyField } from './request_body_field';
import { Mode } from '../types';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => `id-${Math.random()}`,
}));
jest.mock('@kbn/kibana-react-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
return {
...original,
// Mocking CodeEditor, which uses React Monaco under the hood
CodeEditor: (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockCodeEditor'}
data-currentvalue={props.value}
onChange={(e: any) => {
props.onChange(e.jsonContent);
}}
/>
),
};
});
describe('<RequestBodyField />', () => {
const defaultMode = Mode.PLAINTEXT;
const defaultValue = 'sample value';
const WrappedComponent = () => {
const [config, setConfig] = useState({
type: defaultMode,
value: defaultValue,
});
return (
<RequestBodyField
value={{
value: config.value,
type: config.type,
}}
onChange={useCallback(
(code) => setConfig({ type: code.type as Mode, value: code.value }),
[setConfig]
)}
/>
);
};
it('renders RequestBodyField', () => {
const { getByText, getByLabelText } = render(<WrappedComponent />);
expect(getByText('Form')).toBeInTheDocument();
expect(getByText('Text')).toBeInTheDocument();
expect(getByText('XML')).toBeInTheDocument();
expect(getByText('JSON')).toBeInTheDocument();
expect(getByLabelText('Text code editor')).toBeInTheDocument();
});
it('handles changing code editor mode', async () => {
const { getByText, getByLabelText, queryByText, queryByLabelText } = render(
<WrappedComponent />
);
// currently text code editor is displayed
expect(getByLabelText('Text code editor')).toBeInTheDocument();
expect(queryByText('Key')).not.toBeInTheDocument();
const formButton = getByText('Form').closest('button');
if (formButton) {
fireEvent.click(formButton);
}
await waitFor(() => {
expect(getByText('Add form field')).toBeInTheDocument();
expect(queryByLabelText('Text code editor')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,208 @@
/*
* 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, useMemo, useState } from 'react';
import { stringify, parse } from 'query-string';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiTabbedContent } from '@elastic/eui';
import { Mode, MonacoEditorLangId } from '../types';
import { KeyValuePairsField, Pair } from './key_value_field';
import { CodeEditor } from './code_editor';
interface Props {
onChange: (requestBody: { type: Mode; value: string }) => void;
onBlur?: () => void;
value: {
type: Mode;
value: string;
};
}
enum ResponseBodyType {
CODE = 'code',
FORM = 'form',
}
// TO DO: Look into whether or not code editor reports errors, in order to prevent form submission on an error
export const RequestBodyField = ({ onChange, onBlur, value: { type, value } }: Props) => {
const [values, setValues] = useState<Record<ResponseBodyType, string>>({
[ResponseBodyType.FORM]: type === Mode.FORM ? value : '',
[ResponseBodyType.CODE]: type !== Mode.FORM ? value : '',
});
useEffect(() => {
onChange({
type,
value: type === Mode.FORM ? values[ResponseBodyType.FORM] : values[ResponseBodyType.CODE],
});
}, [onChange, type, values]);
const handleSetMode = useCallback(
(currentMode: Mode) => {
onChange({
type: currentMode,
value:
currentMode === Mode.FORM ? values[ResponseBodyType.FORM] : values[ResponseBodyType.CODE],
});
},
[onChange, values]
);
const onChangeFormFields = useCallback(
(pairs: Pair[]) => {
const formattedPairs = pairs.reduce((acc: Record<string, string>, header) => {
const [key, pairValue] = header;
if (key) {
return {
...acc,
[key]: pairValue,
};
}
return acc;
}, {});
return setValues((prevValues) => ({
...prevValues,
[Mode.FORM]: stringify(formattedPairs),
}));
},
[setValues]
);
const defaultFormPairs: Pair[] = useMemo(() => {
const pairs = parse(values[Mode.FORM]);
const keys = Object.keys(pairs);
const formattedPairs: Pair[] = keys.map((key: string) => {
// key, value, checked;
return [key, `${pairs[key]}`];
});
return formattedPairs;
}, [values]);
const tabs = [
{
id: Mode.PLAINTEXT,
name: modeLabels[Mode.PLAINTEXT],
'data-test-subj': `syntheticsRequestBodyTab__${Mode.PLAINTEXT}`,
content: (
<CodeEditor
ariaLabel={i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.requestBody.codeEditor.text.ariaLabel',
{
defaultMessage: 'Text code editor',
}
)}
id={Mode.PLAINTEXT}
languageId={MonacoEditorLangId.PLAINTEXT}
onChange={(code) => {
setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code }));
onBlur?.();
}}
value={values[ResponseBodyType.CODE]}
/>
),
},
{
id: Mode.JSON,
name: modeLabels[Mode.JSON],
'data-test-subj': `syntheticsRequestBodyTab__${Mode.JSON}`,
content: (
<CodeEditor
ariaLabel={i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.requestBody.codeEditor.json.ariaLabel',
{
defaultMessage: 'JSON code editor',
}
)}
id={Mode.JSON}
languageId={MonacoEditorLangId.JSON}
onChange={(code) => {
setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code }));
onBlur?.();
}}
value={values[ResponseBodyType.CODE]}
/>
),
},
{
id: Mode.XML,
name: modeLabels[Mode.XML],
'data-test-subj': `syntheticsRequestBodyTab__${Mode.XML}`,
content: (
<CodeEditor
ariaLabel={i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.requestBody.codeEditor.xml.ariaLabel',
{
defaultMessage: 'XML code editor',
}
)}
id={Mode.XML}
languageId={MonacoEditorLangId.XML}
onChange={(code) => {
setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code }));
onBlur?.();
}}
value={values[ResponseBodyType.CODE]}
/>
),
},
{
id: Mode.FORM,
name: modeLabels[Mode.FORM],
'data-test-subj': `syntheticsRequestBodyTab__${Mode.FORM}`,
content: (
<KeyValuePairsField
addPairControlLabel={
<FormattedMessage
id="xpack.synthetics.createPackagePolicy.stepConfigure.requestBody.formField.addFormField.label"
defaultMessage="Add form field"
/>
}
defaultPairs={defaultFormPairs}
onChange={onChangeFormFields}
onBlur={() => onBlur?.()}
/>
),
},
];
return (
<EuiTabbedContent
tabs={tabs}
initialSelectedTab={tabs.find((tab) => tab.id === type)}
autoFocus="selected"
onTabClick={(tab) => {
handleSetMode(tab.id as Mode);
}}
/>
);
};
const modeLabels = {
[Mode.FORM]: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.form',
{
defaultMessage: 'Form',
}
),
[Mode.PLAINTEXT]: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.text',
{
defaultMessage: 'Text',
}
),
[Mode.JSON]: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.JSON',
{
defaultMessage: 'JSON',
}
),
[Mode.XML]: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.XML',
{
defaultMessage: 'XML',
}
),
};

View file

@ -0,0 +1,122 @@
/*
* 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 { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../utils/testing/rtl_helpers';
import { ScriptRecorderFields } from './script_recorder_fields';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
...jest.requireActual('@elastic/eui/lib/services/accessibility/html_id_generator'),
htmlIdGenerator: () => () => `id-${Math.random()}`,
}));
const onChange = jest.fn();
describe('<ScriptRecorderFields />', () => {
let file: File;
const testScript = 'step(() => {})';
const WrappedComponent = ({
isEditable = true,
script = '',
fileName = '',
}: {
isEditable?: boolean;
script?: string;
fileName?: string;
}) => {
return (
<ScriptRecorderFields
script={script}
fileName={fileName}
onChange={onChange}
isEditable={isEditable}
/>
);
};
beforeEach(() => {
jest.clearAllMocks();
file = new File([testScript], 'samplescript.js', { type: 'text/javascript' });
});
it('renders ScriptRecorderFields', () => {
const { queryByText } = render(<WrappedComponent />);
expect(queryByText('Show script')).not.toBeInTheDocument();
expect(queryByText('Remove script')).not.toBeInTheDocument();
});
it('handles uploading files', async () => {
const { getByTestId } = render(<WrappedComponent />);
const uploader = getByTestId('syntheticsFleetScriptRecorderUploader');
fireEvent.change(uploader, {
target: { files: [file] },
});
await waitFor(() => {
expect(onChange).toBeCalledWith({ scriptText: testScript, fileName: 'samplescript.js' });
});
});
it('shows user errors for invalid file types', async () => {
const { getByTestId, getByText } = render(<WrappedComponent />);
file = new File(['journey(() => {})'], 'samplescript.js', { type: 'text/javascript' });
let uploader = getByTestId('syntheticsFleetScriptRecorderUploader') as HTMLInputElement;
fireEvent.change(uploader, {
target: { files: [file] },
});
uploader = getByTestId('syntheticsFleetScriptRecorderUploader') as HTMLInputElement;
await waitFor(() => {
expect(onChange).not.toBeCalled();
expect(
getByText(
'Error uploading file. Please upload a .js file generated by the Elastic Synthetics Recorder in inline script format.'
)
).toBeInTheDocument();
});
});
it('shows show script button when script is available', () => {
const { getByText, queryByText } = render(<WrappedComponent script={testScript} />);
const showScriptBtn = getByText('Show script');
expect(queryByText(testScript)).not.toBeInTheDocument();
fireEvent.click(showScriptBtn);
expect(getByText(testScript)).toBeInTheDocument();
});
it('shows show remove script button when script is available and isEditable is true', async () => {
const { getByText, getByTestId } = render(
<WrappedComponent script={testScript} isEditable={true} />
);
const showScriptBtn = getByText('Show script');
fireEvent.click(showScriptBtn);
expect(getByText(testScript)).toBeInTheDocument();
fireEvent.click(getByTestId('euiFlyoutCloseButton'));
const removeScriptBtn = getByText('Remove script');
fireEvent.click(removeScriptBtn);
await waitFor(() => {
expect(onChange).toBeCalledWith({ scriptText: '', fileName: '' });
});
});
});

View file

@ -0,0 +1,125 @@
/*
* 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, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutHeader,
EuiFormRow,
EuiCodeBlock,
EuiTitle,
EuiButton,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { Uploader } from './uploader';
interface Props {
onChange: ({ scriptText, fileName }: { scriptText: string; fileName: string }) => void;
script: string;
fileName?: string;
isEditable?: boolean;
}
export function ScriptRecorderFields({ onChange, script, fileName, isEditable }: Props) {
const [showScript, setShowScript] = useState(false);
const handleUpload = useCallback(
({ scriptText, fileName: fileNameT }: { scriptText: string; fileName: string }) => {
onChange({ scriptText, fileName: fileNameT });
},
[onChange]
);
return (
<>
<EuiSpacer size="m" />
{isEditable && script ? (
<EuiFormRow aria-label="Testing script" fullWidth>
<EuiText size="s">
<strong>{fileName}</strong>
</EuiText>
</EuiFormRow>
) : (
<Uploader onUpload={handleUpload} />
)}
{script && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => setShowScript(true)}
iconType="editorCodeBlock"
iconSide="right"
>
<FormattedMessage
id="xpack.synthetics.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.zipUrl.showScriptLabel"
defaultMessage="Show script"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isEditable && (
<EuiButton
onClick={() => onChange({ scriptText: '', fileName: '' })}
iconType="trash"
iconSide="right"
color="danger"
>
<FormattedMessage
id="xpack.synthetics.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.zipUrl.removeScriptLabel"
defaultMessage="Remove script"
/>
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
{showScript && (
<EuiFlyout
ownFocus
onClose={() => setShowScript(false)}
aria-labelledby="syntheticsBrowserScriptBlockHeader"
closeButtonAriaLabel={CLOSE_BUTTON_LABEL}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<span id="syntheticsBrowserScriptBlockHeader">
{fileName || PLACEHOLDER_FILE_NAME}
</span>
</EuiTitle>
</EuiFlyoutHeader>
<div style={{ height: '100%' }}>
<EuiCodeBlock language="js" overflowHeight={'100%'} fontSize="m" isCopyable>
{script}
</EuiCodeBlock>
</div>
</EuiFlyout>
)}
</>
);
}
const PLACEHOLDER_FILE_NAME = i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.scriptRecorder.mockFileName',
{
defaultMessage: 'test_script.js',
}
);
const CLOSE_BUTTON_LABEL = i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.scriptRecorder.closeButtonLabel',
{
defaultMessage: 'Close script flyout',
}
);

View file

@ -0,0 +1,99 @@
/*
* 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 userEvent from '@testing-library/user-event';
import { render } from '../../../utils/testing/rtl_helpers';
import { SourceField } from './source_field';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
...jest.requireActual('@elastic/eui/lib/services/accessibility/html_id_generator'),
htmlIdGenerator: () => () => `id-${Math.random()}`,
}));
jest.mock('@kbn/kibana-react-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
return {
...original,
// Mocking CodeEditor, which uses React Monaco under the hood
CodeEditor: (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockCodeEditor'}
data-currentvalue={props.value}
onChange={(e: any) => {
props.onChange(e.jsonContent);
}}
/>
),
};
});
const onChange = jest.fn();
const onBlur = jest.fn();
describe('<ScriptRecorderFields />', () => {
const WrappedComponent = ({
script = '',
fileName = '',
type = 'recorder',
isEdit = false,
}: {
isEditable?: boolean;
script?: string;
fileName?: string;
type?: 'recorder' | 'inline';
isEdit?: boolean;
}) => {
return (
<SourceField
value={{
script,
fileName,
type,
}}
onChange={onChange}
onBlur={onBlur}
isEditFlow={isEdit}
/>
);
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders ScriptRecorderFields as the default tab', () => {
const { getByText } = render(<WrappedComponent />);
expect(getByText('Select or drag and drop a .js file')).toBeInTheDocument();
});
it('changes to code editor when selected', async () => {
const script = 'test script';
const { getByTestId } = render(<WrappedComponent type="inline" script={script} />);
expect(getByTestId('codeEditorContainer')).toBeInTheDocument();
});
it('displays code editor by default in edit flow', async () => {
const fileName = 'fileName';
const script = 'test script';
const { getByTestId } = render(
<WrappedComponent fileName={fileName} type="recorder" script={script} isEdit={true} />
);
expect(getByTestId('codeEditorContainer')).toBeInTheDocument();
});
it('displays filename of existing script in edit flow', async () => {
const fileName = 'fileName';
const script = 'test script';
const { getByText } = render(
<WrappedComponent fileName={fileName} type="recorder" script={script} isEdit={true} />
);
userEvent.click(getByText(/Upload new script/));
expect(getByText(fileName)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,179 @@
/*
* 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 styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
EuiTabbedContent,
EuiFormRow,
EuiBetaBadge,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { CodeEditor } from './code_editor';
import { ScriptRecorderFields } from './script_recorder_fields';
import { ConfigKey, MonacoEditorLangId } from '../types';
enum SourceType {
INLINE = 'syntheticsBrowserInlineConfig',
SCRIPT_RECORDER = 'syntheticsBrowserScriptRecorderConfig',
ZIP = 'syntheticsBrowserZipURLConfig',
}
interface SourceConfig {
script: string;
type: 'recorder' | 'inline';
fileName?: string;
}
interface Props {
onChange: (sourceConfig: SourceConfig) => void;
onBlur: (field: ConfigKey) => void;
value: SourceConfig;
isEditFlow?: boolean;
}
export const SourceField = ({ onChange, onBlur, value, isEditFlow = false }: Props) => {
const [sourceType, setSourceType] = useState<SourceType>(
value.type === 'inline' ? SourceType.INLINE : SourceType.SCRIPT_RECORDER
);
const [config, setConfig] = useState<SourceConfig>(value);
useEffect(() => {
onChange(config);
}, [config, onChange]);
const allTabs = [
{
id: 'syntheticsBrowserScriptRecorderConfig',
name: (
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
{isEditFlow ? (
<FormattedMessage
id="xpack.synthetics.monitorConfig.scriptRecorderEdit.label"
defaultMessage="Upload new script"
/>
) : (
<FormattedMessage
id="xpack.synthetics.monitorConfig.scriptRecorder.label"
defaultMessage="Upload script"
/>
)}
</EuiFlexItem>
<StyledBetaBadgeWrapper grow={false}>
<EuiBetaBadge
label={i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.browser.scriptRecorder.experimentalLabel',
{
defaultMessage: 'Tech preview',
}
)}
iconType="beaker"
tooltipContent={i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.browser.scriptRecorder.experimentalTooltip',
{
defaultMessage:
'Preview the quickest way to create Elastic Synthetics monitoring scripts with our Elastic Synthetics Recorder',
}
)}
/>
</StyledBetaBadgeWrapper>
</EuiFlexGroup>
),
'data-test-subj': 'syntheticsSourceTab__scriptRecorder',
content: (
<ScriptRecorderFields
onChange={({ scriptText, fileName }) => {
setConfig((prevConfig) => ({
...prevConfig,
script: scriptText,
type: 'recorder',
fileName,
}));
}}
script={config.script}
isEditable={isEditFlow}
fileName={config.fileName}
/>
),
},
{
id: 'syntheticsBrowserInlineConfig',
name: (
<FormattedMessage
id="xpack.synthetics.addEditMonitor.scriptEditor.label"
defaultMessage="Script editor"
/>
),
'data-test-subj': `syntheticsSourceTab__inline`,
content: (
<EuiFormRow
helpText={
<FormattedMessage
id="xpack.synthetics.addEditMonitor.scriptEditor.helpText"
defaultMessage="Runs Synthetic test scripts that are defined inline."
/>
}
fullWidth
>
<CodeEditor
ariaLabel={i18n.translate('xpack.synthetics.addEditMonitor.scriptEditor.ariaLabel', {
defaultMessage: 'JavaScript code editor',
})}
id="javascript"
languageId={MonacoEditorLangId.JAVASCRIPT}
onChange={(code) => {
setConfig((prevConfig) => ({ ...prevConfig, script: code }));
onBlur(ConfigKey.SOURCE_INLINE);
}}
value={config.script}
placeholder={i18n.translate(
'xpack.synthetics.addEditMonitor.scriptEditor.placeholder',
{
defaultMessage: '// Paste your Playwright script here...',
}
)}
/>
</EuiFormRow>
),
},
];
if (isEditFlow) {
allTabs.reverse();
}
return (
<EuiTabbedContent
tabs={allTabs}
initialSelectedTab={
isEditFlow
? allTabs.find((tab) => tab.id === SourceType.INLINE)
: allTabs.find((tab) => tab.id === sourceType)
}
autoFocus="selected"
onTabClick={(tab) => {
if (tab.id !== sourceType) {
setConfig({
script: '',
type: tab.id === SourceType.INLINE ? 'inline' : 'recorder',
fileName: '',
});
}
setSourceType(tab.id as SourceType);
}}
/>
);
};
const StyledBetaBadgeWrapper = styled(EuiFlexItem)`
.euiToolTipAnchor {
display: flex;
}
`;

View file

@ -0,0 +1,96 @@
/*
* 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, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiFilePicker } from '@elastic/eui';
interface Props {
onUpload: ({ scriptText, fileName }: { scriptText: string; fileName: string }) => void;
}
export function Uploader({ onUpload }: Props) {
const fileReader = useRef<null | FileReader>(null);
const [error, setError] = useState<string | null>(null);
const filePickerRef = useRef<EuiFilePicker>(null);
const handleFileRead = (fileName: string) => {
const content = fileReader?.current?.result as string;
if (content?.trim().slice(0, 4) !== 'step') {
setError(PARSING_ERROR);
filePickerRef.current?.removeFiles();
return;
}
onUpload({ scriptText: content, fileName });
setError(null);
};
const handleFileChosen = (files: FileList | null) => {
if (!files || !files.length) {
onUpload({ scriptText: '', fileName: '' });
return;
}
if (files.length && !files[0].type.includes('javascript')) {
setError(INVALID_FILE_ERROR);
filePickerRef.current?.removeFiles();
return;
}
fileReader.current = new FileReader();
fileReader.current.onloadend = () => handleFileRead(files[0].name);
fileReader.current.readAsText(files[0]);
};
return (
<EuiFormRow
isInvalid={Boolean(error)}
error={error}
aria-label={TESTING_SCRIPT_LABEL}
fullWidth
>
<EuiFilePicker
id="syntheticsFleetScriptRecorderUploader"
data-test-subj="syntheticsFleetScriptRecorderUploader"
ref={filePickerRef}
initialPromptText={PROMPT_TEXT}
onChange={handleFileChosen}
display={'large'}
fullWidth
/>
</EuiFormRow>
);
}
const TESTING_SCRIPT_LABEL = i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.uploader.fieldLabel',
{
defaultMessage: 'Testing script',
}
);
const PROMPT_TEXT = i18n.translate('xpack.synthetics.monitorConfig.uploader.label', {
defaultMessage: 'Select or drag and drop a .js file',
});
const INVALID_FILE_ERROR = i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.uploader.invalidFileError',
{
defaultMessage:
'Invalid file type. Please upload a .js file generated by the Elastic Synthetics Recorder.',
}
);
const PARSING_ERROR = i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.uploader.parsingError',
{
defaultMessage:
'Error uploading file. Please upload a .js file generated by the Elastic Synthetics Recorder in inline script format.',
}
);

View file

@ -0,0 +1,92 @@
/*
* 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 { EuiFormRow, EuiFormRowProps } from '@elastic/eui';
import { useSelector } from 'react-redux';
import {
UseFormReturn,
ControllerRenderProps,
ControllerFieldState,
useFormContext,
} from 'react-hook-form';
import { useKibanaSpace, useIsEditFlow } from '../hooks';
import { selectServiceLocationsState } from '../../../state';
import { FieldMeta } from '../types';
type Props = FieldMeta & {
component: React.ComponentType<any>;
field: ControllerRenderProps;
fieldState: ControllerFieldState;
formRowProps: Partial<EuiFormRowProps>;
error: React.ReactNode;
dependenciesValues: unknown[];
dependenciesFieldMeta: Record<string, ControllerFieldState>;
};
const setFieldValue = (key: string, setValue: UseFormReturn['setValue']) => (value: any) => {
setValue(key, value);
};
export const ControlledField = ({
component: FieldComponent,
props,
fieldKey,
shouldUseSetValue,
field,
formRowProps,
fieldState,
customHook,
error,
dependenciesValues,
dependenciesFieldMeta,
}: Props) => {
const { setValue, reset, formState } = useFormContext();
const noop = () => {};
let hook: Function = noop;
let hookProps;
const { locations } = useSelector(selectServiceLocationsState);
const { space } = useKibanaSpace();
const isEdit = useIsEditFlow();
if (customHook) {
hookProps = customHook(field.value);
hook = hookProps.func;
}
const { [hookProps?.fieldKey as string]: hookResult } = hook(hookProps?.params) || {};
const onChange = shouldUseSetValue ? setFieldValue(fieldKey, setValue) : field.onChange;
const generatedProps = props
? props({
field,
setValue,
reset,
locations,
dependencies: dependenciesValues,
dependenciesFieldMeta,
space: space?.id,
isEdit,
formState,
})
: {};
const isInvalid = hookResult || Boolean(fieldState.error);
const hookError = hookResult ? hookProps?.error : undefined;
return (
<EuiFormRow
{...formRowProps}
isInvalid={isInvalid}
error={isInvalid ? hookError || fieldState.error?.message || error : undefined}
>
<FieldComponent
{...field}
checked={field.value}
defaultValue={field.value}
onChange={onChange}
{...generatedProps}
isInvalid={isInvalid}
fullWidth
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,185 @@
/*
* 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 { ConfigKey, DataStream, FormMonitorType, SyntheticsMonitor } from '../types';
import { DEFAULT_FIELDS } from '../constants';
import { formatDefaultFormValues } from './defaults';
describe('defaults', () => {
const testScript = 'testScript';
const monitorValues = {
__ui: {
script_source: {
file_name: '',
is_generated_script: false,
},
},
enabled: true,
'filter_journeys.match': '',
'filter_journeys.tags': [],
form_monitor_type: 'multistep',
ignore_https_errors: false,
journey_id: '',
locations: [
{
id: 'us_central',
isServiceManaged: true,
},
],
name: 'Browser monitor',
namespace: 'default',
origin: 'ui',
params: '',
playwright_options: '',
playwright_text_assertion: '',
project_id: '',
schedule: {
number: '10',
unit: 'm',
},
screenshots: 'on',
'service.name': '',
'source.inline.script': testScript,
'source.project.content': '',
'source.zip_url.folder': '',
'source.zip_url.password': '',
'source.zip_url.proxy_url': '',
'source.zip_url.ssl.certificate': undefined,
'source.zip_url.ssl.certificate_authorities': undefined,
'source.zip_url.ssl.key': undefined,
'source.zip_url.ssl.key_passphrase': undefined,
'source.zip_url.ssl.supported_protocols': undefined,
'source.zip_url.ssl.verification_mode': undefined,
'source.zip_url.url': '',
'source.zip_url.username': '',
'ssl.certificate': '',
'ssl.certificate_authorities': '',
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
'ssl.verification_mode': 'full',
synthetics_args: [],
tags: [],
'throttling.config': '5d/3u/20l',
'throttling.download_speed': '5',
'throttling.is_enabled': true,
'throttling.latency': '20',
'throttling.upload_speed': '3',
timeout: '16',
type: 'browser',
'url.port': null,
urls: '',
} as SyntheticsMonitor;
it('correctly formats monitor type to form type', () => {
expect(formatDefaultFormValues(monitorValues)).toEqual({
__ui: {
script_source: {
file_name: '',
is_generated_script: false,
},
},
enabled: true,
'filter_journeys.match': '',
'filter_journeys.tags': [],
form_monitor_type: 'multistep',
ignore_https_errors: false,
journey_id: '',
locations: [
{
id: 'us_central',
isServiceManaged: true,
},
],
name: 'Browser monitor',
namespace: 'default',
origin: 'ui',
params: '',
playwright_options: '',
playwright_text_assertion: '',
project_id: '',
schedule: {
number: '10',
unit: 'm',
},
screenshots: 'on',
'service.name': '',
'source.inline': {
fileName: '',
script: 'testScript',
type: 'inline',
},
'source.inline.script': 'testScript',
'source.project.content': '',
'source.zip_url.folder': '',
'source.zip_url.password': '',
'source.zip_url.proxy_url': '',
'source.zip_url.ssl.certificate': undefined,
'source.zip_url.ssl.certificate_authorities': undefined,
'source.zip_url.ssl.key': undefined,
'source.zip_url.ssl.key_passphrase': undefined,
'source.zip_url.ssl.supported_protocols': undefined,
'source.zip_url.ssl.verification_mode': undefined,
'source.zip_url.url': '',
'source.zip_url.username': '',
'ssl.certificate': '',
'ssl.certificate_authorities': '',
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
'ssl.verification_mode': 'full',
synthetics_args: [],
tags: [],
'throttling.config': '5d/3u/20l',
'throttling.download_speed': '5',
'throttling.is_enabled': true,
'throttling.latency': '20',
'throttling.upload_speed': '3',
timeout: '16',
type: 'browser',
'url.port': null,
urls: '',
});
});
it.each([
[DataStream.HTTP, 'testCA'],
[DataStream.HTTP, ''],
[DataStream.TCP, 'testCA'],
[DataStream.TCP, ''],
])('correctly formats isTLSEnabled', (formType, testCA) => {
const monitor = {
...DEFAULT_FIELDS[formType as DataStream],
[ConfigKey.FORM_MONITOR_TYPE]: formType as unknown as FormMonitorType,
[ConfigKey.TLS_CERTIFICATE_AUTHORITIES]: testCA,
};
expect(formatDefaultFormValues(monitor)).toEqual({
...monitor,
isTLSEnabled: Boolean(testCA),
[ConfigKey.TLS_CERTIFICATE_AUTHORITIES]: testCA,
});
});
it.each([
[DataStream.HTTP, FormMonitorType.HTTP],
[DataStream.TCP, FormMonitorType.TCP],
[DataStream.ICMP, FormMonitorType.ICMP],
[DataStream.BROWSER, FormMonitorType.MULTISTEP],
])(
'correctly formats legacy uptime monitors to include ConfigKey.FORM_MONITOR_TYPE',
(dataStream, formType) => {
const monitor = {
...DEFAULT_FIELDS[dataStream],
[ConfigKey.FORM_MONITOR_TYPE]: undefined,
};
expect(formatDefaultFormValues(monitor as unknown as SyntheticsMonitor)).toEqual(
expect.objectContaining({
[ConfigKey.FORM_MONITOR_TYPE]: formType,
})
);
}
);
});

View file

@ -0,0 +1,109 @@
/*
* 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 { isEqual } from 'lodash';
import { DEFAULT_FIELDS, DEFAULT_TLS_FIELDS } from '../constants';
import {
ConfigKey,
DataStream,
FormMonitorType,
SyntheticsMonitor,
BrowserFields,
TLSFields,
} from '../types';
export const getDefaultFormFields = (
spaceId: string = 'default'
): Record<FormMonitorType, Record<string, any>> => {
return {
[FormMonitorType.MULTISTEP]: {
...DEFAULT_FIELDS[DataStream.BROWSER],
'source.inline': {
type: 'recorder',
script: '',
fileName: '',
},
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
[ConfigKey.NAMESPACE]: spaceId,
},
[FormMonitorType.SINGLE]: {
...DEFAULT_FIELDS[DataStream.BROWSER],
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.SINGLE,
[ConfigKey.NAMESPACE]: spaceId,
},
[FormMonitorType.HTTP]: {
...DEFAULT_FIELDS[DataStream.HTTP],
isTLSEnabled: false,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.HTTP,
[ConfigKey.NAMESPACE]: spaceId,
},
[FormMonitorType.TCP]: {
...DEFAULT_FIELDS[DataStream.TCP],
isTLSEnabled: false,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.TCP,
[ConfigKey.NAMESPACE]: spaceId,
},
[FormMonitorType.ICMP]: {
...DEFAULT_FIELDS[DataStream.ICMP],
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.ICMP,
[ConfigKey.NAMESPACE]: spaceId,
},
};
};
export const formatDefaultFormValues = (monitor?: SyntheticsMonitor) => {
if (!monitor) return undefined;
let formMonitorType = monitor[ConfigKey.FORM_MONITOR_TYPE];
const monitorType = monitor[ConfigKey.MONITOR_TYPE];
const monitorWithFormMonitorType = {
...monitor,
};
// handle default monitor types from Uptime, which don't contain `ConfigKey.FORM_MONITOR_TYPE`
if (!formMonitorType) {
formMonitorType =
monitorType === DataStream.BROWSER
? FormMonitorType.MULTISTEP
: (monitorType as Omit<DataStream, DataStream.BROWSER> as FormMonitorType);
monitorWithFormMonitorType[ConfigKey.FORM_MONITOR_TYPE] = formMonitorType;
}
switch (formMonitorType) {
case FormMonitorType.MULTISTEP:
const browserMonitor = monitor as BrowserFields;
return {
...monitorWithFormMonitorType,
'source.inline': {
type: browserMonitor[ConfigKey.METADATA]?.script_source?.is_generated_script
? 'recorder'
: 'inline',
script: browserMonitor[ConfigKey.SOURCE_INLINE],
fileName: browserMonitor[ConfigKey.METADATA]?.script_source?.file_name,
},
};
case FormMonitorType.SINGLE:
case FormMonitorType.ICMP:
return {
...monitorWithFormMonitorType,
};
case FormMonitorType.HTTP:
case FormMonitorType.TCP:
return {
...monitorWithFormMonitorType,
isTLSEnabled: isCustomTLSEnabled(monitor),
};
}
};
const isCustomTLSEnabled = (monitor: SyntheticsMonitor) => {
const sslKeys = Object.keys(monitor).filter((key) => key.includes('ssl')) as unknown as Array<
keyof TLSFields
>;
const sslValues: Record<string, unknown> = {};
sslKeys.map((key) => (sslValues[key] = (monitor as TLSFields)[key]));
return !isEqual(sslValues, DEFAULT_TLS_FIELDS);
};

View file

@ -0,0 +1,45 @@
/*
* 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 { render } from '../../../utils/testing/rtl_helpers';
import * as formContext from 'react-hook-form';
import { Disclaimer } from './disclaimer';
import { ServiceLocations } from '../types';
export const mockLocation = {
label: 'US Central',
id: 'us_central',
geo: {
lat: 1,
lon: 1,
},
url: 'url',
isServiceManaged: true,
};
describe('<Disclaimer />', () => {
beforeEach(() => {
jest.spyOn(formContext, 'useFormContext').mockReturnValue({
watch: () => [[mockLocation] as ServiceLocations],
} as unknown as formContext.UseFormReturn);
});
it('shows disclaimer when ', () => {
const { getByText } = render(<Disclaimer />);
expect(getByText(/You consent/)).toBeInTheDocument();
});
it('does not show disclaimer when locations are not service managed', () => {
jest.spyOn(formContext, 'useFormContext').mockReturnValue({
watch: () => [[{ ...mockLocation, isServiceManaged: false }] as ServiceLocations],
} as unknown as formContext.UseFormReturn);
const { queryByText } = render(<Disclaimer />);
expect(queryByText(/You consent/)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,33 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiText, EuiSpacer } from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { ConfigKey, MonitorServiceLocation } from '../types';
export const Disclaimer: React.FC = () => {
const { watch } = useFormContext();
const [locations]: [locations: MonitorServiceLocation[]] = watch([ConfigKey.LOCATIONS]);
const includesServiceLocation = locations.find((location) => location.isServiceManaged === true);
return includesServiceLocation ? (
<>
<EuiSpacer size="l" />
<EuiText size="xs" color="subdued">
<p>
{i18n.translate('xpack.synthetics.monitorConfig.locations.disclaimer', {
defaultMessage:
'You consent to the transfer of testing instructions and the output of such instructions (including any data shown therein) to your selected testing location, on infrastructure provided by a cloud service provider chosen by Elastic.',
})}
</p>
</EuiText>
</>
) : null;
};

View file

@ -0,0 +1,134 @@
/*
* 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, { memo, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Controller, useFormContext, FieldError, ControllerFieldState } from 'react-hook-form';
import { EuiFormRow } from '@elastic/eui';
import { selectServiceLocationsState } from '../../../state';
import { useKibanaSpace, useIsEditFlow } from '../hooks';
import { ControlledField } from './controlled_field';
import { FieldMeta } from '../types';
type Props = FieldMeta & { fieldError?: FieldError };
export const Field = memo<Props>(
({
component: Component,
helpText,
label,
ariaLabel,
props,
fieldKey,
controlled,
showWhen,
shouldUseSetValue,
required,
validation,
error,
fieldError,
dependencies,
customHook,
}: Props) => {
const { register, watch, control, setValue, reset, getFieldState, formState } =
useFormContext();
const { locations } = useSelector(selectServiceLocationsState);
const { space } = useKibanaSpace();
const isEdit = useIsEditFlow();
const [dependenciesFieldMeta, setDependenciesFieldMeta] = useState<
Record<string, ControllerFieldState>
>({});
let show = true;
let dependenciesValues: unknown[] = [];
if (showWhen) {
const [showKey, expectedValue] = showWhen;
const [actualValue] = watch([showKey]);
show = actualValue === expectedValue;
}
if (dependencies) {
dependenciesValues = watch(dependencies);
}
useEffect(() => {
if (dependencies) {
dependencies.forEach((dependency) => {
setDependenciesFieldMeta((prevState) => ({
...prevState,
[dependency]: getFieldState(dependency),
}));
});
}
// run effect when dependencies values change, to get the most up to date meta state
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(dependenciesValues || []), dependencies, getFieldState]);
if (!show) {
return null;
}
const formRowProps = {
label,
'aria-label': ariaLabel,
helpText,
fullWidth: true,
};
return controlled ? (
<Controller
control={control}
name={fieldKey}
rules={{
required,
...(validation ? validation(dependenciesValues) : {}),
}}
render={({ field, fieldState: fieldStateT }) => {
return (
<ControlledField
field={field}
component={Component}
props={props}
shouldUseSetValue={shouldUseSetValue}
fieldKey={fieldKey}
customHook={customHook}
formRowProps={formRowProps}
fieldState={fieldStateT}
error={error}
dependenciesValues={dependenciesValues}
dependenciesFieldMeta={dependenciesFieldMeta}
/>
);
}}
/>
) : (
<EuiFormRow
{...formRowProps}
isInvalid={Boolean(fieldError)}
error={fieldError?.message || error}
>
<Component
{...register(fieldKey, {
required,
...(validation ? validation(dependenciesValues) : {}),
})}
{...(props
? props({
field: undefined,
formState,
setValue,
reset,
locations,
dependencies: dependenciesValues,
dependenciesFieldMeta,
space: space?.id,
isEdit,
})
: {})}
isInvalid={Boolean(fieldError)}
fullWidth
/>
</EuiFormRow>
);
}
);

View file

@ -0,0 +1,980 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { UseFormReturn, ControllerRenderProps, FormState } from 'react-hook-form';
import {
EuiButtonGroup,
EuiCheckbox,
EuiCode,
EuiComboBox,
EuiComboBoxOptionOption,
EuiComboBoxProps,
EuiFlexGroup,
EuiFlexItem,
EuiFieldText,
EuiFieldNumber,
EuiFieldPassword,
EuiSelect,
EuiSuperSelect,
EuiSwitch,
EuiText,
EuiLink,
EuiTextArea,
} from '@elastic/eui';
import { useMonitorName } from '../hooks/use_monitor_name';
import { MonitorTypeRadioGroup } from '../fields/monitor_type_radio_group';
import {
ConfigKey,
DataStream,
FormMonitorType,
HTTPMethod,
MonitorFields,
MonitorServiceLocations,
ScreenshotOption,
ServiceLocations,
SyntheticsMonitor,
TLSVersion,
VerificationMode,
FieldMeta,
} from '../types';
import { DEFAULT_BROWSER_ADVANCED_FIELDS } from '../constants';
import { HeaderField } from '../fields/header_field';
import { RequestBodyField } from '../fields/request_body_field';
import { ResponseBodyIndexField } from '../fields/index_response_body_field';
import { ComboBox } from '../fields/combo_box';
import { SourceField } from '../fields/source_field';
import { getDefaultFormFields } from './defaults';
import { validate, validateHeaders, WHOLE_NUMBERS_ONLY, FLOATS_ONLY } from './validation';
const getScheduleContent = (value: number) => {
if (value > 60) {
return i18n.translate('xpack.synthetics.monitorConfig.schedule.label', {
defaultMessage: 'Every {value, number} {value, plural, one {hour} other {hours}}',
values: {
value: value / 60,
},
});
} else {
return i18n.translate('xpack.synthetics.monitorConfig.schedule.minutes.label', {
defaultMessage: 'Every {value, number} {value, plural, one {minute} other {minutes}}',
values: {
value,
},
});
}
};
const getScheduleConfig = (schedules: number[]) => {
return schedules.map((value) => ({
value: `${value}`,
text: getScheduleContent(value),
}));
};
const BROWSER_SCHEDULES = getScheduleConfig([3, 5, 10, 15, 30, 60, 120, 240]);
const LIGHTWEIGHT_SCHEDULES = getScheduleConfig([1, 3, 5, 10, 15, 30, 60]);
export const MONITOR_TYPE_CONFIG = {
[FormMonitorType.MULTISTEP]: {
id: 'syntheticsMonitorTypeMultistep',
'data-test-subj': 'syntheticsMonitorTypeMultistep',
label: i18n.translate('xpack.synthetics.monitorConfig.monitorType.multiStep.label', {
defaultMessage: 'Multistep',
}),
value: FormMonitorType.MULTISTEP,
descriptionTitle: i18n.translate('xpack.synthetics.monitorConfig.monitorType.multiStep.title', {
defaultMessage: 'Multistep Browser Journey',
}),
description: i18n.translate(
'xpack.synthetics.monitorConfig.monitorType.multiStep.description',
{
defaultMessage:
'Navigate through multiple steps or pages to test key user flows from a real browser.',
}
),
link: '#',
icon: 'videoPlayer',
beta: true,
},
[FormMonitorType.SINGLE]: {
id: 'syntheticsMonitorTypeSingle',
'data-test-subj': 'syntheticsMonitorTypeSingle',
label: i18n.translate('xpack.synthetics.monitorConfig.monitorType.singlePage.label', {
defaultMessage: 'Single Page',
}),
value: FormMonitorType.SINGLE,
descriptionTitle: i18n.translate(
'xpack.synthetics.monitorConfig.monitorType.singlePage.title',
{
defaultMessage: 'Single Page Browser Test',
}
),
description: i18n.translate(
'xpack.synthetics.monitorConfig.monitorType.singlePage.description',
{
defaultMessage:
'Test a single page load including all objects on the page from a real web browser.',
}
),
link: '#',
icon: 'videoPlayer',
beta: true,
},
[FormMonitorType.HTTP]: {
id: 'syntheticsMonitorTypeHTTP',
'data-test-subj': 'syntheticsMonitorTypeHTTP',
label: i18n.translate('xpack.synthetics.monitorConfig.monitorType.http.label', {
defaultMessage: 'HTTP Ping',
}),
value: FormMonitorType.HTTP,
descriptionTitle: i18n.translate('xpack.synthetics.monitorConfig.monitorType.http.title', {
defaultMessage: 'HTTP Ping',
}),
description: i18n.translate('xpack.synthetics.monitorConfig.monitorType.http.description', {
defaultMessage:
'A lightweight API check to validate the availability of a web service or endpoint.',
}),
link: '#',
icon: 'online',
beta: false,
},
[FormMonitorType.TCP]: {
id: 'syntheticsMonitorTypeTCP',
'data-test-subj': 'syntheticsMonitorTypeTCP',
label: i18n.translate('xpack.synthetics.monitorConfig.monitorType.tcp.label', {
defaultMessage: 'TCP Ping',
}),
value: FormMonitorType.TCP,
descriptionTitle: i18n.translate('xpack.synthetics.monitorConfig.monitorType.tcp.title', {
defaultMessage: 'TCP Ping',
}),
description: i18n.translate('xpack.synthetics.monitorConfig.monitorType.tcp.description', {
defaultMessage:
'A lightweight API check to validate the availability of a web service or endpoint.',
}),
link: '#',
icon: 'online',
beta: false,
},
[FormMonitorType.ICMP]: {
id: 'syntheticsMonitorTypeICMP',
'data-test-subj': 'syntheticsMonitorTypeICMP',
label: i18n.translate('xpack.synthetics.monitorConfig.monitorType.icmp.label', {
defaultMessage: 'ICMP Ping',
}),
value: FormMonitorType.ICMP,
descriptionTitle: i18n.translate('xpack.synthetics.monitorConfig.monitorType.icmp.title', {
defaultMessage: 'ICMP Ping',
}),
description: i18n.translate('xpack.synthetics.monitorConfig.monitorType.icmp.description', {
defaultMessage:
'A lightweight API check to validate the availability of a web service or endpoint.',
}),
link: '#',
icon: 'online',
beta: false,
},
};
export const FIELD: Record<string, FieldMeta> = {
[ConfigKey.FORM_MONITOR_TYPE]: {
fieldKey: ConfigKey.FORM_MONITOR_TYPE,
required: true,
component: MonitorTypeRadioGroup,
ariaLabel: i18n.translate('xpack.synthetics.monitorConfig.monitorType.label', {
defaultMessage: 'Monitor type',
}),
controlled: true,
props: ({ field, reset, space }) => ({
onChange: (_: string, monitorType: FormMonitorType) => {
const defaultFields = getDefaultFormFields(space)[monitorType];
reset(defaultFields);
},
selectedOption: field?.value,
options: Object.values(MONITOR_TYPE_CONFIG),
}),
validation: () => ({
required: true,
}),
},
[`${ConfigKey.URLS}__single`]: {
fieldKey: ConfigKey.URLS,
required: true,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.urlsSingle.label', {
defaultMessage: 'Website URL',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.urlsSingle.helpText', {
defaultMessage: 'For example, https://www.elastic.co.',
}),
controlled: true,
dependencies: [ConfigKey.NAME],
props: ({ setValue, dependenciesFieldMeta, isEdit, formState }) => {
return {
'data-test-subj': 'syntheticsMonitorConfigURL',
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(ConfigKey.URLS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) {
setValue(ConfigKey.NAME, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
}
},
};
},
},
[`${ConfigKey.URLS}__http`]: {
fieldKey: ConfigKey.URLS,
required: true,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.urls.label', {
defaultMessage: 'URL',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.urls.helpText', {
defaultMessage: 'For example, your service endpoint.',
}),
controlled: true,
dependencies: [ConfigKey.NAME],
props: ({ setValue, dependenciesFieldMeta, isEdit, formState }) => {
return {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(ConfigKey.URLS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) {
setValue(ConfigKey.NAME, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
}
},
'data-test-subj': 'syntheticsMonitorConfigURL',
};
},
},
[`${ConfigKey.HOSTS}__tcp`]: {
fieldKey: ConfigKey.HOSTS,
required: true,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.hostsTCP.label', {
defaultMessage: 'Host:Port',
}),
controlled: true,
dependencies: [ConfigKey.NAME],
props: ({ setValue, dependenciesFieldMeta, isEdit, formState }) => {
return {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(ConfigKey.HOSTS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) {
setValue(ConfigKey.NAME, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
}
},
'data-test-subj': 'syntheticsMonitorConfigHost',
};
},
},
[`${ConfigKey.HOSTS}__icmp`]: {
fieldKey: ConfigKey.HOSTS,
required: true,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.hostsICMP.label', {
defaultMessage: 'Host',
}),
controlled: true,
dependencies: [ConfigKey.NAME],
props: ({ setValue, dependenciesFieldMeta, isEdit, formState }) => {
return {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(ConfigKey.HOSTS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) {
setValue(ConfigKey.NAME, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
}
},
'data-test-subj': 'syntheticsMonitorConfigHost',
};
},
},
[ConfigKey.NAME]: {
fieldKey: ConfigKey.NAME,
required: true,
component: EuiFieldText,
controlled: true,
label: i18n.translate('xpack.synthetics.monitorConfig.name.label', {
defaultMessage: 'Monitor name',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.name.helpText', {
defaultMessage: 'Choose a name to help identify this monitor in the future.',
}),
dependencies: [ConfigKey.URLS, ConfigKey.HOSTS],
customHook: (value: unknown) => ({
fieldKey: 'nameAlreadyExists',
func: useMonitorName,
params: { search: value as string },
error: i18n.translate('xpack.synthetics.monitorConfig.name.existsError', {
defaultMessage: 'Monitor name already exists',
}),
}),
validation: () => ({
validate: {
notEmpty: (value) => Boolean(value.trim()),
},
}),
error: i18n.translate('xpack.synthetics.monitorConfig.name.error', {
defaultMessage: 'Monitor name is required',
}),
props: () => ({
'data-test-subj': 'syntheticsMonitorConfigName',
}),
},
[ConfigKey.SCHEDULE]: {
fieldKey: `${ConfigKey.SCHEDULE}.number`,
required: true,
component: EuiSelect,
label: i18n.translate('xpack.synthetics.monitorConfig.frequency.label', {
defaultMessage: 'Frequency',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.frequency.helpText', {
defaultMessage:
'How often do you want to run this test? Higher frequencies will increase your total cost.',
}),
dependencies: [ConfigKey.MONITOR_TYPE],
props: ({ dependencies }) => {
const [monitorType] = dependencies;
return {
'data-test-subj': 'syntheticsMonitorConfigSchedule',
options: monitorType === DataStream.BROWSER ? BROWSER_SCHEDULES : LIGHTWEIGHT_SCHEDULES,
};
},
},
[ConfigKey.LOCATIONS]: {
fieldKey: ConfigKey.LOCATIONS,
required: true,
controlled: true,
component: EuiComboBox as React.ComponentType<EuiComboBoxProps<string>>,
label: i18n.translate('xpack.synthetics.monitorConfig.locations.label', {
defaultMessage: 'Locations',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.locations.helpText', {
defaultMessage:
'Where do you want to run this test from? Additional locations will increase your total cost.',
}),
props: ({
field,
setValue,
locations,
formState,
}: {
field?: ControllerRenderProps;
setValue: UseFormReturn['setValue'];
locations: ServiceLocations;
formState: FormState<SyntheticsMonitor>;
}) => {
return {
options: Object.values(locations).map((location) => ({
label: locations?.find((loc) => location.id === loc.id)?.label,
id: location.id,
isServiceManaged: location.isServiceManaged,
})),
selectedOptions: Object.values(field?.value as ServiceLocations).map((location) => ({
label: locations?.find((loc) => location.id === loc.id)?.label,
id: location.id,
isServiceManaged: location.isServiceManaged,
})),
'data-test-subj': 'syntheticsMonitorConfigLocations',
onChange: (updatedValues: ServiceLocations) => {
setValue(
ConfigKey.LOCATIONS,
updatedValues.map((location) => ({
id: location.id,
isServiceManaged: location.isServiceManaged,
})) as MonitorServiceLocations,
{ shouldValidate: Boolean(formState.submitCount > 0) }
);
},
};
},
},
[ConfigKey.TAGS]: {
fieldKey: ConfigKey.TAGS,
component: ComboBox,
label: i18n.translate('xpack.synthetics.monitorConfig.tags.label', {
defaultMessage: 'Tags',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.tags.helpText', {
defaultMessage:
'A list of tags that will be sent with each monitor event. Useful for searching and segmenting data.',
}),
controlled: true,
props: ({ field }) => ({
selectedOptions: field?.value,
}),
},
[ConfigKey.TIMEOUT]: {
fieldKey: ConfigKey.TIMEOUT,
component: EuiFieldNumber,
label: i18n.translate('xpack.synthetics.monitorConfig.timeout.label', {
defaultMessage: 'Timeout in seconds',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.timeout.helpText', {
defaultMessage: 'The total time allowed for testing the connection and exchanging data.',
}),
props: () => ({
min: 1,
step: 'any',
}),
dependencies: [ConfigKey.SCHEDULE],
validation: ([schedule]) => {
return {
validate: (value) => {
switch (true) {
case value < 0:
return i18n.translate('xpack.synthetics.monitorConfig.timeout.greaterThan0Error', {
defaultMessage: 'Timeout must be greater than or equal to 0.',
});
case value > parseFloat((schedule as MonitorFields[ConfigKey.SCHEDULE]).number) * 60:
return i18n.translate('xpack.synthetics.monitorConfig.timeout.scheduleError', {
defaultMessage: 'Timemout must be less than the monitor frequency.',
});
case !Boolean(`${value}`.match(FLOATS_ONLY)):
return i18n.translate('xpack.synthetics.monitorConfig.timeout.formatError', {
defaultMessage: 'Timeout is invalid.',
});
default:
return true;
}
},
};
},
},
[ConfigKey.APM_SERVICE_NAME]: {
fieldKey: ConfigKey.APM_SERVICE_NAME,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.apmServiceName.label', {
defaultMessage: 'APM service name',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.apmServiceName.helpText', {
defaultMessage:
'Corrseponds to the service.name ECS field from APM. Set this to enable integrations between APM and Synthetics data.',
}),
controlled: true,
props: ({ field }) => ({
selectedOptions: field?.value,
'data-test-subj': 'syntheticsMonitorConfigAPMServiceName',
}),
},
[ConfigKey.NAMESPACE]: {
fieldKey: ConfigKey.NAMESPACE,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.namespace.label', {
defaultMessage: 'Data stream namespace',
}),
helpText: (
<span>
{i18n.translate('xpack.synthetics.monitorConfig.namespace.helpText', {
defaultMessage:
"Change the default namespace. This setting changes the name of the monitor's data stream. ",
})}
<EuiLink href="#" target="_blank">
{i18n.translate('xpack.synthetics.monitorConfig.namespace.learnMore', {
defaultMessage: 'Learn more',
})}
</EuiLink>
</span>
),
controlled: true,
props: ({ field }) => ({
selectedOptions: field,
}),
},
[ConfigKey.MAX_REDIRECTS]: {
fieldKey: ConfigKey.MAX_REDIRECTS,
component: EuiFieldNumber,
label: i18n.translate('xpack.synthetics.monitorConfig.maxRedirects.label', {
defaultMessage: 'Max redirects',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.maxRedirects.helpText', {
defaultMessage: 'The total number of redirects to follow.',
}),
props: () => ({
min: 0,
max: 10,
step: 1,
}),
validation: () => ({
min: 0,
pattern: WHOLE_NUMBERS_ONLY,
}),
error: i18n.translate('xpack.synthetics.monitorConfig.maxRedirects.error', {
defaultMessage: 'Max redirects is invalid.',
}),
},
[ConfigKey.WAIT]: {
fieldKey: ConfigKey.WAIT,
component: EuiFieldNumber,
label: i18n.translate('xpack.synthetics.monitorConfig.wait.label', {
defaultMessage: 'Wait',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.wait.helpText', {
defaultMessage:
'The duration to wait before emitting another ICMP Echo Request if no response is received.',
}),
props: () => ({
min: 1,
step: 1,
}),
validation: () => ({
min: 1,
pattern: WHOLE_NUMBERS_ONLY,
}),
error: i18n.translate('xpack.synthetics.monitorConfig.wait.error', {
defaultMessage: 'Wait duration is invalid.',
}),
},
[ConfigKey.USERNAME]: {
fieldKey: ConfigKey.USERNAME,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.username.label', {
defaultMessage: 'Username',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.username.helpText', {
defaultMessage: 'Username for authenticating with the server.',
}),
},
[ConfigKey.PASSWORD]: {
fieldKey: ConfigKey.PASSWORD,
component: EuiFieldPassword,
label: i18n.translate('xpack.synthetics.monitorConfig.password.label', {
defaultMessage: 'Password',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.password.helpText', {
defaultMessage: 'Password for authenticating with the server.',
}),
},
[ConfigKey.PROXY_URL]: {
fieldKey: ConfigKey.PROXY_URL,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.proxyUrl.label', {
defaultMessage: 'Proxy URL',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.proxyUrl.helpText', {
defaultMessage: 'HTTP proxy URL',
}),
},
[ConfigKey.REQUEST_METHOD_CHECK]: {
fieldKey: ConfigKey.REQUEST_METHOD_CHECK,
component: EuiSelect,
label: i18n.translate('xpack.synthetics.monitorConfig.requestMethod.label', {
defaultMessage: 'Request method',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.requestMethod.helpText', {
defaultMessage: 'The HTTP method to use.',
}),
props: () => ({
options: Object.keys(HTTPMethod).map((method) => ({
value: method,
text: method,
})),
}),
},
[ConfigKey.REQUEST_HEADERS_CHECK]: {
fieldKey: ConfigKey.REQUEST_HEADERS_CHECK,
component: HeaderField,
label: i18n.translate('xpack.synthetics.monitorConfig.requestHeaders.label', {
defaultMessage: 'Request headers',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.requestHeaders.helpText', {
defaultMessage:
'A dictionary of additional HTTP headers to send. By default the client will set the User-Agent header to identify itself.',
}),
controlled: true,
validation: () => ({
validate: (headers) => !validateHeaders(headers),
}),
error: i18n.translate('xpack.synthetics.monitorConfig.requestHeaders.error', {
defaultMessage: 'Header key must be a valid HTTP token.',
}),
},
[ConfigKey.REQUEST_BODY_CHECK]: {
fieldKey: ConfigKey.REQUEST_BODY_CHECK,
component: RequestBodyField,
label: i18n.translate('xpack.synthetics.monitorConfig.requestBody.label', {
defaultMessage: 'Request body',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.requestBody.helpText', {
defaultMessage: 'Request body content.',
}),
controlled: true,
},
[ConfigKey.RESPONSE_HEADERS_INDEX]: {
fieldKey: ConfigKey.RESPONSE_HEADERS_INDEX,
component: EuiCheckbox,
helpText: (
<>
<FormattedMessage
id="xpack.synthetics.monitorConfig.indexResponseHeaders.helpText"
defaultMessage="Controls the indexing of the HTTP response headers to "
/>
<EuiCode>http.response.body.headers</EuiCode>
</>
),
props: () => ({
label: i18n.translate('xpack.synthetics.monitorConfig.indexResponseHeaders.label', {
defaultMessage: 'Index response headers',
}),
id: 'syntheticsMonitorConfigResponseHeadersIndex', // checkbox needs an id or it won't work
}),
controlled: true,
},
[ConfigKey.RESPONSE_BODY_INDEX]: {
fieldKey: ConfigKey.RESPONSE_BODY_INDEX,
component: ResponseBodyIndexField,
helpText: (
<>
<FormattedMessage
id="xpack.synthetics.monitorConfig.indexResponseBody.helpText"
defaultMessage="Controls the indexing of the HTTP response body contents to"
/>
<EuiCode>http.response.body.contents</EuiCode>
</>
),
props: () => ({
label: i18n.translate('xpack.synthetics.monitorConfig.indexResponseBody.label', {
defaultMessage: 'Index response body',
}),
}),
controlled: true,
},
[ConfigKey.RESPONSE_STATUS_CHECK]: {
fieldKey: ConfigKey.RESPONSE_STATUS_CHECK,
component: ComboBox,
label: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.label', {
defaultMessage: 'Check response status equals',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.helpText', {
defaultMessage:
'A list of expected status codes. Press enter to add a new code. 4xx and 5xx codes are considered down by default. Other codes are considered up.',
}),
controlled: true,
props: ({ field }) => ({
selectedOptions: field?.value,
}),
validation: () => ({
validate: (value) => {
const validateFn = validate[DataStream.HTTP][ConfigKey.RESPONSE_STATUS_CHECK];
if (validateFn) {
return !validateFn({
[ConfigKey.RESPONSE_STATUS_CHECK]: value,
});
}
},
}),
error: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.error', {
defaultMessage: 'Status code must contain digits only.',
}),
},
[ConfigKey.RESPONSE_HEADERS_CHECK]: {
fieldKey: ConfigKey.RESPONSE_HEADERS_CHECK,
component: HeaderField,
label: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.label', {
defaultMessage: 'Check response headers contain',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.helpText', {
defaultMessage: 'A list of expected response headers.',
}),
controlled: true,
validation: () => ({
validate: (headers) => !validateHeaders(headers),
}),
error: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.error', {
defaultMessage: 'Header key must be a valid HTTP token.',
}),
},
[ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: {
fieldKey: ConfigKey.RESPONSE_BODY_CHECK_POSITIVE,
component: ComboBox,
label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheck.label', {
defaultMessage: 'Check response body contains',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheck.helpText', {
defaultMessage:
'A list of regular expressions to match the body output. Press enter to add a new expression. Only a single expression needs to match.',
}),
controlled: true,
props: ({ field }) => ({
selectedOptions: field?.value,
}),
},
[ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: {
fieldKey: ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE,
component: ComboBox,
label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheckNegative.label', {
defaultMessage: 'Check response body does not contain',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheckNegative.helpText', {
defaultMessage:
'A list of regular expressions to match the the body output negatively. Press enter to add a new expression. Return match failed if single expression matches.',
}),
controlled: true,
props: ({ field }) => ({
selectedOptions: field?.value,
}),
},
[ConfigKey.RESPONSE_RECEIVE_CHECK]: {
fieldKey: ConfigKey.RESPONSE_RECEIVE_CHECK,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.responseReceiveCheck.label', {
defaultMessage: 'Check response contains',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.responseReceiveCheck.helpText', {
defaultMessage: 'The expected remote host response.',
}),
},
[`${ConfigKey.PROXY_URL}__tcp`]: {
fieldKey: ConfigKey.PROXY_URL,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.proxyURLTCP.label', {
defaultMessage: 'Proxy URL',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.proxyURLTCP.helpText', {
defaultMessage:
'The URL of the SOCKS5 proxy to use when connecting to the server. The value must be a URL with a scheme of socks5://.',
}),
},
[ConfigKey.REQUEST_SEND_CHECK]: {
fieldKey: ConfigKey.REQUEST_SEND_CHECK,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.requestSendCheck.label', {
defaultMessage: 'Request payload',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.requestSendCheck.helpText', {
defaultMessage: 'A payload string to send to the remote host.',
}),
},
[ConfigKey.SOURCE_INLINE]: {
fieldKey: 'source.inline',
required: true,
component: SourceField,
ariaLabel: i18n.translate('xpack.synthetics.monitorConfig.monitorScript.label', {
defaultMessage: 'Monitor script',
}),
controlled: true,
props: ({ isEdit }) => ({
isEditFlow: isEdit,
}),
validation: () => ({
validate: (value) => Boolean(value.script),
}),
error: i18n.translate('xpack.synthetics.monitorConfig.monitorScript.error', {
defaultMessage: 'Monitor script is required',
}),
},
isTLSEnabled: {
fieldKey: 'isTLSEnabled',
component: EuiSwitch,
controlled: true,
props: ({ setValue }) => {
return {
id: 'syntheticsMontiorConfigIsTLSEnabledSwitch',
label: i18n.translate('xpack.synthetics.monitorConfig.customTLS.label', {
defaultMessage: 'Use custom TLS configuration',
}),
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setValue('isTLSEnabled', event.target.checked);
},
};
},
},
[ConfigKey.TLS_VERIFICATION_MODE]: {
fieldKey: ConfigKey.TLS_VERIFICATION_MODE,
component: EuiSelect,
label: i18n.translate('xpack.synthetics.monitorConfig.verificationMode.label', {
defaultMessage: 'Verification mode',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.verificationMode.helpText', {
defaultMessage:
'Verifies that the provided certificate is signed by a trusted authority (CA) and also verifies that the servers hostname (or IP address) matches the names identified within the certificate. If the Subject Alternative Name is empty, it returns an error.',
}),
showWhen: ['isTLSEnabled', true],
props: () => ({
options: Object.values(VerificationMode).map((method) => ({
value: method,
text: method.toUpperCase(),
})),
}),
},
[ConfigKey.TLS_VERSION]: {
fieldKey: ConfigKey.TLS_VERSION,
component: EuiComboBox as React.ComponentType<EuiComboBoxProps<string>>,
label: i18n.translate('xpack.synthetics.monitorConfig.tlsVersion.label', {
defaultMessage: 'Supported TLS protocols',
}),
controlled: true,
showWhen: ['isTLSEnabled', true],
props: ({
field,
setValue,
}: {
field?: ControllerRenderProps;
setValue: UseFormReturn['setValue'];
}) => {
return {
options: Object.values(TLSVersion).map((version) => ({
label: version,
})),
selectedOptions: Object.values(field?.value).map((version) => ({
label: version,
})),
onChange: (updatedValues: Array<EuiComboBoxOptionOption<TLSVersion>>) => {
setValue(
ConfigKey.TLS_VERSION,
updatedValues.map((option) => option.label as TLSVersion)
);
},
};
},
},
[ConfigKey.TLS_CERTIFICATE_AUTHORITIES]: {
fieldKey: ConfigKey.TLS_CERTIFICATE_AUTHORITIES,
component: EuiTextArea,
label: i18n.translate('xpack.synthetics.monitorConfig.certificateAuthorities.label', {
defaultMessage: 'Certificate authorities',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.certificateAuthorities.helpText', {
defaultMessage: 'PEM-formatted custom certificate authorities.',
}),
showWhen: ['isTLSEnabled', true],
},
[ConfigKey.TLS_CERTIFICATE]: {
fieldKey: ConfigKey.TLS_CERTIFICATE,
component: EuiTextArea,
label: i18n.translate('xpack.synthetics.monitorConfig.clientCertificate.label', {
defaultMessage: 'Client certificate',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.clientCertificate.helpText', {
defaultMessage: 'PEM-formatted certificate for TLS client authentication.',
}),
showWhen: ['isTLSEnabled', true],
},
[ConfigKey.TLS_KEY]: {
fieldKey: ConfigKey.TLS_KEY,
component: EuiTextArea,
label: i18n.translate('xpack.synthetics.monitorConfig.clientKey.label', {
defaultMessage: 'Client key',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.clientKey.helpText', {
defaultMessage: 'PEM-formatted certificate key for TLS client authentication.',
}),
showWhen: ['isTLSEnabled', true],
},
[ConfigKey.TLS_KEY_PASSPHRASE]: {
fieldKey: ConfigKey.TLS_KEY_PASSPHRASE,
component: EuiFieldPassword,
label: i18n.translate('xpack.synthetics.monitorConfig.clientKeyPassphrase.label', {
defaultMessage: 'Client key passphrase',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.clientKeyPassphrase.helpText', {
defaultMessage: 'Certificate key passphrase for TLS client authentication.',
}),
showWhen: ['isTLSEnabled', true],
},
[ConfigKey.SCREENSHOTS]: {
fieldKey: ConfigKey.SCREENSHOTS,
component: EuiButtonGroup,
label: i18n.translate('xpack.synthetics.monitorConfig.screenshotOptions.label', {
defaultMessage: 'Screenshot options',
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.screenshotOptions.helpText', {
defaultMessage: 'Set this option to manage the screenshots captured by the synthetics agent.',
}),
controlled: true,
props: ({
field,
setValue,
}: {
field?: ControllerRenderProps;
setValue: UseFormReturn['setValue'];
}) => ({
type: 'single',
idSelected: field?.value,
onChange: (option: ScreenshotOption) => setValue(ConfigKey.SCREENSHOTS, option),
options: Object.values(ScreenshotOption).map((option) => ({
id: option,
label: option.replace(/-/g, ' '),
})),
css: {
'text-transform': 'capitalize',
},
}),
},
[ConfigKey.TEXT_ASSERTION]: {
fieldKey: ConfigKey.TEXT_ASSERTION,
component: EuiFieldText,
label: i18n.translate('xpack.synthetics.monitorConfig.textAssertion.label', {
defaultMessage: 'Text assertion',
}),
required: true,
helpText: i18n.translate('xpack.synthetics.monitorConfig.textAssertion.helpText', {
defaultMessage: 'Consider the page loaded when the specified text is rendered.',
}),
validation: () => ({
required: true,
}),
},
[ConfigKey.THROTTLING_CONFIG]: {
fieldKey: ConfigKey.THROTTLING_CONFIG,
component: EuiSuperSelect,
label: i18n.translate('xpack.synthetics.monitorConfig.throttling.label', {
defaultMessage: 'Connection profile',
}),
required: true,
controlled: true,
helpText: i18n.translate('xpack.synthetics.monitorConfig.throttling.helpText', {
defaultMessage:
'Simulate network throttling (download, upload, latency). More options will be added in a future version.',
}),
props: () => ({
options: [
{
value: DEFAULT_BROWSER_ADVANCED_FIELDS[ConfigKey.THROTTLING_CONFIG],
inputDisplay: (
<EuiFlexGroup alignItems="baseline" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiText>
{i18n.translate('xpack.synthetics.monitorConfig.throttling.options.default', {
defaultMessage: 'Default',
})}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{'(5 Mbps, 3 Mbps, 20 ms)'}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
),
},
],
disabled: true, // currently disabled through 1.0 until we define connection profiles
}),
validation: () => ({
required: true,
}),
},
};

View file

@ -0,0 +1,233 @@
/*
* 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 { ConfigKey, FormMonitorType, FieldMeta } from '../types';
import { FIELD } from './field_config';
const DEFAULT_DATA_OPTIONS = {
title: i18n.translate('xpack.synthetics.monitorConfig.section.dataOptions.title', {
defaultMessage: 'Data options',
}),
description: i18n.translate('xpack.synthetics.monitorConfig.section.dataOptions.description', {
defaultMessage: 'Configure data options to add context to the data coming from your monitors.',
}),
components: [
FIELD[ConfigKey.TAGS],
FIELD[ConfigKey.APM_SERVICE_NAME],
FIELD[ConfigKey.NAMESPACE],
],
};
const HTTP_ADVANCED = {
requestConfig: {
title: i18n.translate('xpack.synthetics.monitorConfig.section.requestConfiguration.title', {
defaultMessage: 'Request configuration',
}),
description: i18n.translate(
'xpack.synthetics.monitorConfig.section.requestConfiguration.description',
{
defaultMessage:
'Configure an optional request to send to the remote host including method, body, and headers.',
}
),
components: [
FIELD[ConfigKey.USERNAME],
FIELD[ConfigKey.PASSWORD],
FIELD[ConfigKey.PROXY_URL],
FIELD[ConfigKey.REQUEST_METHOD_CHECK],
FIELD[ConfigKey.REQUEST_HEADERS_CHECK],
FIELD[ConfigKey.REQUEST_BODY_CHECK],
],
},
responseConfig: {
title: i18n.translate('xpack.synthetics.monitorConfig.section.responseConfiguration.title', {
defaultMessage: 'Response configuration',
}),
description: i18n.translate(
'xpack.synthetics.monitorConfig.section.responseConfiguration.description',
{
defaultMessage: 'Control the indexing of the HTTP response contents.',
}
),
components: [FIELD[ConfigKey.RESPONSE_HEADERS_INDEX], FIELD[ConfigKey.RESPONSE_BODY_INDEX]],
},
responseChecks: {
title: i18n.translate('xpack.synthetics.monitorConfig.section.responseChecks.title', {
defaultMessage: 'Response checks',
}),
description: i18n.translate(
'xpack.synthetics.monitorConfig.section.responseChecks.description',
{
defaultMessage: 'Configure the expected HTTP response.',
}
),
components: [
FIELD[ConfigKey.RESPONSE_STATUS_CHECK],
FIELD[ConfigKey.RESPONSE_HEADERS_CHECK],
FIELD[ConfigKey.RESPONSE_BODY_CHECK_POSITIVE],
FIELD[ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE],
],
},
};
export const TCP_ADVANCED = {
requestConfig: {
title: i18n.translate('xpack.synthetics.monitorConfig.section.requestConfigTCP.title', {
defaultMessage: 'Request configuration',
}),
description: i18n.translate(
'xpack.synthetics.monitorConfig.section.requestConfigTCP.description',
{
defaultMessage: 'Configure the payload sent to the remote host.',
}
),
components: [FIELD[`${ConfigKey.PROXY_URL}__tcp`], FIELD[ConfigKey.REQUEST_SEND_CHECK]],
},
responseChecks: {
title: i18n.translate('xpack.synthetics.monitorConfig.section.responseChecksTCP.title', {
defaultMessage: 'Response checks',
}),
description: i18n.translate(
'xpack.synthetics.monitorConfig.section.responseChecksTCP.description',
{
defaultMessage: 'Configure the expected response from the remote host.',
}
),
components: [FIELD[ConfigKey.RESPONSE_RECEIVE_CHECK]],
},
};
interface AdvancedFieldGroup {
title: string;
description: string;
components: FieldMeta[];
}
type FieldConfig = Record<
FormMonitorType,
{
step1: FieldMeta[];
step2: FieldMeta[];
step3?: FieldMeta[];
scriptEdit?: FieldMeta[];
advanced?: AdvancedFieldGroup[];
}
>;
const TLS_OPTIONS = {
title: i18n.translate('xpack.synthetics.monitorConfig.section.tlsOptions.title', {
defaultMessage: 'TLS options',
}),
description: i18n.translate('xpack.synthetics.monitorConfig.section.tlsOptions.description', {
defaultMessage:
'Configure TLS options, including verification mode, certificate authorities, and client certificates.',
}),
components: [
FIELD.isTLSEnabled,
FIELD[ConfigKey.TLS_VERIFICATION_MODE],
FIELD[ConfigKey.TLS_VERSION],
FIELD[ConfigKey.TLS_CERTIFICATE_AUTHORITIES],
FIELD[ConfigKey.TLS_CERTIFICATE],
FIELD[ConfigKey.TLS_KEY],
FIELD[ConfigKey.TLS_KEY_PASSPHRASE],
],
};
export const FORM_CONFIG: FieldConfig = {
[FormMonitorType.HTTP]: {
step1: [FIELD[ConfigKey.FORM_MONITOR_TYPE]],
step2: [
FIELD[`${ConfigKey.URLS}__http`],
FIELD[ConfigKey.NAME],
FIELD[ConfigKey.LOCATIONS],
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.MAX_REDIRECTS],
FIELD[ConfigKey.TIMEOUT],
],
advanced: [
DEFAULT_DATA_OPTIONS,
HTTP_ADVANCED.requestConfig,
HTTP_ADVANCED.responseConfig,
HTTP_ADVANCED.responseChecks,
TLS_OPTIONS,
],
},
[FormMonitorType.TCP]: {
step1: [FIELD[ConfigKey.FORM_MONITOR_TYPE]],
step2: [
FIELD[`${ConfigKey.HOSTS}__tcp`],
FIELD[ConfigKey.NAME],
FIELD[ConfigKey.LOCATIONS],
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.TIMEOUT],
],
advanced: [
DEFAULT_DATA_OPTIONS,
TCP_ADVANCED.requestConfig,
TCP_ADVANCED.responseChecks,
TLS_OPTIONS,
],
},
[FormMonitorType.MULTISTEP]: {
step1: [FIELD[ConfigKey.FORM_MONITOR_TYPE]],
step2: [
FIELD[ConfigKey.NAME],
FIELD[ConfigKey.LOCATIONS],
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.THROTTLING_CONFIG],
],
step3: [FIELD[ConfigKey.SOURCE_INLINE]],
scriptEdit: [FIELD[ConfigKey.SOURCE_INLINE]],
advanced: [
{
...DEFAULT_DATA_OPTIONS,
components: [
FIELD[ConfigKey.TAGS],
FIELD[ConfigKey.APM_SERVICE_NAME],
FIELD[ConfigKey.SCREENSHOTS],
FIELD[ConfigKey.NAMESPACE],
],
},
],
},
[FormMonitorType.SINGLE]: {
step1: [FIELD[ConfigKey.FORM_MONITOR_TYPE]],
step2: [
FIELD[`${ConfigKey.URLS}__single`],
FIELD[ConfigKey.NAME],
FIELD[ConfigKey.TEXT_ASSERTION],
FIELD[ConfigKey.LOCATIONS],
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.THROTTLING_CONFIG],
],
advanced: [
{
...DEFAULT_DATA_OPTIONS,
components: [
FIELD[ConfigKey.TAGS],
FIELD[ConfigKey.APM_SERVICE_NAME],
FIELD[ConfigKey.SCREENSHOTS],
FIELD[ConfigKey.NAMESPACE],
],
},
],
},
[FormMonitorType.ICMP]: {
step1: [FIELD[ConfigKey.FORM_MONITOR_TYPE]],
step2: [
FIELD[`${ConfigKey.HOSTS}__icmp`],
FIELD[ConfigKey.NAME],
FIELD[ConfigKey.LOCATIONS],
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.WAIT],
FIELD[ConfigKey.TIMEOUT],
],
advanced: [DEFAULT_DATA_OPTIONS],
},
};

View file

@ -0,0 +1,347 @@
/*
* 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 { format } from './formatter';
describe('format', () => {
const formValues = {
type: 'http',
form_monitor_type: 'http',
enabled: true,
schedule: {
number: '3',
unit: 'm',
},
'service.name': '',
tags: [],
timeout: '16',
name: 'Sample name',
locations: [
{
id: 'us_central',
isServiceManaged: true,
},
],
namespace: 'default',
origin: 'ui',
__ui: {
is_tls_enabled: false,
},
urls: 'sample url',
max_redirects: '0',
password: '',
proxy_url: '',
'check.response.body.negative': [],
'check.response.body.positive': [],
'response.include_body': 'on_error',
'check.response.headers': {},
'response.include_headers': true,
'check.response.status': [],
'check.request.body': {
value: '',
type: 'text',
},
'check.request.headers': {},
'check.request.method': 'GET',
username: '',
'ssl.certificate_authorities': '',
'ssl.certificate': '',
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.verification_mode': 'full',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
isTLSEnabled: false,
service: {
name: '',
},
check: {
request: {
method: 'GET',
headers: {},
body: {
type: 'text',
value: '',
},
},
response: {
status: [],
headers: {},
body: {
positive: [],
negative: [],
},
},
},
response: {
include_headers: true,
include_body: 'on_error',
},
};
it('correctly formats form fields to monitor type', () => {
expect(format(formValues)).toEqual({
__ui: {
is_tls_enabled: false,
},
config_id: '',
'check.request.body': {
type: 'text',
value: '',
},
'check.request.headers': {},
'check.request.method': 'GET',
'check.response.body.negative': [],
'check.response.body.positive': [],
'check.response.headers': {},
'check.response.status': [],
enabled: true,
form_monitor_type: 'http',
locations: [
{
id: 'us_central',
isServiceManaged: true,
},
],
max_redirects: '0',
name: 'Sample name',
namespace: 'default',
origin: 'ui',
password: '',
proxy_url: '',
'response.include_body': 'on_error',
'response.include_headers': true,
schedule: {
number: '3',
unit: 'm',
},
'service.name': '',
'ssl.certificate': '',
'ssl.certificate_authorities': '',
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
'ssl.verification_mode': 'full',
tags: [],
timeout: '16',
type: 'http',
urls: 'sample url',
username: '',
});
});
it.each([
['recorder', true, 'testScriptRecorder', 'fileName'],
['inline', false, 'testScript', ''],
])(
'correctly formats form fields to monitor type',
(scriptType, isGeneratedScript, script, fileName) => {
const browserFormFields = {
type: 'browser',
form_monitor_type: 'multistep',
config_id: '',
enabled: true,
schedule: {
unit: 'm',
number: '10',
},
'service.name': '',
tags: [],
timeout: '16',
name: 'Browser monitor',
locations: [
{
id: 'us_central',
isServiceManaged: true,
},
],
namespace: 'default',
origin: 'ui',
journey_id: '',
project_id: '',
playwright_options: '',
__ui: {
script_source: {
is_generated_script: false,
file_name: '',
},
is_zip_url_tls_enabled: false,
},
params: '',
'source.inline.script': '',
'source.project.content': '',
'source.zip_url.url': '',
'source.zip_url.username': '',
'source.zip_url.password': '',
'source.zip_url.folder': '',
'source.zip_url.proxy_url': '',
playwright_text_assertion: '',
urls: '',
screenshots: 'on',
synthetics_args: [],
'filter_journeys.match': '',
'filter_journeys.tags': [],
ignore_https_errors: false,
'throttling.is_enabled': true,
'throttling.download_speed': '5',
'throttling.upload_speed': '3',
'throttling.latency': '20',
'throttling.config': '5d/3u/20l',
'ssl.certificate_authorities': '',
'ssl.certificate': '',
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.verification_mode': 'full',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
'source.inline': {
type: 'recorder',
script: '',
fileName: '',
},
throttling: {
config: '5d/3u/20l',
},
source: {
inline: {
type: scriptType,
script,
fileName,
},
},
service: {
name: '',
},
};
expect(format(browserFormFields)).toEqual({
__ui: {
script_source: {
file_name: fileName,
is_generated_script: isGeneratedScript,
},
},
config_id: '',
enabled: true,
'filter_journeys.match': '',
'filter_journeys.tags': [],
form_monitor_type: 'multistep',
ignore_https_errors: false,
journey_id: '',
locations: [
{
id: 'us_central',
isServiceManaged: true,
},
],
name: 'Browser monitor',
namespace: 'default',
origin: 'ui',
params: '',
playwright_options: '',
playwright_text_assertion: '',
project_id: '',
schedule: {
number: '10',
unit: 'm',
},
screenshots: 'on',
'service.name': '',
'source.inline.script': script,
'source.project.content': '',
'source.zip_url.folder': '',
'source.zip_url.password': '',
'source.zip_url.proxy_url': '',
'source.zip_url.ssl.certificate': undefined,
'source.zip_url.ssl.certificate_authorities': undefined,
'source.zip_url.ssl.key': undefined,
'source.zip_url.ssl.key_passphrase': undefined,
'source.zip_url.ssl.supported_protocols': undefined,
'source.zip_url.ssl.verification_mode': undefined,
'source.zip_url.url': '',
'source.zip_url.username': '',
'ssl.certificate': '',
'ssl.certificate_authorities': '',
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
'ssl.verification_mode': 'full',
synthetics_args: [],
tags: [],
'throttling.config': '5d/3u/20l',
'throttling.download_speed': '5',
'throttling.is_enabled': true,
'throttling.latency': '20',
'throttling.upload_speed': '3',
timeout: '16',
type: 'browser',
'url.port': null,
urls: '',
});
}
);
it.each([
['testCA', true],
['', false],
])('correctly formats form fields to monitor type', (certificateAuthorities, isTLSEnabled) => {
expect(
format({
...formValues,
ssl: {
// @ts-ignore next
...formValues.ssl,
certificate_authorities: certificateAuthorities,
},
})
).toEqual({
__ui: {
is_tls_enabled: isTLSEnabled,
},
config_id: '',
'check.request.body': {
type: 'text',
value: '',
},
'check.request.headers': {},
'check.request.method': 'GET',
'check.response.body.negative': [],
'check.response.body.positive': [],
'check.response.headers': {},
'check.response.status': [],
enabled: true,
form_monitor_type: 'http',
locations: [
{
id: 'us_central',
isServiceManaged: true,
},
],
max_redirects: '0',
name: 'Sample name',
namespace: 'default',
origin: 'ui',
password: '',
proxy_url: '',
'response.include_body': 'on_error',
'response.include_headers': true,
schedule: {
number: '3',
unit: 'm',
},
'service.name': '',
'ssl.certificate': '',
'ssl.certificate_authorities': certificateAuthorities,
'ssl.key': '',
'ssl.key_passphrase': '',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'],
'ssl.verification_mode': 'full',
tags: [],
timeout: '16',
type: 'http',
urls: 'sample url',
username: '',
});
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { get, isEqual } from 'lodash';
import { ConfigKey, DataStream, FormMonitorType, MonitorFields } from '../types';
import { DEFAULT_FIELDS, DEFAULT_TLS_FIELDS } from '../constants';
export const formatter = (fields: Record<string, any>) => {
const monitorType = fields[ConfigKey.MONITOR_TYPE] as DataStream;
const monitorFields: Record<string, any> = {};
const defaults = DEFAULT_FIELDS[monitorType] as MonitorFields;
Object.keys(defaults).map((key) => {
/* split key names on dot to handle dot notation fields,
* which are changed to nested fields by react-hook-form */
monitorFields[key] = get(fields, key.split('.')) || defaults[key as ConfigKey];
});
return monitorFields as MonitorFields;
};
export const format = (fields: Record<string, unknown>) => {
const formattedFields = formatter(fields) as MonitorFields;
const formattedMap = {
[FormMonitorType.SINGLE]: {
...formattedFields,
[ConfigKey.SOURCE_INLINE]: `step('Go to ${formattedFields[ConfigKey.URLS]}', async () => {
await page.goto('${formattedFields[ConfigKey.URLS]}');
expect(await page.isVisible('text=${
formattedFields[ConfigKey.TEXT_ASSERTION]
}')).toBeTruthy();
});`,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.SINGLE,
},
[FormMonitorType.MULTISTEP]: {
...formattedFields,
[ConfigKey.METADATA]: {
script_source: {
is_generated_script: get(fields, 'source.inline.type') === 'recorder' ? true : false,
file_name: get(fields, 'source.inline.fileName'),
},
},
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
},
[FormMonitorType.HTTP]: {
...formattedFields,
[ConfigKey.METADATA]: {
is_tls_enabled: isCustomTLSEnabled(formattedFields),
},
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.HTTP,
},
[FormMonitorType.TCP]: {
...formattedFields,
[ConfigKey.METADATA]: {
is_tls_enabled: isCustomTLSEnabled(formattedFields),
},
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.TCP,
},
[FormMonitorType.ICMP]: {
...formattedFields,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.ICMP,
},
};
return formattedMap[fields[ConfigKey.FORM_MONITOR_TYPE] as FormMonitorType];
};
const isCustomTLSEnabled = (fields: MonitorFields) => {
const tlsFields: Record<string, unknown> = {};
Object.keys(DEFAULT_TLS_FIELDS).map((key) => {
tlsFields[key] = fields[key as keyof MonitorFields];
});
return !isEqual(tlsFields, DEFAULT_TLS_FIELDS);
};

View file

@ -0,0 +1,51 @@
/*
* 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 { EuiForm, EuiSpacer } from '@elastic/eui';
import { FormProvider } from 'react-hook-form';
import { useFormWrapped } from '../hooks/use_form_wrapped';
import { FormMonitorType, SyntheticsMonitor } from '../types';
import { getDefaultFormFields, formatDefaultFormValues } from './defaults';
import { ActionBar } from './submit';
import { Disclaimer } from './disclaimer';
export const MonitorForm: React.FC<{ defaultValues?: SyntheticsMonitor; space?: string }> = ({
children,
defaultValues,
space,
}) => {
const methods = useFormWrapped({
mode: 'onSubmit',
reValidateMode: 'onChange',
defaultValues:
formatDefaultFormValues(defaultValues as SyntheticsMonitor) ||
getDefaultFormFields(space)[FormMonitorType.MULTISTEP],
shouldFocusError: true,
});
/* React hook form doesn't seem to register a field
* as dirty until validation unless dirtyFields is subscribed to */
const {
formState: { isSubmitted, errors, dirtyFields: _ },
} = methods;
return (
<FormProvider {...methods}>
<EuiForm
isInvalid={Boolean(isSubmitted && Object.keys(errors).length)}
component="form"
noValidate
>
{children}
<EuiSpacer />
<ActionBar />
</EuiForm>
<Disclaimer />
</FormProvider>
);
};

View file

@ -0,0 +1,128 @@
/*
* 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 { Redirect, useParams, useHistory, useRouteMatch } from 'react-router-dom';
import { EuiButton, EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFormContext } from 'react-hook-form';
import { useFetcher, FETCH_STATUS } from '@kbn/observability-plugin/public';
import { SyntheticsMonitor } from '../types';
import { format } from './formatter';
import { createMonitorAPI, updateMonitorAPI } from '../../../state/monitor_management/api';
import { kibanaService } from '../../../../../utils/kibana_service';
import { MONITORS_ROUTE, MONITOR_EDIT_ROUTE } from '../../../../../../common/constants';
export const ActionBar = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const history = useHistory();
const editRouteMatch = useRouteMatch({ path: MONITOR_EDIT_ROUTE });
const isEdit = editRouteMatch?.isExact;
const { handleSubmit } = useFormContext();
const [monitorData, setMonitorData] = useState<SyntheticsMonitor | undefined>(undefined);
const { data, status } = useFetcher(() => {
if (!monitorData) {
return null;
}
if (isEdit) {
return updateMonitorAPI({
id: monitorId,
monitor: monitorData,
});
} else {
return createMonitorAPI({
monitor: monitorData,
});
}
}, [monitorData]);
const loading = status === FETCH_STATUS.LOADING;
useEffect(() => {
if (status === FETCH_STATUS.FAILURE) {
kibanaService.toasts.addDanger({
title: MONITOR_FAILURE_LABEL,
toastLifeTimeMs: 3000,
});
} else if (status === FETCH_STATUS.SUCCESS && !loading) {
kibanaService.toasts.addSuccess({
title: monitorId ? MONITOR_UPDATED_SUCCESS_LABEL : MONITOR_SUCCESS_LABEL,
toastLifeTimeMs: 3000,
});
}
}, [data, status, monitorId, loading]);
const formSubmitter = (formData: Record<string, any>) => {
setMonitorData(format(formData) as SyntheticsMonitor);
};
return status === FETCH_STATUS.SUCCESS ? (
<Redirect to={MONITORS_ROUTE} />
) : (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiLink href={history.createHref({ pathname: MONITORS_ROUTE })}>{CANCEL_LABEL}</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="plusInCircleFilled"
onClick={handleSubmit(formSubmitter)}
data-test-subj="syntheticsMonitorConfigSubmitButton"
>
{isEdit ? UPDATE_MONITOR_LABEL : CREATE_MONITOR_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.discardLabel', {
defaultMessage: 'Cancel',
});
const CREATE_MONITOR_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.addEdit.createMonitorLabel',
{
defaultMessage: 'Create monitor',
}
);
const UPDATE_MONITOR_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.updateMonitorLabel',
{
defaultMessage: 'Update monitor',
}
);
const MONITOR_SUCCESS_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorAddedSuccessMessage',
{
defaultMessage: 'Monitor added successfully.',
}
);
const MONITOR_UPDATED_SUCCESS_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorEditedSuccessMessage',
{
defaultMessage: 'Monitor updated successfully.',
}
);
const MONITOR_FAILURE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorFailureMessage',
{
defaultMessage: 'Monitor was unable to be saved. Please try again later.',
}
);

View file

@ -0,0 +1,76 @@
/*
* 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 {
ConfigKey,
DataStream,
HTTPFields,
BrowserFields,
MonitorFields,
ScheduleUnit,
} from '../types';
import { validate } from './validation';
describe('[Monitor Management] validation', () => {
const commonPropsValid: Partial<MonitorFields> = {
[ConfigKey.SCHEDULE]: { number: '5', unit: ScheduleUnit.MINUTES },
[ConfigKey.TIMEOUT]: '3m',
};
describe('HTTP', () => {
const httpPropsValid: Partial<HTTPFields> = {
...commonPropsValid,
[ConfigKey.RESPONSE_STATUS_CHECK]: ['200', '204'],
[ConfigKey.RESPONSE_HEADERS_CHECK]: { 'Content-Type': 'application/json' },
[ConfigKey.REQUEST_HEADERS_CHECK]: { 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8' },
[ConfigKey.MAX_REDIRECTS]: '3',
[ConfigKey.URLS]: 'https:// example-url.com',
};
it('should return false for all valid props', () => {
const validators = validate[DataStream.HTTP];
const keysToValidate = [
ConfigKey.SCHEDULE,
ConfigKey.TIMEOUT,
ConfigKey.RESPONSE_STATUS_CHECK,
ConfigKey.RESPONSE_HEADERS_CHECK,
ConfigKey.REQUEST_HEADERS_CHECK,
ConfigKey.MAX_REDIRECTS,
ConfigKey.URLS,
];
const validatorFns = keysToValidate.map((key) => validators[key]);
const result = validatorFns.map((fn) => fn?.(httpPropsValid) ?? true);
expect(result).not.toEqual(expect.arrayContaining([true]));
});
});
describe.each([
[ConfigKey.SOURCE_INLINE, 'step(() => {});'],
[ConfigKey.SOURCE_ZIP_URL, 'https://test.zip'],
])('Browser', (configKey, value) => {
const browserProps: Partial<BrowserFields> = {
...commonPropsValid,
[ConfigKey.MONITOR_TYPE]: DataStream.BROWSER,
[ConfigKey.TIMEOUT]: null,
[ConfigKey.URLS]: null,
[ConfigKey.PORT]: null,
[configKey]: value,
};
it('should return false for all valid props', () => {
const validators = validate[DataStream.BROWSER];
const keysToValidate = [ConfigKey.SCHEDULE, ConfigKey.TIMEOUT, configKey];
const validatorFns = keysToValidate.map((key) => validators[key]);
const result = validatorFns.map((fn) => fn?.(browserProps as Partial<MonitorFields>) ?? true);
expect(result).not.toEqual(expect.arrayContaining([true]));
});
});
// TODO: Add test for other monitor types if needed
});

View file

@ -0,0 +1,157 @@
/*
* 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 {
ConfigKey,
DataStream,
ScheduleUnit,
MonitorFields,
Validator,
Validation,
} from '../types';
export const DIGITS_ONLY = /^[0-9]*$/g;
export const INCLUDES_VALID_PORT = /[^\:]+:[0-9]{1,5}$/g;
export const WHOLE_NUMBERS_ONLY = /^[0-9]+(.?[0]+)?$/;
export const FLOATS_ONLY = /^[0-9]+(.?[0-9]+)?$/;
type ValidationLibrary = Record<string, Validator>;
// returns true if invalid
export function validateHeaders<T>(headers: T): boolean {
return Object.keys(headers).some((key) => {
if (key) {
const whiteSpaceRegEx = /[\s]/g;
return whiteSpaceRegEx.test(key);
} else {
return false;
}
});
}
// returns true if invalid
export const validateTimeout = ({
scheduleNumber,
scheduleUnit,
timeout,
}: {
scheduleNumber: string;
scheduleUnit: ScheduleUnit;
timeout: string;
}): boolean => {
let schedule: number;
switch (scheduleUnit) {
case ScheduleUnit.SECONDS:
schedule = parseFloat(scheduleNumber);
break;
case ScheduleUnit.MINUTES:
schedule = parseFloat(scheduleNumber) * 60;
break;
default:
schedule = parseFloat(scheduleNumber);
}
return parseFloat(timeout) > schedule;
};
// validation functions return true when invalid
const validateCommon: ValidationLibrary = {
[ConfigKey.SCHEDULE]: ({ [ConfigKey.SCHEDULE]: value }) => {
const { number, unit } = value as MonitorFields[ConfigKey.SCHEDULE];
const parsedFloat = parseFloat(number);
return !parsedFloat || !unit || parsedFloat < 1;
},
[ConfigKey.TIMEOUT]: ({
[ConfigKey.MONITOR_TYPE]: monitorType,
[ConfigKey.TIMEOUT]: timeout,
[ConfigKey.SCHEDULE]: schedule,
}) => {
const { number, unit } = schedule as MonitorFields[ConfigKey.SCHEDULE];
// Timeout is not currently supported by browser monitors
if (monitorType === DataStream.BROWSER) {
return false;
}
return (
!timeout ||
parseFloat(timeout) < 0 ||
validateTimeout({
timeout,
scheduleNumber: number,
scheduleUnit: unit,
})
);
},
};
const validateHTTP: ValidationLibrary = {
[ConfigKey.RESPONSE_STATUS_CHECK]: ({ [ConfigKey.RESPONSE_STATUS_CHECK]: value }) => {
const statusCodes = value as MonitorFields[ConfigKey.RESPONSE_STATUS_CHECK];
return statusCodes.length ? statusCodes.some((code) => !`${code}`.match(DIGITS_ONLY)) : false;
},
[ConfigKey.RESPONSE_HEADERS_CHECK]: ({ [ConfigKey.RESPONSE_HEADERS_CHECK]: value }) => {
const headers = value as MonitorFields[ConfigKey.RESPONSE_HEADERS_CHECK];
return validateHeaders<MonitorFields[ConfigKey.RESPONSE_HEADERS_CHECK]>(headers);
},
[ConfigKey.REQUEST_HEADERS_CHECK]: ({ [ConfigKey.REQUEST_HEADERS_CHECK]: value }) => {
const headers = value as MonitorFields[ConfigKey.REQUEST_HEADERS_CHECK];
return validateHeaders<MonitorFields[ConfigKey.REQUEST_HEADERS_CHECK]>(headers);
},
[ConfigKey.MAX_REDIRECTS]: ({ [ConfigKey.MAX_REDIRECTS]: value }) =>
(!!value && !`${value}`.match(DIGITS_ONLY)) ||
parseFloat(value as MonitorFields[ConfigKey.MAX_REDIRECTS]) < 0,
[ConfigKey.URLS]: ({ [ConfigKey.URLS]: value }) => !value,
...validateCommon,
};
const validateTCP: Record<string, Validator> = {
[ConfigKey.HOSTS]: ({ [ConfigKey.HOSTS]: value }) => {
return !value || !`${value}`.match(INCLUDES_VALID_PORT);
},
...validateCommon,
};
const validateICMP: ValidationLibrary = {
[ConfigKey.HOSTS]: ({ [ConfigKey.HOSTS]: value }) => !value,
[ConfigKey.WAIT]: ({ [ConfigKey.WAIT]: value }) =>
!!value &&
!DIGITS_ONLY.test(`${value}`) &&
parseFloat(value as MonitorFields[ConfigKey.WAIT]) < 0,
...validateCommon,
};
const validateThrottleValue = (speed: string | undefined, allowZero?: boolean) => {
if (speed === undefined || speed === '') return false;
const throttleValue = parseFloat(speed);
return isNaN(throttleValue) || (allowZero ? throttleValue < 0 : throttleValue <= 0);
};
const validateBrowser: ValidationLibrary = {
...validateCommon,
[ConfigKey.SOURCE_ZIP_URL]: ({
[ConfigKey.SOURCE_ZIP_URL]: zipUrl,
[ConfigKey.SOURCE_INLINE]: inlineScript,
}) => !zipUrl && !inlineScript,
[ConfigKey.SOURCE_INLINE]: ({
[ConfigKey.SOURCE_ZIP_URL]: zipUrl,
[ConfigKey.SOURCE_INLINE]: inlineScript,
}) => !zipUrl && !inlineScript,
[ConfigKey.DOWNLOAD_SPEED]: ({ [ConfigKey.DOWNLOAD_SPEED]: downloadSpeed }) =>
validateThrottleValue(downloadSpeed),
[ConfigKey.UPLOAD_SPEED]: ({ [ConfigKey.UPLOAD_SPEED]: uploadSpeed }) =>
validateThrottleValue(uploadSpeed),
[ConfigKey.LATENCY]: ({ [ConfigKey.LATENCY]: latency }) => validateThrottleValue(latency, true),
};
export type ValidateDictionary = Record<DataStream, Validation>;
export const validate: ValidateDictionary = {
[DataStream.HTTP]: validateHTTP,
[DataStream.TCP]: validateTCP,
[DataStream.ICMP]: validateICMP,
[DataStream.BROWSER]: validateBrowser,
};

View file

@ -0,0 +1,9 @@
/*
* 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 * from './use_is_edit_flow';
export * from './use_kibana_space';

View file

@ -0,0 +1,33 @@
/*
* 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 { useCallback } 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(props);
const euiRegister = useCallback(
(name, ...registerArgs) => {
const { ref, ...restOfRegister } = register(name, ...registerArgs);
return {
inputRef: ref,
ref,
...restOfRegister,
};
},
[register]
);
return {
register: euiRegister,
...restOfForm,
};
}

View file

@ -0,0 +1,13 @@
/*
* 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 { useRouteMatch } from 'react-router-dom';
import { MONITOR_EDIT_ROUTE } from '../../../../../../common/constants';
export const useIsEditFlow = () => {
const editRouteMatch = useRouteMatch({ path: MONITOR_EDIT_ROUTE });
return editRouteMatch?.isExact || false;
};

View file

@ -0,0 +1,28 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetcher } from '@kbn/observability-plugin/public';
import { ClientPluginsStart } from '../../../../../plugin';
export const useKibanaSpace = () => {
const { services } = useKibana<ClientPluginsStart>();
const {
data: space,
loading,
error,
} = useFetcher(() => {
return services.spaces?.getActiveSpace();
}, [services.spaces]);
return {
space,
loading,
error,
};
};

View file

@ -0,0 +1,80 @@
/*
* 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 { defaultCore, WrappedHelper } from '../../../utils/testing/rtl_helpers';
import { renderHook } from '@testing-library/react-hooks';
import { useMonitorName } from './use_monitor_name';
import * as reactRouter from 'react-router-dom';
const mockRouter = {
...reactRouter,
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn().mockReturnValue({}),
}));
describe('useMonitorName', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useMonitorName({}), { wrapper: WrappedHelper });
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: '' });
expect(defaultCore.savedObjects.client.find).toHaveBeenCalledWith({
aggs: {
monitorNames: {
terms: { field: 'synthetics-monitor.attributes.name.keyword', size: 10000 },
},
},
perPage: 0,
type: 'synthetics-monitor',
});
});
it('returns expected results after data', async () => {
defaultCore.savedObjects.client.find = jest.fn().mockReturnValue({
aggregations: {
monitorNames: {
buckets: [{ key: 'Test' }, { key: 'Test 1' }],
},
},
});
const { result, waitForNextUpdate } = renderHook(() => useMonitorName({ search: 'Test' }), {
wrapper: WrappedHelper,
});
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: 'Test' });
await waitForNextUpdate();
expect(result.current).toStrictEqual({ nameAlreadyExists: true, validName: '' });
});
it('returns expected results after data while editing monitor', async () => {
defaultCore.savedObjects.client.find = jest.fn().mockReturnValue({
aggregations: {
monitorNames: {
buckets: [{ key: 'Test' }, { key: 'Test 1' }],
},
},
});
jest.spyOn(mockRouter, 'useParams').mockReturnValue({ monitorId: 'test-id' });
const { result, waitForNextUpdate } = renderHook(() => useMonitorName({ search: 'Test' }), {
wrapper: WrappedHelper,
});
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: 'Test' });
await waitForNextUpdate();
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: 'Test' });
});
});

View file

@ -0,0 +1,64 @@
/*
* 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 { useEffect, useState } from 'react';
import { useFetcher } from '@kbn/observability-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useParams } from 'react-router-dom';
import { syntheticsMonitorType } from '../../../../../../common/types/saved_objects';
interface AggsResponse {
monitorNames: {
buckets: Array<{
key: string;
}>;
};
}
export const useMonitorName = ({ search = '' }: { search?: string }) => {
const [values, setValues] = useState<string[]>([]);
const { monitorId } = useParams<{ monitorId: string }>();
const { savedObjects } = useKibana().services;
const { data } = useFetcher(() => {
const aggs = {
monitorNames: {
terms: {
field: `${syntheticsMonitorType}.attributes.name.keyword`,
size: 10000,
},
},
};
return savedObjects?.client.find<unknown, typeof aggs>({
type: syntheticsMonitorType,
perPage: 0,
aggs,
});
}, []);
useEffect(() => {
if (data?.aggregations) {
const newValues = (data.aggregations as AggsResponse)?.monitorNames.buckets.map(({ key }) =>
key.toLowerCase()
);
if (monitorId && newValues.includes(search.toLowerCase())) {
setValues(newValues.filter((val) => val !== search.toLowerCase()));
} else {
setValues(newValues);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, monitorId]);
const hasMonitor = Boolean(
search && values && values.length > 0 && values?.includes(search.trim().toLowerCase())
);
return { nameAlreadyExists: hasMonitor, validName: hasMonitor ? '' : search };
};

View file

@ -1,22 +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 { useTrackPageview } from '@kbn/observability-plugin/public';
import { useMonitorAddEditBreadcrumbs } from './use_breadcrumbs';
export const MonitorAddEditPage: React.FC = () => {
useTrackPageview({ app: 'synthetics', path: 'add-monitor' });
useTrackPageview({ app: 'synthetics', path: 'add-monitor', delay: 15000 });
useMonitorAddEditBreadcrumbs();
return (
<>
<p>Monitor Add or Edit page</p>
</>
);
};

View file

@ -0,0 +1,37 @@
/*
* 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 } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { useTrackPageview } from '@kbn/observability-plugin/public';
import { useKibanaSpace } from './hooks/use_kibana_space';
import { getServiceLocations } from '../../state';
import { MonitorSteps } from './steps';
import { MonitorForm } from './form';
import { ADD_MONITOR_STEPS } from './steps/step_config';
import { useMonitorAddEditBreadcrumbs } from './use_breadcrumbs';
export const MonitorAddPage = () => {
useTrackPageview({ app: 'synthetics', path: 'add-monitor' });
const { space, loading, error } = useKibanaSpace();
useTrackPageview({ app: 'synthetics', path: 'add-monitor', delay: 15000 });
useMonitorAddEditBreadcrumbs();
const dispatch = useDispatch();
useEffect(() => {
dispatch(getServiceLocations());
}, [dispatch]);
return !loading && !error ? (
<MonitorForm space={space?.id}>
<MonitorSteps stepMap={ADD_MONITOR_STEPS} />
</MonitorForm>
) : (
<EuiLoadingSpinner />
);
};

View file

@ -0,0 +1,32 @@
/*
* 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 { useHistory } from 'react-router-dom';
import { EuiButtonEmpty } from '@elastic/eui';
import { InPortal } from 'react-reverse-portal';
import { MonitorDetailsLinkPortalNode } from './portals';
export const MonitorDetailsLinkPortal = ({ name, id }: { name: string; id: string }) => {
return (
<InPortal node={MonitorDetailsLinkPortalNode}>
<MonitorDetailsLink name={name} id={id} />
</InPortal>
);
};
export const MonitorDetailsLink = ({ name, id }: { name: string; id: string }) => {
const history = useHistory();
const href = history.createHref({
pathname: `monitor/${id}`,
});
return (
<EuiButtonEmpty href={href} iconType="arrowLeft" flush="left">
{name}
</EuiButtonEmpty>
);
};

View file

@ -0,0 +1,44 @@
/*
* 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 } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { useParams } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useTrackPageview, useFetcher } from '@kbn/observability-plugin/public';
import { getServiceLocations } from '../../state';
import { MonitorSteps } from './steps';
import { MonitorForm } from './form';
import { MonitorDetailsLinkPortal } from './monitor_details_portal';
import { useMonitorAddEditBreadcrumbs } from './use_breadcrumbs';
import { getMonitorAPI } from '../../state/monitor_management/api';
import { EDIT_MONITOR_STEPS } from './steps/step_config';
export const MonitorEditPage: React.FC = () => {
useTrackPageview({ app: 'synthetics', path: 'edit-monitor' });
useTrackPageview({ app: 'synthetics', path: 'edit-monitor', delay: 15000 });
const { monitorId } = useParams<{ monitorId: string }>();
useMonitorAddEditBreadcrumbs(true);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getServiceLocations());
}, [dispatch]);
const { data, loading, error } = useFetcher(() => {
return getMonitorAPI({ id: monitorId });
}, []);
return data && !loading && !error ? (
<MonitorForm defaultValues={data?.attributes}>
<MonitorSteps stepMap={EDIT_MONITOR_STEPS} isEditFlow={true} />
<MonitorDetailsLinkPortal id={data?.id} name={data?.attributes.name} />
</MonitorForm>
) : (
<EuiLoadingSpinner />
);
};

View file

@ -0,0 +1,12 @@
/*
* 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 { createHtmlPortalNode } from 'react-reverse-portal';
export const MonitorTypePortalNode = createHtmlPortalNode();
export const MonitorDetailsLinkPortalNode = createHtmlPortalNode();

View file

@ -0,0 +1,48 @@
/*
* 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 { EuiSteps, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { ConfigKey, FormMonitorType, StepMap } from '../types';
import { AdvancedConfig } from '../advanced';
import { MonitorTypePortal } from './monitor_type_portal';
export const MonitorSteps = ({
stepMap,
isEditFlow = false,
}: {
stepMap: StepMap;
isEditFlow?: boolean;
}) => {
const { watch } = useFormContext();
const [type]: [FormMonitorType] = watch([ConfigKey.FORM_MONITOR_TYPE]);
const steps = stepMap[type];
return (
<>
{isEditFlow ? (
steps.map((step) => (
<>
<EuiPanel hasBorder>
<EuiText size="s">
<h2>{step.title}</h2>
</EuiText>
<EuiSpacer size="xs" />
{step.children}
</EuiPanel>
<EuiSpacer size="m" />
</>
))
) : (
<EuiSteps steps={steps} headingElement="h2" />
)}
<AdvancedConfig />
<MonitorTypePortal monitorType={type} />
</>
);
};

View file

@ -0,0 +1,46 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiBetaBadge, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormMonitorType } from '../types';
import { MONITOR_TYPE_CONFIG } from '../form/field_config';
export const MonitorType = ({ monitorType }: { monitorType: FormMonitorType }) => {
const config = MONITOR_TYPE_CONFIG[monitorType];
return (
<>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.synthetics.monitorConfig.monitorType.label', {
defaultMessage: 'Monitor type',
})}
</EuiText>
<EuiText size="s">
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem>
<strong>{config.descriptionTitle}</strong>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{config.beta && (
<EuiBetaBadge
label="Beta"
tooltipContent={i18n.translate(
'xpack.synthetics.monitorConfig.monitorType.betaLabel',
{
defaultMessage:
'This functionality is in beta and is subject to change. The design and code is less mature than official generally available features and is being provided as-is with no warranties. Beta features are not subject to the support service level agreement of official generally available features.',
}
)}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
</>
);
};

View file

@ -0,0 +1,21 @@
/*
* 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 { InPortal } from 'react-reverse-portal';
import { MonitorTypePortalNode } from '../portals';
import { FormMonitorType } from '../types';
import { MonitorType } from './monitor_type';
export const MonitorTypePortal = ({ monitorType }: { monitorType: FormMonitorType }) => {
return (
<InPortal node={MonitorTypePortalNode}>
<MonitorType monitorType={monitorType} />
</InPortal>
);
};

View file

@ -0,0 +1,25 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
interface Props {
description: React.ReactNode;
children: React.ReactNode;
}
export const Step = ({ description, children }: Props) => {
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiText>{description}</EuiText>
</EuiFlexItem>
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,153 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import { FormMonitorType, Step, StepMap } from '../types';
import { StepFields } from './step_fields';
const MONITOR_TYPE_STEP: Step = {
title: i18n.translate('xpack.synthetics.monitorConfig.monitorTypeStep.title', {
defaultMessage: 'Select a monitor type',
}),
children: (
<StepFields
description={
<p>
{i18n.translate('xpack.synthetics.monitorConfig.monitorTypeStep.description', {
defaultMessage: 'Choose a monitor that best fits your use case',
})}
</p>
}
stepKey="step1"
/>
),
};
const MONITOR_DETAILS_STEP: Step = {
title: i18n.translate('xpack.synthetics.monitorConfig.monitorDetailsStep.title', {
defaultMessage: 'Monitor details',
}),
children: (
<StepFields
description={
<p>
{i18n.translate('xpack.synthetics.monitorConfig.monitorDetailsStep.description', {
defaultMessage: 'Provide some details about how your monitor should run',
})}
</p>
}
stepKey="step2"
/>
),
};
const SCRIPT_RECORDER_BTNS = (
<EuiFlexGroup justifyContent="flexStart">
<EuiFlexItem grow={false}>
<EuiButton href={`elastic-synthetics-recorder://`} iconType="popout" iconSide="right">
{i18n.translate('xpack.synthetics.monitorConfig.monitorScriptStep.scriptRecorder.launch', {
defaultMessage: 'Launch Synthetics Recorder',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
href="https://github.com/elastic/synthetics-recorder/releases/"
iconType="download"
>
{i18n.translate(
'xpack.synthetics.monitorConfig.monitorScriptStep.scriptRecorder.download',
{
defaultMessage: 'Download Synthetics Recorder',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
const MONITOR_SCRIPT_STEP: Step = {
title: i18n.translate('xpack.synthetics.monitorConfig.monitorScriptStep.title', {
defaultMessage: 'Add a script',
}),
children: (
<StepFields
description={
<>
<p>
<FormattedMessage
id="xpack.synthetics.monitorConfig.monitorScriptStep.description"
defaultMessage="Use Elastic Synthetics Recorder to generate a script and then upload it. Alternatively, you can write your own {playwright} script and paste it in the script editor."
values={{
playwright: (
<EuiLink href="https://playwright.dev/" target="_blank" external>
<FormattedMessage
id="xpack.synthetics.monitorConfig.monitorScriptStep.playwrightLink"
defaultMessage="Playwright"
/>
</EuiLink>
),
}}
/>
</p>
{SCRIPT_RECORDER_BTNS}
</>
}
stepKey="step3"
/>
),
};
const MONITOR_SCRIPT_STEP_EDIT: Step = {
title: i18n.translate('xpack.synthetics.monitorConfig.monitorScriptEditStep.title', {
defaultMessage: 'Monitor script',
}),
children: (
<StepFields
description={
<>
<p>
<FormattedMessage
id="xpack.synthetics.monitorConfig.monitorScriptEditStep.description"
defaultMessage="Use Elastic Synthetics Recorder to generate and upload a script. Alternatively, you can edit the existing {playwright} script (or paste a new one) in the script editor."
values={{
playwright: (
<EuiLink href="https://playwright.dev/" target="_blank" external>
<FormattedMessage
id="xpack.synthetics.monitorConfig.monitorScriptEditStep.playwrightLink"
defaultMessage="Playwright"
/>
</EuiLink>
),
}}
/>
</p>
{SCRIPT_RECORDER_BTNS}
</>
}
stepKey="scriptEdit"
/>
),
};
export const ADD_MONITOR_STEPS: StepMap = {
[FormMonitorType.MULTISTEP]: [MONITOR_TYPE_STEP, MONITOR_DETAILS_STEP, MONITOR_SCRIPT_STEP],
[FormMonitorType.SINGLE]: [MONITOR_TYPE_STEP, MONITOR_DETAILS_STEP],
[FormMonitorType.HTTP]: [MONITOR_TYPE_STEP, MONITOR_DETAILS_STEP],
[FormMonitorType.ICMP]: [MONITOR_TYPE_STEP, MONITOR_DETAILS_STEP],
[FormMonitorType.TCP]: [MONITOR_TYPE_STEP, MONITOR_DETAILS_STEP],
};
export const EDIT_MONITOR_STEPS: StepMap = {
[FormMonitorType.MULTISTEP]: [MONITOR_SCRIPT_STEP_EDIT, MONITOR_DETAILS_STEP],
[FormMonitorType.SINGLE]: [MONITOR_DETAILS_STEP],
[FormMonitorType.HTTP]: [MONITOR_DETAILS_STEP],
[FormMonitorType.ICMP]: [MONITOR_DETAILS_STEP],
[FormMonitorType.TCP]: [MONITOR_DETAILS_STEP],
};

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.
*/
import React from 'react';
import { useFormContext, FieldError } from 'react-hook-form';
import { Step } from './step';
import { FORM_CONFIG } from '../form/form_config';
import { Field } from '../form/field';
import { ConfigKey, FormMonitorType, StepKey } from '../types';
export const StepFields = ({
description,
stepKey,
}: {
description: React.ReactNode;
stepKey: StepKey;
}) => {
const {
watch,
formState: { errors },
} = useFormContext();
const [type]: [FormMonitorType] = watch([ConfigKey.FORM_MONITOR_TYPE]);
return (
<Step description={description}>
{FORM_CONFIG[type][stepKey]?.map((field) => {
return (
<Field
{...field}
key={field.fieldKey}
fieldError={errors[field.fieldKey] as FieldError}
/>
);
})}
</Step>
);
};

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 React from 'react';
import {
UseFormReturn,
ControllerRenderProps,
ControllerFieldState,
FormState,
} from 'react-hook-form';
import {
ServiceLocations,
FormMonitorType,
SyntheticsMonitor,
} from '../../../../../common/runtime_types/monitor_management';
export type StepKey = 'step1' | 'step2' | 'step3' | 'scriptEdit';
export interface Step {
title: string;
children: React.ReactNode;
}
export type StepMap = Record<FormMonitorType, Step[]>;
export * from '../../../../../common/runtime_types/monitor_management';
export * from '../../../../../common/types/monitor_validation';
export interface FieldMeta {
fieldKey: string;
component: React.ComponentType<any>;
label?: string;
ariaLabel?: string;
helpText?: string | React.ReactNode;
props?: (params: {
field?: ControllerRenderProps;
formState: FormState<SyntheticsMonitor>;
setValue: UseFormReturn['setValue'];
reset: UseFormReturn['reset'];
locations: ServiceLocations;
dependencies: unknown[];
dependenciesFieldMeta: Record<string, ControllerFieldState>;
space?: string;
isEdit?: boolean;
}) => Record<string, any>;
controlled?: boolean;
required?: boolean;
shouldUseSetValue?: boolean;
customHook?: (value: unknown) => {
// custom hooks are only supported for controlled components and only supported for determining error validation
func: Function;
params: unknown;
fieldKey: string;
error: string;
};
onChange?: (
event: React.ChangeEvent<HTMLInputElement>,
formOnChange: (event: React.ChangeEvent<HTMLInputElement>) => void
) => void;
showWhen?: [string, any]; // show field when another field equals an arbitrary value
validation?: (dependencies: unknown[]) => Parameters<UseFormReturn['register']>[1];
error?: React.ReactNode;
dependencies?: string[]; // fields that another field may depend for or validation. Values are passed to the validation function
}

View file

@ -7,19 +7,24 @@
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { MONITOR_ADD_ROUTE } from '../../../../../common/constants';
import { MONITOR_ADD_ROUTE, MONITOR_EDIT_ROUTE } from '../../../../../common/constants';
import { PLUGIN } from '../../../../../common/constants/plugin';
export const useMonitorAddEditBreadcrumbs = () => {
export const useMonitorAddEditBreadcrumbs = (isEdit?: boolean) => {
const kibana = useKibana();
const appPath = kibana.services.application?.getUrlForApp(PLUGIN.SYNTHETICS_PLUGIN_ID) ?? '';
useBreadcrumbs([
{
text: ADD_MONITOR_CRUMB,
href: `${appPath}/${MONITOR_ADD_ROUTE}`,
},
]);
const config = isEdit
? {
text: EDIT_MONITOR_CRUMB,
href: `${appPath}/${MONITOR_EDIT_ROUTE}`,
}
: {
text: ADD_MONITOR_CRUMB,
href: `${appPath}/${MONITOR_ADD_ROUTE}`,
};
useBreadcrumbs([config]);
};
export const ADD_MONITOR_CRUMB = i18n.translate(
@ -28,3 +33,10 @@ export const ADD_MONITOR_CRUMB = i18n.translate(
defaultMessage: 'Add monitor',
}
);
export const EDIT_MONITOR_CRUMB = i18n.translate(
'xpack.synthetics.monitorManagement.editMonitorCrumb',
{
defaultMessage: 'Edit monitor',
}
);

View file

@ -98,6 +98,7 @@ export const Actions = ({ euiTheme, id, name, reloadPage, canEditSynthetics }: P
iconType="boxesHorizontal"
color="primary"
iconSide="right"
data-test-subj="syntheticsMonitorListActions"
onClick={openPopover}
/>
);
@ -139,7 +140,7 @@ export const Actions = ({ euiTheme, id, name, reloadPage, canEditSynthetics }: P
key="xpack.synthetics.editMonitor"
icon="pencil"
onClick={closePopover}
href={`${basePath}/app/uptime/edit-monitor/${id}`}
href={`${basePath}/app/synthetics/edit-monitor/${id}`}
disabled={!canEditSynthetics}
>
{labels.EDIT_LABEL}

View file

@ -42,7 +42,7 @@ export const MonitorsPageHeader = () => {
fill
iconSide="left"
iconType="plusInCircleFilled"
href={`${basePath}/app/uptime${MONITOR_ADD_ROUTE}`}
href={`${basePath}/app/synthetics${MONITOR_ADD_ROUTE}`}
isDisabled={!isEnabled}
data-test-subj="syntheticsAddMonitorBtn"
>

View file

@ -7,25 +7,38 @@
import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
import React, { FC, useEffect } from 'react';
import { EuiPageTemplateProps, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import {
EuiPageTemplateProps,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
useEuiTheme,
} from '@elastic/eui';
import { Route, Switch, useHistory } from 'react-router-dom';
import { OutPortal } from 'react-reverse-portal';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { APP_WRAPPER_CLASS } from '@kbn/core/public';
import { useInspectorContext } from '@kbn/observability-plugin/public';
import { MonitorAddPage } from './components/monitor_add_edit/monitor_add_page';
import { MonitorEditPage } from './components/monitor_add_edit/monitor_edit_page';
import { RunTestManually } from './components/monitor_summary/run_test_manually';
import { MonitorSummaryHeaderContent } from './components/monitor_summary/monitor_summary_header_content';
import { MonitorSummaryTitle } from './components/monitor_summary/monitor_summary_title';
import { MonitorSummaryPage } from './components/monitor_summary/monitor_summary';
import { GettingStartedPage } from './components/getting_started/getting_started_page';
import { MonitorAddEditPage } from './components/monitor_add_edit/monitor_add_edit_page';
import { MonitorsPageHeader } from './components/monitors_page/management/page_header/monitors_page_header';
import { OverviewPage } from './components/monitors_page/overview/overview_page';
import { SyntheticsPageTemplateComponent } from './components/common/pages/synthetics_page_template';
import { NotFoundPage } from './components/common/pages/not_found';
import { ServiceAllowedWrapper } from './components/common/wrappers/service_allowed_wrapper';
import {
MonitorTypePortalNode,
MonitorDetailsLinkPortalNode,
} from './components/monitor_add_edit/portals';
import {
MONITOR_ADD_ROUTE,
MONITOR_EDIT_ROUTE,
MONITORS_ROUTE,
OVERVIEW_ROUTE,
GETTING_STARTED_ROUTE,
@ -185,27 +198,68 @@ const getRoutes = (
},
},
{
title: i18n.translate('xpack.synthetics.addMonitorRoute.title', {
defaultMessage: 'Add Monitor | {baseTitle}',
title: i18n.translate('xpack.synthetics.createMonitorRoute.title', {
defaultMessage: 'Create Monitor | {baseTitle}',
values: { baseTitle },
}),
path: MONITOR_ADD_ROUTE,
component: () => (
<ServiceAllowedWrapper>
<MonitorAddEditPage />
<MonitorAddPage />
</ServiceAllowedWrapper>
),
dataTestSubj: 'syntheticsMonitorAddPage',
pageHeader: {
pageTitle: (
<FormattedMessage
id="xpack.synthetics.addMonitor.pageHeader.title"
defaultMessage="Add Monitor"
id="xpack.synthetics.createMonitor.pageHeader.title"
defaultMessage="Create Monitor"
/>
),
children: (
<FormattedMessage
id="xpack.synthetics.addMonitor.pageHeader.description"
defaultMessage="For more information about available monitor types and other options, see our {docs}."
values={{
docs: (
<EuiLink target="_blank" href="#">
<FormattedMessage
id="xpack.synthetics.addMonitor.pageHeader.docsLink"
defaultMessage="documentation"
/>
</EuiLink>
),
}}
/>
),
},
// bottomBar: <MonitorManagementBottomBar />,
bottomBarProps: { paddingSize: 'm' as const },
},
{
title: i18n.translate('xpack.synthetics.editMonitorRoute.title', {
defaultMessage: 'Edit Monitor | {baseTitle}',
values: { baseTitle },
}),
path: MONITOR_EDIT_ROUTE,
component: () => (
<ServiceAllowedWrapper>
<MonitorEditPage />
</ServiceAllowedWrapper>
),
dataTestSubj: 'syntheticsMonitorEditPage',
pageHeader: {
pageTitle: (
<FormattedMessage
id="xpack.synthetics.editMonitor.pageHeader.title"
defaultMessage="Edit Monitor"
/>
),
rightSideItems: [<OutPortal node={MonitorTypePortalNode} />],
breadcrumbs: [
{
text: <OutPortal node={MonitorDetailsLinkPortalNode} />,
},
],
},
},
];
};

View file

@ -0,0 +1,42 @@
/*
* 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 { apiService } from '../../../../utils/api_service';
import {
EncryptedSyntheticsMonitor,
ServiceLocationErrors,
SyntheticsMonitor,
SyntheticsMonitorWithId,
} from '../../../../../common/runtime_types';
import { API_URLS } from '../../../../../common/constants';
import { DecryptedSyntheticsMonitorSavedObject } from '../../../../../common/types';
export const createMonitorAPI = async ({
monitor,
}: {
monitor: SyntheticsMonitor | EncryptedSyntheticsMonitor;
}): Promise<{ attributes: { errors: ServiceLocationErrors } } | SyntheticsMonitor> => {
return await apiService.post(API_URLS.SYNTHETICS_MONITORS, monitor);
};
export const updateMonitorAPI = async ({
monitor,
id,
}: {
monitor: SyntheticsMonitor | EncryptedSyntheticsMonitor;
id: string;
}): Promise<{ attributes: { errors: ServiceLocationErrors } } | SyntheticsMonitorWithId> => {
return await apiService.put(`${API_URLS.SYNTHETICS_MONITORS}/${id}`, monitor);
};
export const getMonitorAPI = async ({
id,
}: {
id: string;
}): Promise<DecryptedSyntheticsMonitorSavedObject> => {
return await apiService.get(`${API_URLS.SYNTHETICS_MONITORS}/${id}`);
};

View file

@ -83,6 +83,7 @@ const Application = (props: SyntheticsAppProps) => {
triggersActionsUi: startPlugins.triggersActionsUi,
observability: startPlugins.observability,
cases: startPlugins.cases,
spaces: startPlugins.spaces,
}}
>
<Router history={appMountParameters.history}>

View file

@ -112,5 +112,6 @@ export const browserNormalizers: BrowserNormalizerMap = {
[ConfigKey.PLAYWRIGHT_OPTIONS]: getBrowserNormalizer(ConfigKey.PLAYWRIGHT_OPTIONS),
[ConfigKey.CUSTOM_HEARTBEAT_ID]: getBrowserNormalizer(ConfigKey.CUSTOM_HEARTBEAT_ID),
[ConfigKey.ORIGINAL_SPACE]: getBrowserNormalizer(ConfigKey.ORIGINAL_SPACE),
[ConfigKey.TEXT_ASSERTION]: getBrowserNormalizer(ConfigKey.TEXT_ASSERTION),
...commonNormalizers,
};

View file

@ -91,4 +91,5 @@ export const commonNormalizers: CommonNormalizerMap = {
fields?.[ConfigKey.NAMESPACE]?.value ?? DEFAULT_NAMESPACE_STRING,
[ConfigKey.REVISION]: getCommonNormalizer(ConfigKey.REVISION),
[ConfigKey.MONITOR_SOURCE_TYPE]: getCommonNormalizer(ConfigKey.MONITOR_SOURCE_TYPE),
[ConfigKey.FORM_MONITOR_TYPE]: getCommonNormalizer(ConfigKey.FORM_MONITOR_TYPE),
};

View file

@ -40,6 +40,7 @@ import { Start as InspectorPluginStart } from '@kbn/inspector-plugin/public';
import { CasesUiStart } from '@kbn/cases-plugin/public';
import { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { PLUGIN } from '../common/constants/plugin';
import { MONITORS_ROUTE } from '../common/constants/ui';
import {
@ -76,6 +77,7 @@ export interface ClientPluginsStart {
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
cases: CasesUiStart;
dataViews: DataViewsPublicPluginStart;
spaces: SpacesPluginStart;
cloud?: CloudStart;
}
@ -194,7 +196,6 @@ export class UptimePlugin
],
mount: async (params: AppMountParameters) => {
const [coreStart, corePlugins] = await core.getStartServices();
const { renderApp } = await import('./legacy_uptime/app/render_app');
return renderApp(coreStart, plugins, corePlugins, params, this.initContext.env.mode.dev);
},

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mergeWith } from 'lodash';
import { schema } from '@kbn/config-schema';
import {
SavedObjectsUpdateResponse,
@ -20,6 +20,7 @@ import {
SyntheticsMonitorWithSecrets,
SyntheticsMonitor,
ConfigKey,
FormMonitorType,
} from '../../../common/runtime_types';
import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types';
import { API_URLS } from '../../../common/constants';
@ -77,10 +78,7 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => (
);
const normalizedPreviousMonitor = normalizeSecrets(decryptedPreviousMonitor).attributes;
const editedMonitor = {
...normalizedPreviousMonitor,
...monitor,
};
const editedMonitor = mergeWith(normalizedPreviousMonitor, monitor, customizer);
const validationResult = validateMonitor(editedMonitor as MonitorFields);
@ -94,12 +92,15 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => (
revision: (previousMonitor.attributes[ConfigKey.REVISION] || 0) + 1,
};
const formattedMonitor = formatSecrets(monitorWithRevision);
const isMultiStepMonitor =
monitor.type === 'browser' &&
monitor[ConfigKey.FORM_MONITOR_TYPE] !== FormMonitorType.SINGLE;
const editedMonitorSavedObject: SavedObjectsUpdateResponse<EncryptedSyntheticsMonitor> =
await savedObjectsClient.update<MonitorFields>(
syntheticsMonitorType,
monitorId,
monitor.type === 'browser' ? { ...formattedMonitor, urls: '' } : formattedMonitor
isMultiStepMonitor ? { ...formattedMonitor, urls: '' } : formattedMonitor
);
const errors = await syncEditedMonitor({
@ -185,3 +186,10 @@ export const syncEditedMonitor = async ({
throw e;
}
};
// Ensure that METADATA is merged deeply, to protect AAD and prevent decryption errors
const customizer = (_: any, srcValue: any, key: string) => {
if (key !== ConfigKey.METADATA) {
return srcValue;
}
};

View file

@ -12,6 +12,7 @@ import {
CommonFields,
ConfigKey,
DataStream,
FormMonitorType,
HTTPAdvancedFields,
HTTPFields,
HTTPSimpleFields,
@ -75,6 +76,7 @@ describe('validateMonitor', () => {
},
],
[ConfigKey.NAMESPACE]: 'testnamespace',
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
};
testMetaData = {
is_tls_enabled: false,
@ -90,6 +92,7 @@ describe('validateMonitor', () => {
[ConfigKey.HOSTS]: 'test-hosts',
[ConfigKey.WAIT]: '',
[ConfigKey.MONITOR_TYPE]: DataStream.ICMP,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.ICMP,
};
testTLSFields = {
@ -105,6 +108,7 @@ describe('validateMonitor', () => {
...testCommonFields,
[ConfigKey.METADATA]: testMetaData,
[ConfigKey.HOSTS]: 'https://host1.com',
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.TCP,
};
testTCPAdvancedFields = {
@ -126,6 +130,7 @@ describe('validateMonitor', () => {
[ConfigKey.METADATA]: testMetaData,
[ConfigKey.MAX_REDIRECTS]: '3',
[ConfigKey.URLS]: 'https://example.com',
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.HTTP,
};
testHTTPAdvancedFields = {
@ -162,6 +167,7 @@ describe('validateMonitor', () => {
testBrowserSimpleFields = {
...testZipUrlTLSFields,
...testCommonFields,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
[ConfigKey.MONITOR_SOURCE_TYPE]: SourceType.PROJECT,
[ConfigKey.JOURNEY_ID]: '',
[ConfigKey.PROJECT_ID]: '',
@ -414,6 +420,7 @@ function getJsonPayload() {
' "response.include_body": "never",' +
' "check.response.headers": {},' +
' "response.include_headers": true,' +
' "form_monitor_type": "http",' +
' "check.response.status": [' +
' "200",' +
' "201"' +

View file

@ -72,5 +72,6 @@ export const browserFormatters: BrowserFormatMap = {
stringToObjectFormatter(fields[ConfigKey.PLAYWRIGHT_OPTIONS] || ''),
[ConfigKey.CUSTOM_HEARTBEAT_ID]: null,
[ConfigKey.ORIGINAL_SPACE]: null,
[ConfigKey.TEXT_ASSERTION]: null,
...commonFormatters,
};

View file

@ -29,6 +29,7 @@ export const commonFormatters: CommonFormatMap = {
[ConfigKey.REVISION]: null,
[ConfigKey.MONITOR_SOURCE_TYPE]: (fields) =>
fields[ConfigKey.MONITOR_SOURCE_TYPE] || SourceType.UI,
[ConfigKey.FORM_MONITOR_TYPE]: null,
};
export const arrayFormatter = (value: string[] = []) => (value.length ? value : null);

View file

@ -25,6 +25,8 @@ const UI_KEYS_TO_SKIP = [
ConfigKey.IS_THROTTLING_ENABLED,
ConfigKey.REVISION,
ConfigKey.CUSTOM_HEARTBEAT_ID,
ConfigKey.FORM_MONITOR_TYPE,
ConfigKey.TEXT_ASSERTION,
'secrets',
];

View file

@ -11,6 +11,7 @@ import {
BrowserFields,
ConfigKey,
DataStream,
FormMonitorType,
Locations,
ProjectBrowserMonitor,
ScheduleUnit,
@ -58,6 +59,7 @@ export const normalizeProjectMonitor = ({
const defaultFields = DEFAULT_FIELDS[DataStream.BROWSER];
const normalizedFields: NormalizedPublicFields = {
[ConfigKey.MONITOR_TYPE]: DataStream.BROWSER,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
[ConfigKey.MONITOR_SOURCE_TYPE]: SourceType.PROJECT,
[ConfigKey.NAME]: monitor.name || '',
[ConfigKey.SCHEDULE]: {

View file

@ -34,7 +34,7 @@ export default function ({ getService }: FtrProviderContext) {
before(async () => {
await supertestAPI.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200);
await supertestAPI
.post('/api/fleet/epm/packages/synthetics/0.9.4')
.post('/api/fleet/epm/packages/synthetics/0.10.2')
.set('kbn-xsrf', 'true')
.send({ force: true })
.expect(200);
@ -115,7 +115,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(packagePolicy.policy_id).eql(testFleetPolicyID);
comparePolicies(packagePolicy, getTestSyntheticsPolicy(httpMonitorJson.name));
comparePolicies(packagePolicy, getTestSyntheticsPolicy(httpMonitorJson.name, newMonitorId));
});
let testFleetPolicyID2: string;
@ -154,7 +154,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(packagePolicy.policy_id).eql(testFleetPolicyID);
comparePolicies(packagePolicy, getTestSyntheticsPolicy(httpMonitorJson.name));
comparePolicies(packagePolicy, getTestSyntheticsPolicy(httpMonitorJson.name, newMonitorId));
packagePolicy = apiResponsePolicy.body.items.find(
(pkgPolicy: PackagePolicy) =>
@ -162,7 +162,10 @@ export default function ({ getService }: FtrProviderContext) {
);
expect(packagePolicy.policy_id).eql(testFleetPolicyID2);
comparePolicies(packagePolicy, getTestSyntheticsPolicy(httpMonitorJson.name));
comparePolicies(
packagePolicy,
getTestSyntheticsPolicy(httpMonitorJson.name, newMonitorId, 'Test private location 1')
);
});
it('deletes integration for a removed location from monitor', async () => {
@ -187,7 +190,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(packagePolicy.policy_id).eql(testFleetPolicyID);
comparePolicies(packagePolicy, getTestSyntheticsPolicy(httpMonitorJson.name));
comparePolicies(packagePolicy, getTestSyntheticsPolicy(httpMonitorJson.name, newMonitorId));
packagePolicy = apiResponsePolicy.body.items.find(
(pkgPolicy: PackagePolicy) =>
@ -274,7 +277,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(packagePolicy.policy_id).eql(testFleetPolicyID);
expect(packagePolicy.name).eql(`${monitor.name}-Test private location 0-${SPACE_ID}`);
comparePolicies(packagePolicy, getTestSyntheticsPolicy(monitor.name));
comparePolicies(packagePolicy, getTestSyntheticsPolicy(monitor.name, monitorId));
} finally {
await security.user.delete(username);
await security.role.delete(roleName);

View file

@ -74,7 +74,7 @@ export default function ({ getService }: FtrProviderContext) {
before(async () => {
await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200);
await supertest
.post('/api/fleet/epm/packages/synthetics/0.9.4')
.post('/api/fleet/epm/packages/synthetics/0.10.2')
.set('kbn-xsrf', 'true')
.send({ force: true })
.expect(200);
@ -1011,7 +1011,18 @@ export default function ({ getService }: FtrProviderContext) {
);
expect(packagePolicy.policy_id).eql(testPolicyId);
comparePolicies(packagePolicy, getTestProjectSyntheticsPolicy());
const configId = monitorsResponse.body.monitors[0].id;
const id = monitorsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID];
comparePolicies(
packagePolicy,
getTestProjectSyntheticsPolicy({
inputs: {},
name: 'check if title is present-Test private location 0',
id,
configId,
})
);
} finally {
await deleteMonitor(projectMonitors.monitors[0].id, projectMonitors.project);
@ -1056,7 +1067,18 @@ export default function ({ getService }: FtrProviderContext) {
expect(packagePolicy.policy_id).eql(testPolicyId);
comparePolicies(packagePolicy, getTestProjectSyntheticsPolicy());
const configId = monitorsResponse.body.monitors[0].id;
const id = monitorsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID];
comparePolicies(
packagePolicy,
getTestProjectSyntheticsPolicy({
inputs: {},
name: 'check if title is present-Test private location 0',
id,
configId,
})
);
await supertest
.put(API_URLS.SYNTHETICS_MONITORS_PROJECT)
@ -1124,7 +1146,18 @@ export default function ({ getService }: FtrProviderContext) {
expect(packagePolicy.policy_id).eql(testPolicyId);
comparePolicies(packagePolicy, getTestProjectSyntheticsPolicy());
const configId = monitorsResponse.body.monitors[0].id;
const id = monitorsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID];
comparePolicies(
packagePolicy,
getTestProjectSyntheticsPolicy({
inputs: {},
name: 'check if title is present-Test private location 0',
id,
configId,
})
);
await supertest
.put(API_URLS.SYNTHETICS_MONITORS_PROJECT)
@ -1199,17 +1232,25 @@ export default function ({ getService }: FtrProviderContext) {
'/api/fleet/package_policies?page=1&perPage=2000&kuery=ingest-package-policies.package.name%3A%20synthetics'
);
const configId = monitorsResponse.body.monitors[0].id;
const id = monitorsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID];
const policyId = `${id}-${testPolicyId}`;
const packagePolicy = apiResponsePolicy.body.items.find(
(pkgPolicy: PackagePolicy) =>
pkgPolicy.id ===
monitorsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] +
'-' +
testPolicyId
(pkgPolicy: PackagePolicy) => pkgPolicy.id === policyId
);
expect(packagePolicy.policy_id).eql(testPolicyId);
comparePolicies(packagePolicy, getTestProjectSyntheticsPolicy());
comparePolicies(
packagePolicy,
getTestProjectSyntheticsPolicy({
inputs: {},
name: 'check if title is present-Test private location 0',
id,
configId,
})
);
await supertest
.put(API_URLS.SYNTHETICS_MONITORS_PROJECT)
@ -1229,18 +1270,21 @@ export default function ({ getService }: FtrProviderContext) {
'/api/fleet/package_policies?page=1&perPage=2000&kuery=ingest-package-policies.package.name%3A%20synthetics'
);
const configId2 = monitorsResponse.body.monitors[0].id;
const id2 = monitorsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID];
const policyId2 = `${id}-${testPolicyId}`;
const packagePolicy2 = apiResponsePolicy2.body.items.find(
(pkgPolicy: PackagePolicy) =>
pkgPolicy.id ===
monitorsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] +
'-' +
testPolicyId
(pkgPolicy: PackagePolicy) => pkgPolicy.id === policyId2
);
comparePolicies(
packagePolicy2,
getTestProjectSyntheticsPolicy({
inputs: { enabled: { value: false, type: 'bool' } },
name: 'check if title is present-Test private location 0',
id: id2,
configId: configId2,
})
);
} finally {

View file

@ -41,7 +41,7 @@ export default function ({ getService }: FtrProviderContext) {
_httpMonitorJson = getFixtureJson('http_monitor');
await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200);
await supertest
.post('/api/fleet/epm/packages/synthetics/0.9.4')
.post('/api/fleet/epm/packages/synthetics/0.10.2')
.set('kbn-xsrf', 'true')
.send({ force: true })
.expect(200);

View file

@ -66,9 +66,37 @@ export default function ({ getService }: FtrProviderContext) {
const updates: Partial<HTTPFields> = {
[ConfigKey.URLS]: 'https://modified-host.com',
[ConfigKey.NAME]: 'Modified name',
[ConfigKey.LOCATIONS]: [
{
id: 'eu-west-01',
label: 'Europe West',
geo: {
lat: 33.2343132435,
lon: 73.2342343434,
},
url: 'https://example-url.com',
isServiceManaged: true,
},
],
[ConfigKey.REQUEST_HEADERS_CHECK]: {
sampleHeader2: 'sampleValue2',
},
[ConfigKey.METADATA]: {
script_source: {
is_generated_script: false,
file_name: 'test-file.name',
},
},
};
const modifiedMonitor = { ...newMonitor, ...updates };
const modifiedMonitor = {
...newMonitor,
...updates,
[ConfigKey.METADATA]: {
...newMonitor[ConfigKey.METADATA],
...updates[ConfigKey.METADATA],
},
};
const editResponse = await supertest
.put(API_URLS.SYNTHETICS_MONITORS + '/' + monitorId)

View file

@ -45,6 +45,7 @@
"name": "Test HTTP Monitor 03",
"namespace": "testnamespace",
"origin": "ui",
"form_monitor_type": "multistep",
"urls": "",
"url.port": null
}

View file

@ -27,7 +27,9 @@
"check.response.body.negative": [],
"check.response.body.positive": [],
"response.include_body": "never",
"check.response.headers": {},
"check.request.headers": {
"sampleHeader": "sampleHeaderValue"
},
"response.include_headers": true,
"check.response.status": [
"200",
@ -37,7 +39,7 @@
"value": "testValue",
"type": "json"
},
"check.request.headers": {},
"check.response.headers": {},
"check.request.method": "",
"username": "test-username",
"ssl.certificate_authorities": "t.string",
@ -74,5 +76,6 @@
],
"namespace": "testnamespace",
"revision": 1,
"origin": "ui"
"origin": "ui",
"form_monitor_type": "http"
}

View file

@ -34,5 +34,6 @@
],
"name": "Test HTTP Monitor 04",
"namespace": "testnamespace",
"origin": "ui"
"origin": "ui",
"form_monitor_type": "icmp"
}

View file

@ -30,5 +30,6 @@
],
"name": "Test HTTP Monitor 04",
"namespace": "testnamespace",
"origin": "ui"
"origin": "ui",
"form_monitor_type": "tcp"
}

View file

@ -9,14 +9,18 @@ import { omit, sortBy } from 'lodash';
import expect from '@kbn/expect';
import { PackagePolicy } from '@kbn/fleet-plugin/common';
export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
id: '5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
version: 'WzMyNTcsMV0=',
name: '5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
export const getTestSyntheticsPolicy = (
name: string,
id: string,
locationName?: string
): PackagePolicy => ({
id: '2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default',
version: 'WzE2MjYsMV0=',
name: 'test-monitor-name-Test private location 0-default',
namespace: 'default',
package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.9.4' },
package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.10.2' },
enabled: true,
policy_id: '5347cd10-0368-11ed-8df7-a7424c6f5167',
policy_id: '27337270-22ed-11ed-8c6b-09a2d21dfbc3',
output_id: '',
inputs: [
{
@ -48,7 +52,10 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'response.include_headers': { value: true, type: 'bool' },
'response.include_body': { value: 'never', type: 'text' },
'check.request.method': { value: '', type: 'text' },
'check.request.headers': { value: null, type: 'yaml' },
'check.request.headers': {
value: '{"sampleHeader":"sampleHeaderValue"}',
type: 'yaml',
},
'check.request.body': { value: '"testValue"', type: 'yaml' },
'check.response.status': { value: '["200","201"]', type: 'yaml' },
'check.response.headers': { value: null, type: 'yaml' },
@ -60,8 +67,13 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'ssl.key_passphrase': { value: 't.string', type: 'text' },
'ssl.verification_mode': { value: 'certificate', type: 'text' },
'ssl.supported_protocols': { value: '["TLSv1.1","TLSv1.2"]', type: 'yaml' },
location_name: { value: locationName || 'Test private location 0', type: 'text' },
id: { value: id, type: 'text' },
config_id: { value: id, type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { value: 'ui', type: 'text' },
},
id: 'synthetics/http-http-5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
id: 'synthetics/http-http-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default',
compiled_stream: {
__ui: {
is_tls_enabled: false,
@ -70,6 +82,8 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
},
type: 'http',
name,
id,
origin: 'ui',
enabled: true,
urls: 'https://nextjs-test-synthetics.vercel.app/api/users',
schedule: '@every 5m',
@ -82,6 +96,7 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'response.include_headers': true,
'response.include_body': 'never',
'check.request.method': null,
'check.request.headers': { sampleHeader: 'sampleHeaderValue' },
'check.request.body': 'testValue',
'check.response.status': ['200', '201'],
'ssl.certificate': 't.string',
@ -91,8 +106,18 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'ssl.verification_mode': 'certificate',
'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2'],
processors: [
{ add_observer_metadata: { geo: { name: 'Fleet managed' } } },
{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } },
{
add_observer_metadata: { geo: { name: locationName || 'Test private location 0' } },
},
{
add_fields: {
target: '',
fields: {
'monitor.fleet_managed': true,
config_id: id,
},
},
},
],
},
},
@ -126,8 +151,13 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'ssl.key_passphrase': { type: 'text' },
'ssl.verification_mode': { type: 'text' },
'ssl.supported_protocols': { type: 'yaml' },
location_name: { value: 'Fleet managed', type: 'text' },
id: { type: 'text' },
config_id: { type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { type: 'text' },
},
id: 'synthetics/tcp-tcp-5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
id: 'synthetics/tcp-tcp-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default',
},
],
},
@ -150,8 +180,13 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'service.name': { type: 'text' },
timeout: { type: 'text' },
tags: { type: 'yaml' },
location_name: { value: 'Fleet managed', type: 'text' },
id: { type: 'text' },
config_id: { type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { type: 'text' },
},
id: 'synthetics/icmp-icmp-5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
id: 'synthetics/icmp-icmp-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default',
},
],
},
@ -177,7 +212,9 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'source.zip_url.folder': { type: 'text' },
'source.zip_url.password': { type: 'password' },
'source.inline.script': { type: 'yaml' },
'source.project.content': { type: 'text' },
params: { type: 'yaml' },
playwright_options: { type: 'yaml' },
screenshots: { type: 'text' },
synthetics_args: { type: 'text' },
ignore_https_errors: { type: 'bool' },
@ -191,8 +228,15 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
'source.zip_url.ssl.verification_mode': { type: 'text' },
'source.zip_url.ssl.supported_protocols': { type: 'yaml' },
'source.zip_url.proxy_url': { type: 'text' },
location_name: { value: 'Fleet managed', type: 'text' },
id: { type: 'text' },
config_id: { type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { type: 'text' },
'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' },
},
id: 'synthetics/browser-browser-5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
id: 'synthetics/browser-browser-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default',
compiled_stream: {
__ui: null,
type: 'browser',
@ -210,7 +254,7 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
{
enabled: true,
data_stream: { type: 'synthetics', dataset: 'browser.network' },
id: 'synthetics/browser-browser.network-5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
id: 'synthetics/browser-browser.network-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default',
compiled_stream: {
processors: [
{ add_observer_metadata: { geo: { name: 'Fleet managed' } } },
@ -221,7 +265,7 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
{
enabled: true,
data_stream: { type: 'synthetics', dataset: 'browser.screenshot' },
id: 'synthetics/browser-browser.screenshot-5863efe0-0368-11ed-8df7-a7424c6f5167-5347cd10-0368-11ed-8df7-a7424c6f5167',
id: 'synthetics/browser-browser.screenshot-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default',
compiled_stream: {
processors: [
{ add_observer_metadata: { geo: { name: 'Fleet managed' } } },
@ -234,9 +278,9 @@ export const getTestSyntheticsPolicy = (name: string): PackagePolicy => ({
],
is_managed: true,
revision: 1,
created_at: '2022-07-14T11:30:23.034Z',
created_at: '2022-08-23T14:09:17.176Z',
created_by: 'system',
updated_at: '2022-07-14T11:30:23.034Z',
updated_at: '2022-08-23T14:09:17.176Z',
updated_by: 'system',
});
@ -244,21 +288,27 @@ export const getTestProjectSyntheticsPolicy = (
{
name,
inputs = {},
configId,
id,
}: {
name?: string;
inputs: Record<string, { value: string | boolean; type: string }>;
configId: string;
id: string;
} = {
name: 'check if title is present-Test private location 0',
inputs: {},
configId: '',
id: '',
}
): PackagePolicy => ({
id: 'cccec568-e488-4049-a399-def8b6a31f34-test-suite-default-46034710-0ba6-11ed-ba04-5f123b9faa8b',
version: 'WzMwNTMsMV0=',
name: 'check if title is present-Test private location 0',
id: '4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
version: 'WzEzMDksMV0=',
name: '4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-Test private location 0',
namespace: 'default',
package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.9.4' },
package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.10.2' },
enabled: true,
policy_id: '46034710-0ba6-11ed-ba04-5f123b9faa8b',
policy_id: 'd70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
output_id: '',
inputs: [
{
@ -298,8 +348,13 @@ export const getTestProjectSyntheticsPolicy = (
'ssl.key_passphrase': { type: 'text' },
'ssl.verification_mode': { type: 'text' },
'ssl.supported_protocols': { type: 'yaml' },
location_name: { value: 'Fleet managed', type: 'text' },
id: { type: 'text' },
config_id: { type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { type: 'text' },
},
id: 'synthetics/http-http-cccec568-e488-4049-a399-def8b6a31f34-test-suite-default-46034710-0ba6-11ed-ba04-5f123b9faa8b',
id: 'synthetics/http-http-4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
},
],
},
@ -331,8 +386,13 @@ export const getTestProjectSyntheticsPolicy = (
'ssl.key_passphrase': { type: 'text' },
'ssl.verification_mode': { type: 'text' },
'ssl.supported_protocols': { type: 'yaml' },
location_name: { value: 'Fleet managed', type: 'text' },
id: { type: 'text' },
config_id: { type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { type: 'text' },
},
id: 'synthetics/tcp-tcp-cccec568-e488-4049-a399-def8b6a31f34-test-suite-default-46034710-0ba6-11ed-ba04-5f123b9faa8b',
id: 'synthetics/tcp-tcp-4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
},
],
},
@ -355,8 +415,13 @@ export const getTestProjectSyntheticsPolicy = (
'service.name': { type: 'text' },
timeout: { type: 'text' },
tags: { type: 'yaml' },
location_name: { value: 'Fleet managed', type: 'text' },
id: { type: 'text' },
config_id: { type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { type: 'text' },
},
id: 'synthetics/icmp-icmp-cccec568-e488-4049-a399-def8b6a31f34-test-suite-default-46034710-0ba6-11ed-ba04-5f123b9faa8b',
id: 'synthetics/icmp-icmp-4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
},
],
},
@ -386,7 +451,16 @@ export const getTestProjectSyntheticsPolicy = (
'source.zip_url.folder': { value: '', type: 'text' },
'source.zip_url.password': { value: '', type: 'password' },
'source.inline.script': { value: null, type: 'yaml' },
'source.project.content': {
value:
'UEsDBBQACAAIAON5qVQAAAAAAAAAAAAAAAAfAAAAZXhhbXBsZXMvdG9kb3MvYmFzaWMuam91cm5leS50c22Q0WrDMAxF3/sVF7MHB0LMXlc6RvcN+wDPVWNviW0sdUsp/fe5SSiD7UFCWFfHujIGlpnkybwxFTZfoY/E3hsaLEtwhs9RPNWKDU12zAOxkXRIbN4tB9d9pFOJdO6EN2HMqQguWN9asFBuQVMmJ7jiWNII9fIXrbabdUYr58l9IhwhQQZCYORCTFFUC31Btj21NRc7Mq4Nds+4bDD/pNVgT9F52Jyr2Fa+g75LAPttg8yErk+S9ELpTmVotlVwnfNCuh2lepl3+JflUmSBJ3uggt1v9INW/lHNLKze9dJe1J3QJK8pSvWkm6aTtCet5puq+x63+AFQSwcIAPQ3VfcAAACcAQAAUEsBAi0DFAAIAAgA43mpVAD0N1X3AAAAnAEAAB8AAAAAAAAAAAAgAKSBAAAAAGV4YW1wbGVzL3RvZG9zL2Jhc2ljLmpvdXJuZXkudHNQSwUGAAAAAAEAAQBNAAAARAEAAAAA',
type: 'text',
},
params: { value: '', type: 'yaml' },
playwright_options: {
value: '{"headless":true,"chromiumSandbox":false}',
type: 'yaml',
},
screenshots: { value: 'on', type: 'text' },
synthetics_args: { value: null, type: 'text' },
ignore_https_errors: { value: false, type: 'bool' },
@ -400,9 +474,16 @@ export const getTestProjectSyntheticsPolicy = (
'source.zip_url.ssl.verification_mode': { value: null, type: 'text' },
'source.zip_url.ssl.supported_protocols': { value: null, type: 'yaml' },
'source.zip_url.proxy_url': { value: '', type: 'text' },
location_name: { value: 'Test private location 0', type: 'text' },
id: { value: id, type: 'text' },
config_id: { value: configId, type: 'text' },
run_once: { value: false, type: 'bool' },
origin: { value: 'project', type: 'text' },
'monitor.project.id': { value: 'test-suite', type: 'text' },
'monitor.project.name': { value: 'test-suite', type: 'text' },
...inputs,
},
id: 'synthetics/browser-browser-cccec568-e488-4049-a399-def8b6a31f34-test-suite-default-46034710-0ba6-11ed-ba04-5f123b9faa8b',
id: 'synthetics/browser-browser-4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
compiled_stream: {
__ui: {
script_source: { is_generated_script: false, file_name: '' },
@ -410,15 +491,30 @@ export const getTestProjectSyntheticsPolicy = (
},
type: 'browser',
name: 'check if title is present',
id,
origin: 'project',
enabled: true,
schedule: '@every 10m',
timeout: null,
throttling: '5d/3u/20l',
'source.project.content':
'UEsDBBQACAAIAON5qVQAAAAAAAAAAAAAAAAfAAAAZXhhbXBsZXMvdG9kb3MvYmFzaWMuam91cm5leS50c22Q0WrDMAxF3/sVF7MHB0LMXlc6RvcN+wDPVWNviW0sdUsp/fe5SSiD7UFCWFfHujIGlpnkybwxFTZfoY/E3hsaLEtwhs9RPNWKDU12zAOxkXRIbN4tB9d9pFOJdO6EN2HMqQguWN9asFBuQVMmJ7jiWNII9fIXrbabdUYr58l9IhwhQQZCYORCTFFUC31Btj21NRc7Mq4Nds+4bDD/pNVgT9F52Jyr2Fa+g75LAPttg8yErk+S9ELpTmVotlVwnfNCuh2lepl3+JflUmSBJ3uggt1v9INW/lHNLKze9dJe1J3QJK8pSvWkm6aTtCet5puq+x63+AFQSwcIAPQ3VfcAAACcAQAAUEsBAi0DFAAIAAgA43mpVAD0N1X3AAAAnAEAAB8AAAAAAAAAAAAgAKSBAAAAAGV4YW1wbGVzL3RvZG9zL2Jhc2ljLmpvdXJuZXkudHNQSwUGAAAAAAEAAQBNAAAARAEAAAAA',
playwright_options: { headless: true, chromiumSandbox: false },
screenshots: 'on',
'filter_journeys.match': 'check if title is present',
processors: [
{ add_observer_metadata: { geo: { name: 'Fleet managed' } } },
{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } },
{ add_observer_metadata: { geo: { name: 'Test private location 0' } } },
{
add_fields: {
target: '',
fields: {
'monitor.fleet_managed': true,
config_id: configId,
'monitor.project.name': 'test-suite',
'monitor.project.id': 'test-suite',
},
},
},
],
...Object.keys(inputs).reduce((acc: Record<string, unknown>, key) => {
acc[key] = inputs[key].value;
@ -429,7 +525,7 @@ export const getTestProjectSyntheticsPolicy = (
{
enabled: true,
data_stream: { type: 'synthetics', dataset: 'browser.network' },
id: 'synthetics/browser-browser.network-cccec568-e488-4049-a399-def8b6a31f34-test-suite-default-46034710-0ba6-11ed-ba04-5f123b9faa8b',
id: 'synthetics/browser-browser.network-4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
compiled_stream: {
processors: [
{ add_observer_metadata: { geo: { name: 'Fleet managed' } } },
@ -440,7 +536,7 @@ export const getTestProjectSyntheticsPolicy = (
{
enabled: true,
data_stream: { type: 'synthetics', dataset: 'browser.screenshot' },
id: 'synthetics/browser-browser.screenshot-cccec568-e488-4049-a399-def8b6a31f34-test-suite-default-46034710-0ba6-11ed-ba04-5f123b9faa8b',
id: 'synthetics/browser-browser.screenshot-4b6abc6c-118b-4d93-a489-1135500d09f1-test-suite-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3',
compiled_stream: {
processors: [
{ add_observer_metadata: { geo: { name: 'Fleet managed' } } },
@ -453,9 +549,9 @@ export const getTestProjectSyntheticsPolicy = (
],
is_managed: true,
revision: 1,
created_at: '2022-07-24T23:13:55.606Z',
created_at: '2022-08-23T13:52:42.531Z',
created_by: 'system',
updated_at: '2022-07-24T23:13:55.606Z',
updated_at: '2022-08-23T13:52:42.531Z',
updated_by: 'system',
});