diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index 6d4d107adb796..aa36a3a7562bf 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -14,7 +14,7 @@ import type { GetDataStreamsResponse } from '../../../common'; import { getPackageSavedObjects } from '../../services/epm/packages/get'; import { defaultIngestErrorHandler } from '../../errors'; -const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*'; +const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*,synthetics-*-*'; interface ESDataStreamInfo { name: string; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 6237951805547..deb2da8dee553 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -745,7 +745,13 @@ class AgentPolicyService { cluster: ['monitor'], indices: [ { - names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + names: [ + 'logs-*', + 'metrics-*', + 'traces-*', + '.logs-endpoint.diagnostic.collection-*', + 'synthetics-*', + ], privileges: ['auto_configure', 'create_doc'], }, ], diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index ae3e2eb8c270d..528db7f4dec53 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -47,7 +47,7 @@ export type HasData = ( export type ObservabilityFetchDataPlugins = Exclude< ObservabilityApp, - 'observability-overview' | 'stack_monitoring' + 'observability-overview' | 'stack_monitoring' | 'fleet' >; export interface DataHandler< diff --git a/x-pack/plugins/observability/typings/common.ts b/x-pack/plugins/observability/typings/common.ts index 81477d0a7f815..d6209c737a468 100644 --- a/x-pack/plugins/observability/typings/common.ts +++ b/x-pack/plugins/observability/typings/common.ts @@ -14,7 +14,8 @@ export type ObservabilityApp = | 'synthetics' | 'observability-overview' | 'stack_monitoring' - | 'ux'; + | 'ux' + | 'fleet'; export type PromiseReturnType = Func extends (...args: any[]) => Promise ? Value diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 4ba836c1e5d26..0d2346f59b0a1 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -9,7 +9,8 @@ "data", "home", "observability", - "ml" + "ml", + "fleet" ], "requiredPlugins": [ "alerting", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index a578fced134e8..c6a08e84c6da9 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -27,9 +27,14 @@ import { DataPublicPluginStart, } from '../../../../../src/plugins/data/public'; import { alertTypeInitializers } from '../lib/alert_types'; +import { FleetStart } from '../../../fleet/public'; import { FetchDataParams, ObservabilityPublicSetup } from '../../../observability/public'; import { PLUGIN } from '../../common/constants/plugin'; import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public'; +import { + LazySyntheticsPolicyCreateExtension, + LazySyntheticsPolicyEditExtension, +} from '../components/fleet_package'; export interface ClientPluginsSetup { data: DataPublicPluginSetup; @@ -42,6 +47,7 @@ export interface ClientPluginsStart { embeddable: EmbeddableStart; data: DataPublicPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + fleet?: FleetStart; } export interface UptimePluginServices extends Partial { @@ -143,6 +149,22 @@ export class UptimePlugin plugins.triggersActionsUi.alertTypeRegistry.register(alertInitializer); } }); + + if (plugins.fleet) { + const { registerExtension } = plugins.fleet; + + registerExtension({ + package: 'synthetics', + view: 'package-policy-create', + component: LazySyntheticsPolicyCreateExtension, + }); + + registerExtension({ + package: 'synthetics', + view: 'package-policy-edit', + component: LazySyntheticsPolicyEditExtension, + }); + } } public stop(): void {} diff --git a/x-pack/plugins/uptime/public/components/fleet_package/combo_box.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/combo_box.test.tsx new file mode 100644 index 0000000000000..932bce9328d4c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/combo_box.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '../../lib/helper/rtl_helpers'; +import { ComboBox } from './combo_box'; + +describe('', () => { + const onChange = jest.fn(); + const selectedOptions: string[] = []; + + it('renders ComboBox', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('syntheticsFleetComboBox')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/combo_box.tsx b/x-pack/plugins/uptime/public/components/fleet_package/combo_box.tsx new file mode 100644 index 0000000000000..12ee154dbcac4 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/combo_box.tsx @@ -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 React, { useState, useCallback } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +export interface Props { + onChange: (value: string[]) => void; + selectedOptions: string[]; +} + +export const ComboBox = ({ onChange, selectedOptions }: Props) => { + const [formattedSelectedOptions, setSelectedOptions] = useState< + Array> + >(selectedOptions.map((option) => ({ label: option, key: option }))); + const [isInvalid, setInvalid] = useState(false); + + const onOptionsChange = useCallback( + (options: Array>) => { + 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 ( + + data-test-subj="syntheticsFleetComboBox" + noSuggestions + selectedOptions={formattedSelectedOptions} + onCreateOption={onCreateOption} + onChange={onOptionsChange} + onSearchChange={onSearchChange} + isInvalid={isInvalid} + /> + ); +}; + +const isValid = (value: string) => { + // Ensure that the tag is more than whitespace + return value.match(/\S+/) !== null; +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx new file mode 100644 index 0000000000000..c257a8f71b77a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx @@ -0,0 +1,69 @@ +/* + * 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, { createContext, useContext, useMemo, useState } from 'react'; +import { + IHTTPAdvancedFields, + ConfigKeys, + Mode, + ResponseBodyIndexPolicy, + HTTPMethod, +} from '../types'; + +interface IHTTPAdvancedFieldsContext { + setFields: React.Dispatch>; + fields: IHTTPAdvancedFields; + defaultValues: IHTTPAdvancedFields; +} + +interface IHTTPAdvancedFieldsContextProvider { + children: React.ReactNode; + defaultValues?: IHTTPAdvancedFields; +} + +export const initialValues = { + [ConfigKeys.PASSWORD]: '', + [ConfigKeys.PROXY_URL]: '', + [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: [], + [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: [], + [ConfigKeys.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicy.ON_ERROR, + [ConfigKeys.RESPONSE_HEADERS_CHECK]: {}, + [ConfigKeys.RESPONSE_HEADERS_INDEX]: true, + [ConfigKeys.RESPONSE_STATUS_CHECK]: [], + [ConfigKeys.REQUEST_BODY_CHECK]: { + value: '', + type: Mode.TEXT, + }, + [ConfigKeys.REQUEST_HEADERS_CHECK]: {}, + [ConfigKeys.REQUEST_METHOD_CHECK]: HTTPMethod.GET, + [ConfigKeys.USERNAME]: '', +}; + +export const defaultContext: IHTTPAdvancedFieldsContext = { + setFields: (_fields: React.SetStateAction) => { + throw new Error('setFields was not initialized, set it when you invoke the context'); + }, + fields: initialValues, + defaultValues: initialValues, +}; + +export const HTTPAdvancedFieldsContext = createContext(defaultContext); + +export const HTTPAdvancedFieldsContextProvider = ({ + children, + defaultValues = initialValues, +}: IHTTPAdvancedFieldsContextProvider) => { + const [fields, setFields] = useState(defaultValues); + + const value = useMemo(() => { + return { fields, setFields, defaultValues }; + }, [fields, defaultValues]); + + return ; +}; + +export const useHTTPAdvancedFieldsContext = () => useContext(HTTPAdvancedFieldsContext); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx new file mode 100644 index 0000000000000..6e4f46111c283 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx @@ -0,0 +1,52 @@ +/* + * 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, { createContext, useContext, useMemo, useState } from 'react'; +import { ITCPAdvancedFields, ConfigKeys } from '../types'; + +interface ITCPAdvancedFieldsContext { + setFields: React.Dispatch>; + fields: ITCPAdvancedFields; + defaultValues: ITCPAdvancedFields; +} + +interface ITCPAdvancedFieldsContextProvider { + children: React.ReactNode; + defaultValues?: ITCPAdvancedFields; +} + +export const initialValues = { + [ConfigKeys.PROXY_URL]: '', + [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: false, + [ConfigKeys.RESPONSE_RECEIVE_CHECK]: '', + [ConfigKeys.REQUEST_SEND_CHECK]: '', +}; + +const defaultContext: ITCPAdvancedFieldsContext = { + setFields: (_fields: React.SetStateAction) => { + throw new Error('setFields was not initialized, set it when you invoke the context'); + }, + fields: initialValues, // mutable + defaultValues: initialValues, // immutable +}; + +export const TCPAdvancedFieldsContext = createContext(defaultContext); + +export const TCPAdvancedFieldsContextProvider = ({ + children, + defaultValues = initialValues, +}: ITCPAdvancedFieldsContextProvider) => { + const [fields, setFields] = useState(defaultValues); + + const value = useMemo(() => { + return { fields, setFields, defaultValues }; + }, [fields, defaultValues]); + + return ; +}; + +export const useTCPAdvancedFieldsContext = () => useContext(TCPAdvancedFieldsContext); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts new file mode 100644 index 0000000000000..bea3e9d5641a5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts @@ -0,0 +1,31 @@ +/* + * 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 { + SimpleFieldsContext, + SimpleFieldsContextProvider, + initialValues as defaultSimpleFields, + useSimpleFieldsContext, +} from './simple_fields_context'; +export { + TCPAdvancedFieldsContext, + TCPAdvancedFieldsContextProvider, + initialValues as defaultTCPAdvancedFields, + useTCPAdvancedFieldsContext, +} from './advanced_fields_tcp_context'; +export { + HTTPAdvancedFieldsContext, + HTTPAdvancedFieldsContextProvider, + initialValues as defaultHTTPAdvancedFields, + useHTTPAdvancedFieldsContext, +} from './advanced_fields_http_context'; +export { + TLSFieldsContext, + TLSFieldsContextProvider, + initialValues as defaultTLSFields, + useTLSFieldsContext, +} from './tls_fields_context'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/simple_fields_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/simple_fields_context.tsx new file mode 100644 index 0000000000000..1d981ed4c2c8f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/simple_fields_context.tsx @@ -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, { createContext, useContext, useMemo, useState } from 'react'; +import { ISimpleFields, ConfigKeys, ScheduleUnit, DataStream } from '../types'; + +interface ISimpleFieldsContext { + setFields: React.Dispatch>; + fields: ISimpleFields; + defaultValues: ISimpleFields; +} + +interface ISimpleFieldsContextProvider { + children: React.ReactNode; + defaultValues?: ISimpleFields; +} + +export const initialValues = { + [ConfigKeys.HOSTS]: '', + [ConfigKeys.MAX_REDIRECTS]: '0', + [ConfigKeys.MONITOR_TYPE]: DataStream.HTTP, + [ConfigKeys.SCHEDULE]: { + number: '3', + unit: ScheduleUnit.MINUTES, + }, + [ConfigKeys.APM_SERVICE_NAME]: '', + [ConfigKeys.TAGS]: [], + [ConfigKeys.TIMEOUT]: '16', + [ConfigKeys.URLS]: '', + [ConfigKeys.WAIT]: '1', +}; + +const defaultContext: ISimpleFieldsContext = { + setFields: (_fields: React.SetStateAction) => { + throw new Error('setSimpleFields was not initialized, set it when you invoke the context'); + }, + fields: initialValues, // mutable + defaultValues: initialValues, // immutable +}; + +export const SimpleFieldsContext = createContext(defaultContext); + +export const SimpleFieldsContextProvider = ({ + children, + defaultValues = initialValues, +}: ISimpleFieldsContextProvider) => { + const [fields, setFields] = useState(defaultValues); + + const value = useMemo(() => { + return { fields, setFields, defaultValues }; + }, [fields, defaultValues]); + + return ; +}; + +export const useSimpleFieldsContext = () => useContext(SimpleFieldsContext); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/tls_fields_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tls_fields_context.tsx new file mode 100644 index 0000000000000..eaeb995654448 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tls_fields_context.tsx @@ -0,0 +1,72 @@ +/* + * 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, { createContext, useContext, useMemo, useState } from 'react'; +import { ITLSFields, ConfigKeys, TLSVersion, VerificationMode } from '../types'; + +interface ITLSFieldsContext { + setFields: React.Dispatch>; + fields: ITLSFields; + defaultValues: ITLSFields; +} + +interface ITLSFieldsContextProvider { + children: React.ReactNode; + defaultValues?: ITLSFields; +} + +export const initialValues = { + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + value: '', + isEnabled: false, + }, + [ConfigKeys.TLS_CERTIFICATE]: { + value: '', + isEnabled: false, + }, + [ConfigKeys.TLS_KEY]: { + value: '', + isEnabled: false, + }, + [ConfigKeys.TLS_KEY_PASSPHRASE]: { + value: '', + isEnabled: false, + }, + [ConfigKeys.TLS_VERIFICATION_MODE]: { + value: VerificationMode.FULL, + isEnabled: false, + }, + [ConfigKeys.TLS_VERSION]: { + value: [TLSVersion.ONE_ONE, TLSVersion.ONE_TWO, TLSVersion.ONE_THREE], + isEnabled: false, + }, +}; + +const defaultContext: ITLSFieldsContext = { + setFields: (_fields: React.SetStateAction) => { + throw new Error('setFields was not initialized, set it when you invoke the context'); + }, + fields: initialValues, // mutable + defaultValues: initialValues, // immutable +}; + +export const TLSFieldsContext = createContext(defaultContext); + +export const TLSFieldsContextProvider = ({ + children, + defaultValues = initialValues, +}: ITLSFieldsContextProvider) => { + const [fields, setFields] = useState(defaultValues); + + const value = useMemo(() => { + return { fields, setFields, defaultValues }; + }, [fields, defaultValues]); + + return ; +}; + +export const useTLSFieldsContext = () => useContext(TLSFieldsContext); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx new file mode 100644 index 0000000000000..b5fec58d4da85 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx @@ -0,0 +1,247 @@ +/* + * 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 '../../lib/helper/rtl_helpers'; +import { + SimpleFieldsContextProvider, + HTTPAdvancedFieldsContextProvider, + TCPAdvancedFieldsContextProvider, + TLSFieldsContextProvider, + defaultSimpleFields, + defaultTLSFields, + defaultHTTPAdvancedFields, + defaultTCPAdvancedFields, +} from './contexts'; +import { CustomFields } from './custom_fields'; +import { ConfigKeys, DataStream, ScheduleUnit } from './types'; +import { validate as centralValidation } from './validation'; + +// ensures that fields appropriately match to their label +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +const defaultValidation = centralValidation[DataStream.HTTP]; + +const defaultConfig = { + ...defaultSimpleFields, + ...defaultTLSFields, + ...defaultHTTPAdvancedFields, + ...defaultTCPAdvancedFields, +}; + +describe('', () => { + const WrappedComponent = ({ validate = defaultValidation, typeEditable = false }) => { + return ( + + + + + + + + + + ); + }; + + it('renders CustomFields', async () => { + const { getByText, getByLabelText, queryByLabelText } = render(); + const monitorType = queryByLabelText('Monitor Type') as HTMLInputElement; + const url = getByLabelText('URL') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + expect(monitorType).not.toBeInTheDocument(); + expect(url).toBeInTheDocument(); + expect(url.value).toEqual(defaultConfig[ConfigKeys.URLS]); + expect(proxyUrl).toBeInTheDocument(); + expect(proxyUrl.value).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect(monitorIntervalNumber).toBeInTheDocument(); + expect(monitorIntervalNumber.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].number); + expect(monitorIntervalUnit).toBeInTheDocument(); + expect(monitorIntervalUnit.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].unit); + // expect(tags).toBeInTheDocument(); + expect(apmServiceName).toBeInTheDocument(); + expect(apmServiceName.value).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect(maxRedirects).toBeInTheDocument(); + expect(maxRedirects.value).toEqual(`${defaultConfig[ConfigKeys.MAX_REDIRECTS]}`); + expect(timeout).toBeInTheDocument(); + expect(timeout.value).toEqual(`${defaultConfig[ConfigKeys.TIMEOUT]}`); + + // ensure other monitor type options are not in the DOM + expect(queryByLabelText('Host')).not.toBeInTheDocument(); + expect(queryByLabelText('Wait in seconds')).not.toBeInTheDocument(); + + // ensure at least one http advanced option is present + const advancedOptionsButton = getByText('Advanced HTTP options'); + fireEvent.click(advancedOptionsButton); + await waitFor(() => { + expect(getByLabelText('Request method')).toBeInTheDocument(); + }); + }); + + it('shows SSL fields when Enable SSL Fields is checked', async () => { + const { findByLabelText, queryByLabelText } = render(); + const enableSSL = queryByLabelText('Enable TLS configuration') as HTMLInputElement; + expect(queryByLabelText('Certificate authorities')).not.toBeInTheDocument(); + expect(queryByLabelText('Client key')).not.toBeInTheDocument(); + expect(queryByLabelText('Client certificate')).not.toBeInTheDocument(); + expect(queryByLabelText('Client key passphrase')).not.toBeInTheDocument(); + expect(queryByLabelText('Verification mode')).not.toBeInTheDocument(); + + // ensure at least one http advanced option is present + fireEvent.click(enableSSL); + + const ca = (await findByLabelText('Certificate authorities')) as HTMLInputElement; + const clientKey = (await findByLabelText('Client key')) as HTMLInputElement; + const clientKeyPassphrase = (await findByLabelText( + 'Client key passphrase' + )) as HTMLInputElement; + const clientCertificate = (await findByLabelText('Client certificate')) as HTMLInputElement; + const verificationMode = (await findByLabelText('Verification mode')) as HTMLInputElement; + expect(ca).toBeInTheDocument(); + expect(clientKey).toBeInTheDocument(); + expect(clientKeyPassphrase).toBeInTheDocument(); + expect(clientCertificate).toBeInTheDocument(); + expect(verificationMode).toBeInTheDocument(); + + await waitFor(() => { + expect(ca.value).toEqual(defaultConfig[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES].value); + expect(clientKey.value).toEqual(defaultConfig[ConfigKeys.TLS_KEY].value); + expect(clientKeyPassphrase.value).toEqual(defaultConfig[ConfigKeys.TLS_KEY_PASSPHRASE].value); + expect(clientCertificate.value).toEqual(defaultConfig[ConfigKeys.TLS_CERTIFICATE].value); + expect(verificationMode.value).toEqual(defaultConfig[ConfigKeys.TLS_VERIFICATION_MODE].value); + }); + }); + + it('handles updating each field (besides TLS)', async () => { + const { getByLabelText } = render(); + const url = getByLabelText('URL') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + fireEvent.change(proxyUrl, { target: { value: 'http://proxy.co' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(monitorIntervalUnit, { target: { value: ScheduleUnit.MINUTES } }); + fireEvent.change(apmServiceName, { target: { value: 'APM Service' } }); + fireEvent.change(maxRedirects, { target: { value: '2' } }); + fireEvent.change(timeout, { target: { value: '3' } }); + + expect(url.value).toEqual('http://elastic.co'); + expect(proxyUrl.value).toEqual('http://proxy.co'); + expect(monitorIntervalNumber.value).toEqual('1'); + expect(monitorIntervalUnit.value).toEqual(ScheduleUnit.MINUTES); + expect(apmServiceName.value).toEqual('APM Service'); + expect(maxRedirects.value).toEqual('2'); + expect(timeout.value).toEqual('3'); + }); + + it('handles switching monitor type', () => { + const { getByText, getByLabelText, queryByLabelText } = render( + + ); + const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; + expect(monitorType).toBeInTheDocument(); + expect(monitorType.value).toEqual(defaultConfig[ConfigKeys.MONITOR_TYPE]); + fireEvent.change(monitorType, { target: { value: DataStream.TCP } }); + + // expect tcp fields to be in the DOM + const host = getByLabelText('Host:Port') as HTMLInputElement; + + expect(host).toBeInTheDocument(); + expect(host.value).toEqual(defaultConfig[ConfigKeys.HOSTS]); + + // expect HTTP fields not to be in the DOM + expect(queryByLabelText('URL')).not.toBeInTheDocument(); + expect(queryByLabelText('Max redirects')).not.toBeInTheDocument(); + + // ensure at least one tcp advanced option is present + const advancedOptionsButton = getByText('Advanced TCP options'); + fireEvent.click(advancedOptionsButton); + + expect(queryByLabelText('Request method')).not.toBeInTheDocument(); + expect(getByLabelText('Request payload')).toBeInTheDocument(); + + fireEvent.change(monitorType, { target: { value: DataStream.ICMP } }); + + // expect ICMP fields to be in the DOM + expect(getByLabelText('Wait in seconds')).toBeInTheDocument(); + + // expect TCP fields not to be in the DOM + expect(queryByLabelText('Proxy URL')).not.toBeInTheDocument(); + }); + + it('shows resolve hostnames locally field when proxy url is filled for tcp monitors', () => { + const { getByLabelText, queryByLabelText } = render(); + const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; + fireEvent.change(monitorType, { target: { value: DataStream.TCP } }); + + expect(queryByLabelText('Resolve hostnames locally')).not.toBeInTheDocument(); + + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + + fireEvent.change(proxyUrl, { target: { value: 'sampleProxyUrl' } }); + + expect(getByLabelText('Resolve hostnames locally')).toBeInTheDocument(); + }); + + it('handles validation', () => { + const { getByText, getByLabelText, queryByText } = render(); + + const url = getByLabelText('URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(maxRedirects, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + + const urlError = getByText('URL is required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const maxRedirectsError = getByText('Max redirects must be 0 or greater'); + const timeoutError = getByText('Timeout must be 0 or greater and less than schedule interval'); + + expect(urlError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(maxRedirectsError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + + // resolve errors + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(maxRedirects, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + + expect(queryByText('URL is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); + expect( + queryByText('Timeout must be 0 or greater and less than schedule interval') + ).not.toBeInTheDocument(); + + // create more errors + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); // 1 minute + fireEvent.change(timeout, { target: { value: '61' } }); // timeout cannot be more than monitor interval + + const timeoutError2 = getByText('Timeout must be 0 or greater and less than schedule interval'); + + expect(timeoutError2).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx new file mode 100644 index 0000000000000..1dbd37dc00803 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx @@ -0,0 +1,416 @@ +/* + * 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, memo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiFieldNumber, + EuiSelect, + EuiSpacer, + EuiDescribedFormGroup, + EuiCheckbox, +} from '@elastic/eui'; +import { ConfigKeys, DataStream, ISimpleFields, Validation } from './types'; +import { useSimpleFieldsContext } from './contexts'; +import { TLSFields, TLSRole } from './tls_fields'; +import { ComboBox } from './combo_box'; +import { OptionalLabel } from './optional_label'; +import { HTTPAdvancedFields } from './http_advanced_fields'; +import { TCPAdvancedFields } from './tcp_advanced_fields'; +import { ScheduleField } from './schedule_field'; + +interface Props { + typeEditable?: boolean; + isTLSEnabled?: boolean; + validate: Validation; +} + +export const CustomFields = memo( + ({ typeEditable = false, isTLSEnabled: defaultIsTLSEnabled = false, validate }) => { + const [isTLSEnabled, setIsTLSEnabled] = useState(defaultIsTLSEnabled); + const { fields, setFields, defaultValues } = useSimpleFieldsContext(); + const { type } = fields; + + const isHTTP = fields[ConfigKeys.MONITOR_TYPE] === DataStream.HTTP; + const isTCP = fields[ConfigKeys.MONITOR_TYPE] === DataStream.TCP; + const isICMP = fields[ConfigKeys.MONITOR_TYPE] === DataStream.ICMP; + + // reset monitor type specific fields any time a monitor type is switched + useEffect(() => { + if (typeEditable) { + setFields((prevFields: ISimpleFields) => ({ + ...prevFields, + [ConfigKeys.HOSTS]: defaultValues[ConfigKeys.HOSTS], + [ConfigKeys.URLS]: defaultValues[ConfigKeys.URLS], + })); + } + }, [defaultValues, type, typeEditable, setFields]); + + const handleInputChange = ({ value, configKey }: { value: unknown; configKey: ConfigKeys }) => { + setFields((prevFields) => ({ ...prevFields, [configKey]: value })); + }; + + return ( + + + + + } + description={ + + } + > + + + {typeEditable && ( + + } + isInvalid={!!validate[ConfigKeys.MONITOR_TYPE]?.(fields[ConfigKeys.MONITOR_TYPE])} + error={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.MONITOR_TYPE, + }) + } + /> + + )} + {isHTTP && ( + + } + isInvalid={!!validate[ConfigKeys.URLS]?.(fields[ConfigKeys.URLS])} + error={ + + } + > + + handleInputChange({ value: event.target.value, configKey: ConfigKeys.URLS }) + } + /> + + )} + {isTCP && ( + + } + isInvalid={!!validate[ConfigKeys.HOSTS]?.(fields[ConfigKeys.HOSTS])} + error={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.HOSTS, + }) + } + /> + + )} + {isICMP && ( + + } + isInvalid={!!validate[ConfigKeys.HOSTS]?.(fields[ConfigKeys.HOSTS])} + error={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.HOSTS, + }) + } + /> + + )} + + } + isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields[ConfigKeys.SCHEDULE])} + error={ + + } + > + + handleInputChange({ + value: schedule, + configKey: ConfigKeys.SCHEDULE, + }) + } + number={fields[ConfigKeys.SCHEDULE].number} + unit={fields[ConfigKeys.SCHEDULE].unit} + /> + + {isICMP && ( + + } + isInvalid={!!validate[ConfigKeys.WAIT]?.(fields[ConfigKeys.WAIT])} + error={ + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ value: event.target.value, configKey: ConfigKeys.WAIT }) + } + step={'any'} + /> + + )} + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.APM_SERVICE_NAME, + }) + } + /> + + {isHTTP && ( + + } + isInvalid={ + !!validate[ConfigKeys.MAX_REDIRECTS]?.(fields[ConfigKeys.MAX_REDIRECTS]) + } + error={ + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.MAX_REDIRECTS, + }) + } + /> + + )} + + } + isInvalid={ + !!validate[ConfigKeys.TIMEOUT]?.( + fields[ConfigKeys.TIMEOUT], + fields[ConfigKeys.SCHEDULE].number, + fields[ConfigKeys.SCHEDULE].unit + ) + } + error={ + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.TIMEOUT, + }) + } + step={'any'} + /> + + + } + labelAppend={} + helpText={ + + } + > + handleInputChange({ value, configKey: ConfigKeys.TAGS })} + /> + + + + + {(isHTTP || isTCP) && ( + + + + } + description={ + + } + > + + } + onChange={(event) => setIsTLSEnabled(event.target.checked)} + /> + + + )} + + {isHTTP && } + {isTCP && } + + ); + } +); + +const dataStreamOptions = [ + { value: DataStream.HTTP, text: 'HTTP' }, + { value: DataStream.TCP, text: 'TCP' }, + { value: DataStream.ICMP, text: 'ICMP' }, +]; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx new file mode 100644 index 0000000000000..ee33083b3eae9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 '../../lib/helper/rtl_helpers'; +import { HeaderField, contentTypes } from './header_field'; +import { Mode } from './types'; + +describe('', () => { + const onChange = jest.fn(); + const defaultValue = {}; + + it('renders HeaderField', () => { + const { getByText, getByTestId } = render( + + ); + + 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('formats headers and handles onChange', async () => { + const { getByTestId, getByText } = render( + + ); + 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( + + ); + 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.TEXT; + render( + + ); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ + 'Content-Type': contentTypes[Mode.TEXT], + }); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx new file mode 100644 index 0000000000000..9f337d4b00704 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { 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; + onChange: (value: Record) => void; +} + +export const HeaderField = ({ contentMode, defaultValue, onChange }: 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((key) => { + return [key || '', defaultValue[key] || '']; // key, value + }), + ]; + const [headers, setHeaders] = useState(formattedDefaultValues); + + useEffect(() => { + const formattedHeaders = headers.reduce((acc: Record, 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 ( + + } + defaultPairs={headers} + onChange={setHeaders} + /> + ); +}; + +export const contentTypes: Record = { + [Mode.JSON]: ContentType.JSON, + [Mode.TEXT]: ContentType.TEXT, + [Mode.XML]: ContentType.XML, + [Mode.FORM]: ContentType.FORM, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http_advanced_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/http_advanced_fields.test.tsx new file mode 100644 index 0000000000000..b1a37be1bffb6 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/http_advanced_fields.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 } from '@testing-library/react'; +import { render } from '../../lib/helper/rtl_helpers'; +import { HTTPAdvancedFields } from './http_advanced_fields'; +import { ConfigKeys, DataStream, HTTPMethod, IHTTPAdvancedFields, Validation } from './types'; +import { + HTTPAdvancedFieldsContextProvider, + defaultHTTPAdvancedFields as defaultConfig, +} from './contexts'; +import { validate as centralValidation } from './validation'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +const defaultValidation = centralValidation[DataStream.HTTP]; + +describe('', () => { + const WrappedComponent = ({ + defaultValues, + validate = defaultValidation, + }: { + defaultValues?: IHTTPAdvancedFields; + validate?: Validation; + }) => { + return ( + + + + ); + }; + + it('renders HTTPAdvancedFields', () => { + const { getByText, getByLabelText } = render(); + + const requestMethod = getByLabelText('Request method') as HTMLInputElement; + const requestHeaders = getByText('Request headers'); + const requestBody = getByText('Request body'); + const indexResponseBody = getByLabelText('Index response body') as HTMLInputElement; + const indexResponseBodySelect = getByLabelText( + 'Response body index policy' + ) as HTMLInputElement; + const indexResponseHeaders = getByLabelText('Index response headers') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const responseHeadersContain = getByText('Check response headers contain'); + const responseStatusEquals = getByText('Check response status equals'); + const responseBodyContains = getByText('Check response body contains'); + const responseBodyDoesNotContain = getByText('Check response body does not contain'); + const username = getByLabelText('Username') as HTMLInputElement; + const password = getByLabelText('Password') as HTMLInputElement; + expect(requestMethod).toBeInTheDocument(); + expect(requestMethod.value).toEqual(defaultConfig[ConfigKeys.REQUEST_METHOD_CHECK]); + expect(requestHeaders).toBeInTheDocument(); + expect(requestBody).toBeInTheDocument(); + expect(indexResponseBody).toBeInTheDocument(); + expect(indexResponseBody.checked).toBe(true); + expect(indexResponseBodySelect).toBeInTheDocument(); + expect(indexResponseBodySelect.value).toEqual(defaultConfig[ConfigKeys.RESPONSE_BODY_INDEX]); + expect(indexResponseHeaders).toBeInTheDocument(); + expect(indexResponseHeaders.checked).toBe(true); + expect(proxyUrl).toBeInTheDocument(); + expect(proxyUrl.value).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect(responseStatusEquals).toBeInTheDocument(); + expect(responseBodyContains).toBeInTheDocument(); + expect(responseBodyDoesNotContain).toBeInTheDocument(); + expect(responseHeadersContain).toBeInTheDocument(); + expect(username).toBeInTheDocument(); + expect(username.value).toBe(defaultConfig[ConfigKeys.USERNAME]); + expect(password).toBeInTheDocument(); + expect(password.value).toBe(defaultConfig[ConfigKeys.PASSWORD]); + }); + + it('handles changing fields', () => { + const { getByText, getByLabelText } = render(); + + const username = getByLabelText('Username') as HTMLInputElement; + const password = getByLabelText('Password') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const requestMethod = getByLabelText('Request method') as HTMLInputElement; + const requestHeaders = getByText('Request headers'); + const indexResponseBody = getByLabelText('Index response body') as HTMLInputElement; + const indexResponseHeaders = getByLabelText('Index response headers') as HTMLInputElement; + + fireEvent.change(username, { target: { value: 'username' } }); + fireEvent.change(password, { target: { value: 'password' } }); + fireEvent.change(proxyUrl, { target: { value: 'proxyUrl' } }); + fireEvent.change(requestMethod, { target: { value: HTTPMethod.POST } }); + fireEvent.click(indexResponseBody); + fireEvent.click(indexResponseHeaders); + + expect(username.value).toEqual('username'); + expect(password.value).toEqual('password'); + expect(proxyUrl.value).toEqual('proxyUrl'); + expect(requestMethod.value).toEqual(HTTPMethod.POST); + expect(requestHeaders).toBeInTheDocument(); + expect(indexResponseBody.checked).toBe(false); + expect(indexResponseHeaders.checked).toBe(false); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http_advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/http_advanced_fields.tsx new file mode 100644 index 0000000000000..5cc1dd12ef961 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/http_advanced_fields.tsx @@ -0,0 +1,476 @@ +/* + * 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, memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiAccordion, + EuiCode, + EuiFieldText, + EuiFormRow, + EuiSelect, + EuiDescribedFormGroup, + EuiCheckbox, + EuiSpacer, +} from '@elastic/eui'; + +import { useHTTPAdvancedFieldsContext } from './contexts'; + +import { ConfigKeys, HTTPMethod, Validation } from './types'; + +import { OptionalLabel } from './optional_label'; +import { HeaderField } from './header_field'; +import { RequestBodyField } from './request_body_field'; +import { ResponseBodyIndexField } from './index_response_body_field'; +import { ComboBox } from './combo_box'; + +interface Props { + validate: Validation; +} + +export const HTTPAdvancedFields = memo(({ validate }) => { + const { fields, setFields } = useHTTPAdvancedFieldsContext(); + const handleInputChange = useCallback( + ({ value, configKey }: { value: unknown; configKey: ConfigKeys }) => { + setFields((prevFields) => ({ ...prevFields, [configKey]: value })); + }, + [setFields] + ); + + return ( + + } + > + + + + + } + description={ + + } + > + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.USERNAME, + }) + } + /> + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.PASSWORD, + }) + } + /> + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.PROXY_URL, + }) + } + /> + + + } + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.REQUEST_METHOD_CHECK, + }) + } + /> + + + } + labelAppend={} + isInvalid={ + !!validate[ConfigKeys.REQUEST_HEADERS_CHECK]?.(fields[ConfigKeys.REQUEST_HEADERS_CHECK]) + } + error={ + !!validate[ConfigKeys.REQUEST_HEADERS_CHECK]?.( + fields[ConfigKeys.REQUEST_HEADERS_CHECK] + ) ? ( + + ) : undefined + } + helpText={ + + } + > + + handleInputChange({ + value, + configKey: ConfigKeys.REQUEST_HEADERS_CHECK, + }), + [handleInputChange] + )} + /> + + + } + labelAppend={} + helpText={ + + } + fullWidth + > + + handleInputChange({ + value, + configKey: ConfigKeys.REQUEST_BODY_CHECK, + }), + [handleInputChange] + )} + /> + + + + + + + } + description={ + + } + > + + + + http.response.body.headers + + } + > + + } + onChange={(event) => + handleInputChange({ + value: event.target.checked, + configKey: ConfigKeys.RESPONSE_HEADERS_INDEX, + }) + } + /> + + + + http.response.body.contents + + } + > + + handleInputChange({ value: policy, configKey: ConfigKeys.RESPONSE_BODY_INDEX }), + [handleInputChange] + )} + /> + + + + + + } + description={ + + } + > + + } + labelAppend={} + isInvalid={ + !!validate[ConfigKeys.RESPONSE_STATUS_CHECK]?.(fields[ConfigKeys.RESPONSE_STATUS_CHECK]) + } + error={ + + } + helpText={i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.httpAdvancedOptions.responseChecks.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.', + } + )} + > + + handleInputChange({ + value, + configKey: ConfigKeys.RESPONSE_STATUS_CHECK, + }) + } + /> + + + } + labelAppend={} + isInvalid={ + !!validate[ConfigKeys.RESPONSE_HEADERS_CHECK]?.( + fields[ConfigKeys.RESPONSE_HEADERS_CHECK] + ) + } + error={ + !!validate[ConfigKeys.RESPONSE_HEADERS_CHECK]?.( + fields[ConfigKeys.RESPONSE_HEADERS_CHECK] + ) + ? [ + , + ] + : undefined + } + helpText={ + + } + > + + handleInputChange({ + value, + configKey: ConfigKeys.RESPONSE_HEADERS_CHECK, + }), + [handleInputChange] + )} + /> + + + } + labelAppend={} + helpText={i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.httpAdvancedOptions.responseBodyCheckPositive.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.', + } + )} + > + + handleInputChange({ + value, + configKey: ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE, + }), + [handleInputChange] + )} + /> + + + } + labelAppend={} + helpText={i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.httpAdvancedOptions.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.', + } + )} + > + + handleInputChange({ + value, + configKey: ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE, + }), + [handleInputChange] + )} + /> + + + + ); +}); + +const requestMethodOptions = Object.values(HTTPMethod).map((method) => ({ + value: method, + text: method, +})); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/index.tsx b/x-pack/plugins/uptime/public/components/fleet_package/index.tsx new file mode 100644 index 0000000000000..47fd04e3fb71d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/index.tsx @@ -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 { LazySyntheticsPolicyCreateExtension } from './lazy_synthetics_policy_create_extension'; +export { LazySyntheticsPolicyEditExtension } from './lazy_synthetics_policy_edit_extension'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.test.tsx new file mode 100644 index 0000000000000..53a96c5ec1c73 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.test.tsx @@ -0,0 +1,97 @@ +/* + * 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 '../../lib/helper/rtl_helpers'; +import { ResponseBodyIndexField } from './index_response_body_field'; +import { ResponseBodyIndexPolicy } from './types'; + +describe('', () => { + const defaultDefaultValue = ResponseBodyIndexPolicy.ON_ERROR; + const onChange = jest.fn(); + const WrappedComponent = ({ defaultValue = defaultDefaultValue }) => { + return ; + }; + + it('renders ResponseBodyIndexField', () => { + const { getByText, getByTestId } = render(); + 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(); + 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('handles checkbox change', async () => { + const { getByTestId, getByLabelText } = render(); + 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( + + ); + 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); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.tsx new file mode 100644 index 0000000000000..a82e7a0938078 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.tsx @@ -0,0 +1,98 @@ +/* + * 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; +} + +export const ResponseBodyIndexField = ({ defaultValue, onChange }: Props) => { + const [policy, setPolicy] = useState( + defaultValue !== ResponseBodyIndexPolicy.NEVER ? defaultValue : ResponseBodyIndexPolicy.ON_ERROR + ); + const [checked, setChecked] = useState(defaultValue !== ResponseBodyIndexPolicy.NEVER); + + useEffect(() => { + if (checked) { + setPolicy(policy); + onChange(policy); + } else { + onChange(ResponseBodyIndexPolicy.NEVER); + } + }, [checked, policy, setPolicy, onChange]); + + useEffect(() => { + onChange(policy); + }, [onChange, policy]); + + return ( + + + + } + onChange={(event) => { + const checkedEvent = event.target.checked; + setChecked(checkedEvent); + }} + /> + + {checked && ( + + { + setPolicy(event.target.value as ResponseBodyIndexPolicy); + }} + /> + + )} + + ); +}; + +const responseBodyIndexPolicyOptions = [ + { + value: ResponseBodyIndexPolicy.ALWAYS, + text: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.responseBodyIndex.always', + { + defaultMessage: 'Always', + } + ), + }, + { + value: ResponseBodyIndexPolicy.ON_ERROR, + text: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.responseBodyIndex.onError', + { + defaultMessage: 'On error', + } + ), + }, +]; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/key_value_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/key_value_field.test.tsx new file mode 100644 index 0000000000000..b0143ab976722 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/key_value_field.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../lib/helper/rtl_helpers'; +import { KeyValuePairsField, Pair } from './key_value_field'; + +describe('', () => { + const onChange = jest.fn(); + const defaultDefaultValue = [['', '']] as Pair[]; + const WrappedComponent = ({ + defaultValue = defaultDefaultValue, + addPairControlLabel = 'Add pair', + }) => { + return ( + + ); + }; + + it('renders KeyValuePairsField', () => { + const { getByText } = render(); + expect(getByText('Key')).toBeInTheDocument(); + expect(getByText('Value')).toBeInTheDocument(); + + expect(getByText('Add pair')).toBeInTheDocument(); + }); + + it('handles adding and editing a new row', async () => { + const { getByTestId, queryByTestId, getByText } = render( + + ); + + 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]]); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/key_value_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/key_value_field.tsx new file mode 100644 index 0000000000000..5391233698950 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/key_value_field.tsx @@ -0,0 +1,181 @@ +/* + * 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; +} + +export const KeyValuePairsField = ({ addPairControlLabel, defaultPairs, onChange }: Props) => { + const [pairs, setPairs] = useState(defaultPairs); + + const handleOnChange = useCallback( + (event: React.ChangeEvent, 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 ( + <> + + + + + {addPairControlLabel} + + + + + + + { + + } + + + { + + } + + + ), + } + : undefined + } + > + {pairs.map((pair, index) => { + const [key, value] = pair; + return ( + + + + handleDeletePair(index)} + /> + + } + startControl={ + handleOnChange(event, index, true)} + /> + } + endControl={ + handleOnChange(event, index, false)} + /> + } + delimiter=":" + /> + + + ); + })} + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/lazy_synthetics_policy_create_extension.tsx b/x-pack/plugins/uptime/public/components/fleet_package/lazy_synthetics_policy_create_extension.tsx new file mode 100644 index 0000000000000..ec7266acca989 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/lazy_synthetics_policy_create_extension.tsx @@ -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 { lazy } from 'react'; +import { PackagePolicyCreateExtensionComponent } from '../../../../fleet/public'; + +export const LazySyntheticsPolicyCreateExtension = lazy( + async () => { + const { SyntheticsPolicyCreateExtensionWrapper } = await import( + './synthetics_policy_create_extension_wrapper' + ); + return { + default: SyntheticsPolicyCreateExtensionWrapper, + }; + } +); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/lazy_synthetics_policy_edit_extension.tsx b/x-pack/plugins/uptime/public/components/fleet_package/lazy_synthetics_policy_edit_extension.tsx new file mode 100644 index 0000000000000..e7b0564ad4cc3 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/lazy_synthetics_policy_edit_extension.tsx @@ -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 { lazy } from 'react'; +import { PackagePolicyEditExtensionComponent } from '../../../../fleet/public'; + +export const LazySyntheticsPolicyEditExtension = lazy( + async () => { + const { SyntheticsPolicyEditExtensionWrapper } = await import( + './synthetics_policy_edit_extension_wrapper' + ); + return { + default: SyntheticsPolicyEditExtensionWrapper, + }; + } +); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/optional_label.tsx b/x-pack/plugins/uptime/public/components/fleet_package/optional_label.tsx new file mode 100644 index 0000000000000..6f207d3ccd208 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/optional_label.tsx @@ -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 ( + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx new file mode 100644 index 0000000000000..849809eae52a4 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../lib/helper/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()}`, +})); + +describe('', () => { + const defaultMode = Mode.TEXT; + const defaultValue = 'sample value'; + const WrappedComponent = () => { + const [config, setConfig] = useState({ + type: defaultMode, + value: defaultValue, + }); + + return ( + setConfig({ type: code.type as Mode, value: code.value }), [ + setConfig, + ])} + /> + ); + }; + + it('renders RequestBodyField', () => { + const { getByText, getByLabelText } = render(); + + 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( + + ); + + // 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(); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx new file mode 100644 index 0000000000000..0b6faefd7aa62 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx @@ -0,0 +1,243 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { stringify, parse } from 'query-string'; + +import styled from 'styled-components'; + +import { EuiCodeEditor, EuiPanel, EuiTabbedContent } from '@elastic/eui'; + +import { Mode } from './types'; + +import { KeyValuePairsField, Pair } from './key_value_field'; + +import 'brace/theme/github'; +import 'brace/mode/xml'; +import 'brace/mode/json'; +import 'brace/ext/language_tools'; + +const CodeEditorContainer = styled(EuiPanel)` + padding: 0; +`; + +enum ResponseBodyType { + CODE = 'code', + FORM = 'form', +} + +const CodeEditor = ({ + ariaLabel, + id, + mode, + onChange, + value, +}: { + ariaLabel: string; + id: string; + mode: Mode; + onChange: (value: string) => void; + value: string; +}) => { + return ( + +
+ +
+
+ ); +}; + +interface Props { + onChange: (requestBody: { type: Mode; value: string }) => void; + type: Mode; + value: string; +} + +// TO DO: Look into whether or not code editor reports errors, in order to prevent form submission on an error +export const RequestBodyField = ({ onChange, type, value }: Props) => { + const [values, setValues] = useState>({ + [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, 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.TEXT, + name: modeLabels[Mode.TEXT], + content: ( + + setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) + } + value={values[ResponseBodyType.CODE]} + /> + ), + }, + { + id: Mode.JSON, + name: modeLabels[Mode.JSON], + content: ( + + setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) + } + value={values[ResponseBodyType.CODE]} + /> + ), + }, + { + id: Mode.XML, + name: modeLabels[Mode.XML], + content: ( + + setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) + } + value={values[ResponseBodyType.CODE]} + /> + ), + }, + { + id: Mode.FORM, + name: modeLabels[Mode.FORM], + content: ( + + } + defaultPairs={defaultFormPairs} + onChange={onChangeFormFields} + /> + ), + }, + ]; + + return ( + tab.id === type)} + autoFocus="selected" + onTabClick={(tab) => { + handleSetMode(tab.id as Mode); + }} + /> + ); +}; + +const modeLabels = { + [Mode.FORM]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.requestBodyType.form', + { + defaultMessage: 'Form', + } + ), + [Mode.TEXT]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.requestBodyType.text', + { + defaultMessage: 'Text', + } + ), + [Mode.JSON]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.requestBodyType.JSON', + { + defaultMessage: 'JSON', + } + ), + [Mode.XML]: i18n.translate('xpack.uptime.createPackagePolicy.stepConfigure.requestBodyType.XML', { + defaultMessage: 'XML', + }), +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.test.tsx new file mode 100644 index 0000000000000..3358d1edabcc9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../lib/helper/rtl_helpers'; +import { ScheduleField } from './schedule_field'; +import { ScheduleUnit } from './types'; + +describe('', () => { + const number = '1'; + const unit = ScheduleUnit.MINUTES; + const WrappedComponent = () => { + const [config, setConfig] = useState({ + number, + unit, + }); + + return ( + setConfig(value)} + /> + ); + }; + + it('hanles schedule', () => { + const { getByText, getByTestId } = render(); + const input = getByTestId('scheduleFieldInput') as HTMLInputElement; + const select = getByTestId('scheduleFieldSelect') as HTMLInputElement; + expect(input.value).toBe(number); + expect(select.value).toBe(unit); + expect(getByText('Minutes')).toBeInTheDocument(); + }); + + it('hanles on change', async () => { + const { getByText, getByTestId } = render(); + const input = getByTestId('scheduleFieldInput') as HTMLInputElement; + const select = getByTestId('scheduleFieldSelect') as HTMLInputElement; + const newNumber = '2'; + const newUnit = ScheduleUnit.SECONDS; + expect(input.value).toBe(number); + expect(select.value).toBe(unit); + + fireEvent.change(input, { target: { value: newNumber } }); + + await waitFor(() => { + expect(input.value).toBe(newNumber); + }); + + fireEvent.change(select, { target: { value: newUnit } }); + + await waitFor(() => { + expect(select.value).toBe(newUnit); + expect(getByText('Seconds')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.tsx new file mode 100644 index 0000000000000..047d200d0af02 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.tsx @@ -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 from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import { ConfigKeys, ICustomFields, ScheduleUnit } from './types'; + +interface Props { + number: string; + onChange: (schedule: ICustomFields[ConfigKeys.SCHEDULE]) => void; + unit: ScheduleUnit; +} + +export const ScheduleField = ({ number, onChange, unit }: Props) => { + return ( + + + { + const updatedNumber = event.target.value; + onChange({ number: updatedNumber, unit }); + }} + /> + + + { + const updatedUnit = event.target.value; + onChange({ number, unit: updatedUnit as ScheduleUnit }); + }} + /> + + + ); +}; + +const options = [ + { + text: i18n.translate('xpack.uptime.createPackagePolicy.stepConfigure.scheduleField.seconds', { + defaultMessage: 'Seconds', + }), + value: ScheduleUnit.SECONDS, + }, + { + text: i18n.translate('xpack.uptime.createPackagePolicy.stepConfigure.scheduleField.minutes', { + defaultMessage: 'Minutes', + }), + value: ScheduleUnit.MINUTES, + }, +]; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension.tsx new file mode 100644 index 0000000000000..51585e227b56e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension.tsx @@ -0,0 +1,75 @@ +/* + * 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, useContext, useEffect } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { PackagePolicyCreateExtensionComponentProps } from '../../../../fleet/public'; +import { useTrackPageview } from '../../../../observability/public'; +import { Config, ConfigKeys } from './types'; +import { + SimpleFieldsContext, + HTTPAdvancedFieldsContext, + TCPAdvancedFieldsContext, + TLSFieldsContext, +} from './contexts'; +import { CustomFields } from './custom_fields'; +import { useUpdatePolicy } from './use_update_policy'; +import { validate } from './validation'; + +/** + * Exports Synthetics-specific package policy instructions + * for use in the Ingest app create / edit package policy + */ +export const SyntheticsPolicyCreateExtension = memo( + ({ newPolicy, onChange }) => { + const { fields: simpleFields } = useContext(SimpleFieldsContext); + const { fields: httpAdvancedFields } = useContext(HTTPAdvancedFieldsContext); + const { fields: tcpAdvancedFields } = useContext(TCPAdvancedFieldsContext); + const { fields: tlsFields } = useContext(TLSFieldsContext); + const defaultConfig: Config = { + name: '', + ...simpleFields, + ...httpAdvancedFields, + ...tcpAdvancedFields, + ...tlsFields, + }; + useTrackPageview({ app: 'fleet', path: 'syntheticsCreate' }); + useTrackPageview({ app: 'fleet', path: 'syntheticsCreate', delay: 15000 }); + const { config, setConfig } = useUpdatePolicy({ defaultConfig, newPolicy, onChange, validate }); + + // Fleet will initialize the create form with a default name for the integratin policy, however, + // for synthetics, we want the user to explicitely type in a name to use as the monitor name, + // so we blank it out only during 1st component render (thus why the eslint disabled rule below). + useEffect(() => { + onChange({ + isValid: false, + updatedPolicy: { + ...newPolicy, + name: '', + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useDebounce( + () => { + setConfig((prevConfig) => ({ + ...prevConfig, + ...simpleFields, + ...httpAdvancedFields, + ...tcpAdvancedFields, + ...tlsFields, + })); + }, + 250, + [setConfig, simpleFields, httpAdvancedFields, tcpAdvancedFields, tlsFields] + ); + + return ; + } +); +SyntheticsPolicyCreateExtension.displayName = 'SyntheticsPolicyCreateExtension'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.test.tsx new file mode 100644 index 0000000000000..ff05636e7774b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.test.tsx @@ -0,0 +1,739 @@ +/* + * 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 '../../lib/helper/rtl_helpers'; +import { NewPackagePolicy } from '../../../../fleet/public'; +import { + defaultSimpleFields, + defaultTLSFields, + defaultHTTPAdvancedFields, + defaultTCPAdvancedFields, +} from './contexts'; +import { SyntheticsPolicyCreateExtensionWrapper } from './synthetics_policy_create_extension_wrapper'; +import { ConfigKeys, DataStream, ScheduleUnit, VerificationMode } from './types'; + +const defaultConfig = { + ...defaultSimpleFields, + ...defaultTLSFields, + ...defaultHTTPAdvancedFields, + ...defaultTCPAdvancedFields, +}; + +// ensures that fields appropriately match to their label +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +const defaultNewPolicy: NewPackagePolicy = { + name: 'samplePolicyName', + description: '', + namespace: 'default', + policy_id: 'ae774160-8e49-11eb-aba5-99269d21ba6e', + enabled: true, + output_id: '', + inputs: [ + { + type: 'synthetics/http', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { + type: 'synthetics', + dataset: 'http', + }, + vars: { + type: { + value: 'http', + type: 'text', + }, + name: { + value: 'Sample name', + type: 'text', + }, + schedule: { + value: '"@every 5s"', + type: 'text', + }, + urls: { + value: '', + type: 'text', + }, + 'service.name': { + value: '', + type: 'text', + }, + timeout: { + value: 1600, + type: 'integer', + }, + max_redirects: { + value: 0, + type: 'integer', + }, + proxy_url: { + value: '', + type: 'text', + }, + tags: { + value: '[]', + type: 'yaml', + }, + 'response.include_headers': { + value: true, + type: 'bool', + }, + 'response.include_body': { + value: 'on_error', + type: 'text', + }, + 'check.request.method': { + value: 'GET', + type: 'text', + }, + 'check.request.headers': { + value: '{}', + type: 'yaml', + }, + 'check.request.body': { + value: '""', + type: 'yaml', + }, + 'check.response.status': { + value: '[]', + type: 'yaml', + }, + 'check.response.headers': { + value: '{}', + type: 'yaml', + }, + 'check.response.body.positive': { + value: '[]', + type: 'yaml', + }, + 'check.response.body.negative': { + value: '[]', + type: 'yaml', + }, + 'ssl.certificate_authorities': { + value: '', + type: 'yaml', + }, + 'ssl.certificate': { + value: '', + type: 'yaml', + }, + 'ssl.key': { + value: '', + type: 'yaml', + }, + 'ssl.key_passphrase': { + type: 'text', + }, + 'ssl.verification_mode': { + value: 'full', + type: 'text', + }, + }, + }, + ], + }, + { + type: 'synthetics/tcp', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'tcp', + }, + vars: { + type: { + value: 'tcp', + type: 'text', + }, + name: { + type: 'text', + }, + schedule: { + value: '10s', + type: 'text', + }, + hosts: { + type: 'text', + }, + 'service.name': { + type: 'text', + }, + timeout: { + type: 'integer', + }, + max_redirects: { + type: 'integer', + }, + proxy_url: { + type: 'text', + }, + proxy_use_local_resolver: { + value: false, + type: 'bool', + }, + tags: { + type: 'yaml', + }, + 'check.send': { + type: 'text', + }, + 'check.receive': { + type: 'yaml', + }, + 'ssl.certificate_authorities': { + type: 'yaml', + }, + 'ssl.certificate': { + type: 'yaml', + }, + 'ssl.key': { + type: 'yaml', + }, + 'ssl.key_passphrase': { + type: 'text', + }, + 'ssl.verification_mode': { + type: 'text', + }, + }, + }, + ], + }, + { + type: 'synthetics/icmp', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'icmp', + }, + vars: { + type: { + value: 'icmp', + type: 'text', + }, + name: { + type: 'text', + }, + schedule: { + value: '10s', + type: 'text', + }, + wait: { + value: '1s', + type: 'text', + }, + hosts: { + type: 'text', + }, + 'service.name': { + type: 'text', + }, + timeout: { + type: 'integer', + }, + max_redirects: { + type: 'integer', + }, + tags: { + type: 'yaml', + }, + }, + }, + ], + }, + ], + package: { + name: 'synthetics', + title: 'Elastic Synthetics', + version: '0.66.0', + }, +}; + +describe('', () => { + const onChange = jest.fn(); + const WrappedComponent = ({ newPolicy = defaultNewPolicy }) => { + return ; + }; + + it('renders SyntheticsPolicyCreateExtension', async () => { + const { getByText, getByLabelText, queryByLabelText } = render(); + const monitorType = queryByLabelText('Monitor Type') as HTMLInputElement; + const url = getByLabelText('URL') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + expect(monitorType).toBeInTheDocument(); + expect(monitorType.value).toEqual(defaultConfig[ConfigKeys.MONITOR_TYPE]); + expect(url).toBeInTheDocument(); + expect(url.value).toEqual(defaultConfig[ConfigKeys.URLS]); + expect(proxyUrl).toBeInTheDocument(); + expect(proxyUrl.value).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect(monitorIntervalNumber).toBeInTheDocument(); + expect(monitorIntervalNumber.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].number); + expect(monitorIntervalUnit).toBeInTheDocument(); + expect(monitorIntervalUnit.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].unit); + expect(apmServiceName).toBeInTheDocument(); + expect(apmServiceName.value).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect(maxRedirects).toBeInTheDocument(); + expect(maxRedirects.value).toEqual(`${defaultConfig[ConfigKeys.MAX_REDIRECTS]}`); + expect(timeout).toBeInTheDocument(); + expect(timeout.value).toEqual(`${defaultConfig[ConfigKeys.TIMEOUT]}`); + + // ensure other monitor type options are not in the DOM + expect(queryByLabelText('Host')).not.toBeInTheDocument(); + expect(queryByLabelText('Wait in seconds')).not.toBeInTheDocument(); + + // ensure at least one http advanced option is present + const advancedOptionsButton = getByText('Advanced HTTP options'); + fireEvent.click(advancedOptionsButton); + await waitFor(() => { + expect(getByLabelText('Request method')).toBeInTheDocument(); + }); + }); + + it('handles updating each field', async () => { + const { getByLabelText } = render(); + const url = getByLabelText('URL') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + fireEvent.change(proxyUrl, { target: { value: 'http://proxy.co' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(monitorIntervalUnit, { target: { value: ScheduleUnit.MINUTES } }); + fireEvent.change(apmServiceName, { target: { value: 'APM Service' } }); + fireEvent.change(maxRedirects, { target: { value: '2' } }); + fireEvent.change(timeout, { target: { value: '3' } }); + + expect(url.value).toEqual('http://elastic.co'); + expect(proxyUrl.value).toEqual('http://proxy.co'); + expect(monitorIntervalNumber.value).toEqual('1'); + expect(monitorIntervalUnit.value).toEqual(ScheduleUnit.MINUTES); + expect(apmServiceName.value).toEqual('APM Service'); + expect(maxRedirects.value).toEqual('2'); + expect(timeout.value).toEqual('3'); + }); + + it('handles calling onChange', async () => { + const { getByLabelText } = render(); + const url = getByLabelText('URL') as HTMLInputElement; + + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ + isValid: true, + updatedPolicy: { + ...defaultNewPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + streams: [ + { + ...defaultNewPolicy.inputs[0].streams[0], + vars: { + ...defaultNewPolicy.inputs[0].streams[0].vars, + urls: { + value: 'http://elastic.co', + type: 'text', + }, + }, + }, + ], + }, + defaultNewPolicy.inputs[1], + defaultNewPolicy.inputs[2], + ], + }, + }); + }); + }); + + it('handles switching monitor type', async () => { + const { getByText, getByLabelText, queryByLabelText } = render(); + const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; + expect(monitorType).toBeInTheDocument(); + expect(monitorType.value).toEqual(defaultConfig[ConfigKeys.MONITOR_TYPE]); + fireEvent.change(monitorType, { target: { value: DataStream.TCP } }); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ + isValid: false, + updatedPolicy: { + ...defaultNewPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: true, + }, + defaultNewPolicy.inputs[2], + ], + }, + }); + }); + + // expect tcp fields to be in the DOM + const host = getByLabelText('Host:Port') as HTMLInputElement; + + expect(host).toBeInTheDocument(); + expect(host.value).toEqual(defaultConfig[ConfigKeys.HOSTS]); + + // expect HTTP fields not to be in the DOM + expect(queryByLabelText('URL')).not.toBeInTheDocument(); + expect(queryByLabelText('Max redirects')).not.toBeInTheDocument(); + + // ensure at least one tcp advanced option is present + const advancedOptionsButton = getByText('Advanced TCP options'); + fireEvent.click(advancedOptionsButton); + + expect(queryByLabelText('Request method')).not.toBeInTheDocument(); + expect(getByLabelText('Request payload')).toBeInTheDocument(); + + fireEvent.change(monitorType, { target: { value: DataStream.ICMP } }); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ + isValid: false, + updatedPolicy: { + ...defaultNewPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[2], + enabled: true, + }, + ], + }, + }); + }); + + // expect ICMP fields to be in the DOM + expect(getByLabelText('Wait in seconds')).toBeInTheDocument(); + + // expect TCP fields not to be in the DOM + expect(queryByLabelText('Proxy URL')).not.toBeInTheDocument(); + }); + + it('handles http validation', async () => { + const { getByText, getByLabelText, queryByText } = render(); + + const url = getByLabelText('URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(maxRedirects, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + + const urlError = getByText('URL is required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const maxRedirectsError = getByText('Max redirects must be 0 or greater'); + const timeoutError = getByText('Timeout must be 0 or greater and less than schedule interval'); + + expect(urlError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(maxRedirectsError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + + // expect onChange to be called with isValid false + await waitFor(() => { + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + // resolve errors + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(maxRedirects, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + + // expect onChange to be called with isValid true + await waitFor(() => { + expect(queryByText('URL is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); + expect( + queryByText('Timeout must be 0 or greater and less than schedule interval') + ).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + }); + + it('handles tcp validation', async () => { + const { getByText, getByLabelText, queryByText } = render(); + + const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; + fireEvent.change(monitorType, { target: { value: DataStream.TCP } }); + + const host = getByLabelText('Host:Port') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(host, { target: { value: 'localhost' } }); // host without port + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + + await waitFor(() => { + const hostError = getByText('Host and port are required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const timeoutError = getByText( + 'Timeout must be 0 or greater and less than schedule interval' + ); + + expect(hostError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + // resolve errors + fireEvent.change(host, { target: { value: 'smtp.gmail.com:587' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + + await waitFor(() => { + expect(queryByText('Host and port are required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); + expect( + queryByText('Timeout must be 0 or greater and less than schedule interval') + ).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + }); + + it('handles icmp validation', async () => { + const { getByText, getByLabelText, queryByText } = render(); + + const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; + fireEvent.change(monitorType, { target: { value: DataStream.ICMP } }); + + const host = getByLabelText('Host') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + const wait = getByLabelText('Wait in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(host, { target: { value: '' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + fireEvent.change(wait, { target: { value: '-1' } }); + + await waitFor(() => { + const hostError = getByText('Host is required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const timeoutError = getByText( + 'Timeout must be 0 or greater and less than schedule interval' + ); + const waitError = getByText('Wait must be 0 or greater'); + + expect(hostError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + expect(waitError).toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + // resolve errors + fireEvent.change(host, { target: { value: '1.1.1.1' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + fireEvent.change(wait, { target: { value: '1' } }); + + await waitFor(() => { + expect(queryByText('Host is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect( + queryByText('Timeout must be 0 or greater and less than schedule interval') + ).not.toBeInTheDocument(); + expect(queryByText('Wait must be 0 or greater')).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + }); + + it('handles changing TLS fields', async () => { + const { findByLabelText, queryByLabelText } = render(); + const enableSSL = queryByLabelText('Enable TLS configuration') as HTMLInputElement; + + await waitFor(() => { + expect(onChange).toBeCalledWith({ + isValid: true, + updatedPolicy: { + ...defaultNewPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + streams: [ + { + ...defaultNewPolicy.inputs[0].streams[0], + vars: { + ...defaultNewPolicy.inputs[0].streams[0].vars, + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + value: null, + type: 'yaml', + }, + [ConfigKeys.TLS_CERTIFICATE]: { + value: null, + type: 'yaml', + }, + [ConfigKeys.TLS_KEY]: { + value: null, + type: 'yaml', + }, + [ConfigKeys.TLS_KEY_PASSPHRASE]: { + value: null, + type: 'text', + }, + [ConfigKeys.TLS_VERIFICATION_MODE]: { + value: null, + type: 'text', + }, + }, + }, + ], + }, + defaultNewPolicy.inputs[1], + defaultNewPolicy.inputs[2], + ], + }, + }); + }); + + // ensure at least one http advanced option is present + fireEvent.click(enableSSL); + + const ca = (await findByLabelText('Certificate authorities')) as HTMLInputElement; + const clientKey = (await findByLabelText('Client key')) as HTMLInputElement; + const clientKeyPassphrase = (await findByLabelText( + 'Client key passphrase' + )) as HTMLInputElement; + const clientCertificate = (await findByLabelText('Client certificate')) as HTMLInputElement; + const verificationMode = (await findByLabelText('Verification mode')) as HTMLInputElement; + + await waitFor(() => { + fireEvent.change(ca, { target: { value: 'certificateAuthorities' } }); + expect(ca.value).toEqual(defaultConfig[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES].value); + }); + await waitFor(() => { + fireEvent.change(clientCertificate, { target: { value: 'clientCertificate' } }); + expect(clientCertificate.value).toEqual(defaultConfig[ConfigKeys.TLS_KEY].value); + }); + await waitFor(() => { + fireEvent.change(clientKey, { target: { value: 'clientKey' } }); + expect(clientKey.value).toEqual(defaultConfig[ConfigKeys.TLS_KEY].value); + }); + await waitFor(() => { + fireEvent.change(clientKeyPassphrase, { target: { value: 'clientKeyPassphrase' } }); + expect(clientKeyPassphrase.value).toEqual(defaultConfig[ConfigKeys.TLS_KEY_PASSPHRASE].value); + }); + await waitFor(() => { + fireEvent.change(verificationMode, { target: { value: VerificationMode.NONE } }); + expect(verificationMode.value).toEqual(defaultConfig[ConfigKeys.TLS_VERIFICATION_MODE].value); + }); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ + isValid: true, + updatedPolicy: { + ...defaultNewPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + streams: [ + { + ...defaultNewPolicy.inputs[0].streams[0], + vars: { + ...defaultNewPolicy.inputs[0].streams[0].vars, + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + value: '"certificateAuthorities"', + type: 'yaml', + }, + [ConfigKeys.TLS_CERTIFICATE]: { + value: '"clientCertificate"', + type: 'yaml', + }, + [ConfigKeys.TLS_KEY]: { + value: '"clientKey"', + type: 'yaml', + }, + [ConfigKeys.TLS_KEY_PASSPHRASE]: { + value: 'clientKeyPassphrase', + type: 'text', + }, + [ConfigKeys.TLS_VERIFICATION_MODE]: { + value: VerificationMode.NONE, + type: 'text', + }, + }, + }, + ], + }, + defaultNewPolicy.inputs[1], + defaultNewPolicy.inputs[2], + ], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.tsx new file mode 100644 index 0000000000000..688ee24bd2330 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.tsx @@ -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, { memo } from 'react'; +import { PackagePolicyCreateExtensionComponentProps } from '../../../../fleet/public'; +import { SyntheticsPolicyCreateExtension } from './synthetics_policy_create_extension'; +import { + SimpleFieldsContextProvider, + HTTPAdvancedFieldsContextProvider, + TCPAdvancedFieldsContextProvider, + TLSFieldsContextProvider, +} from './contexts'; + +/** + * Exports Synthetics-specific package policy instructions + * for use in the Ingest app create / edit package policy + */ +export const SyntheticsPolicyCreateExtensionWrapper = memo( + ({ newPolicy, onChange }) => { + return ( + + + + + + + + + + ); + } +); +SyntheticsPolicyCreateExtensionWrapper.displayName = 'SyntheticsPolicyCreateExtensionWrapper'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx new file mode 100644 index 0000000000000..386d99add87b6 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useContext } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { PackagePolicyEditExtensionComponentProps } from '../../../../fleet/public'; +import { useTrackPageview } from '../../../../observability/public'; +import { + SimpleFieldsContext, + HTTPAdvancedFieldsContext, + TCPAdvancedFieldsContext, + TLSFieldsContext, +} from './contexts'; +import { Config, ConfigKeys } from './types'; +import { CustomFields } from './custom_fields'; +import { useUpdatePolicy } from './use_update_policy'; +import { validate } from './validation'; + +interface SyntheticsPolicyEditExtensionProps { + newPolicy: PackagePolicyEditExtensionComponentProps['newPolicy']; + onChange: PackagePolicyEditExtensionComponentProps['onChange']; + defaultConfig: Config; + isTLSEnabled: boolean; +} +/** + * Exports Synthetics-specific package policy instructions + * for use in the Fleet app create / edit package policy + */ +export const SyntheticsPolicyEditExtension = memo( + ({ newPolicy, onChange, defaultConfig, isTLSEnabled }) => { + useTrackPageview({ app: 'fleet', path: 'syntheticsEdit' }); + useTrackPageview({ app: 'fleet', path: 'syntheticsEdit', delay: 15000 }); + const { fields: simpleFields } = useContext(SimpleFieldsContext); + const { fields: httpAdvancedFields } = useContext(HTTPAdvancedFieldsContext); + const { fields: tcpAdvancedFields } = useContext(TCPAdvancedFieldsContext); + const { fields: tlsFields } = useContext(TLSFieldsContext); + const { config, setConfig } = useUpdatePolicy({ defaultConfig, newPolicy, onChange, validate }); + + useDebounce( + () => { + setConfig((prevConfig) => ({ + ...prevConfig, + ...simpleFields, + ...httpAdvancedFields, + ...tcpAdvancedFields, + ...tlsFields, + })); + }, + 250, + [setConfig, simpleFields, httpAdvancedFields, tcpAdvancedFields, tlsFields] + ); + + return ( + + ); + } +); +SyntheticsPolicyEditExtension.displayName = 'SyntheticsPolicyEditExtension'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx new file mode 100644 index 0000000000000..03e0b338dfd72 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx @@ -0,0 +1,803 @@ +/* + * 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 '../../lib/helper/rtl_helpers'; +import { NewPackagePolicy } from '../../../../fleet/public'; +import { SyntheticsPolicyEditExtensionWrapper } from './synthetics_policy_edit_extension_wrapper'; +import { ConfigKeys, DataStream, ScheduleUnit } from './types'; +import { + defaultSimpleFields, + defaultTLSFields, + defaultHTTPAdvancedFields, + defaultTCPAdvancedFields, +} from './contexts'; + +// ensures that fields appropriately match to their label +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +const defaultConfig = { + ...defaultSimpleFields, + ...defaultTLSFields, + ...defaultHTTPAdvancedFields, + ...defaultTCPAdvancedFields, +}; + +const defaultNewPolicy: NewPackagePolicy = { + name: 'samplePolicyName', + description: '', + namespace: 'default', + policy_id: 'ae774160-8e49-11eb-aba5-99269d21ba6e', + enabled: true, + output_id: '', + inputs: [ + { + type: 'synthetics/http', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { + type: 'synthetics', + dataset: 'http', + }, + vars: { + type: { + value: 'http', + type: 'text', + }, + name: { + value: 'Sample name', + type: 'text', + }, + schedule: { + value: '"@every 3m"', + type: 'text', + }, + urls: { + value: '', + type: 'text', + }, + 'service.name': { + value: '', + type: 'text', + }, + timeout: { + value: '16s', + type: 'text', + }, + max_redirects: { + value: 0, + type: 'integer', + }, + proxy_url: { + value: '', + type: 'text', + }, + tags: { + value: '[]', + type: 'yaml', + }, + 'response.include_headers': { + value: true, + type: 'bool', + }, + 'response.include_body': { + value: 'on_error', + type: 'text', + }, + 'check.request.method': { + value: 'GET', + type: 'text', + }, + 'check.request.headers': { + value: '{}', + type: 'yaml', + }, + 'check.request.body': { + value: '""', + type: 'yaml', + }, + 'check.response.status': { + value: '[]', + type: 'yaml', + }, + 'check.response.headers': { + value: '{}', + type: 'yaml', + }, + 'check.response.body.positive': { + value: '[]', + type: 'yaml', + }, + 'check.response.body.negative': { + value: '[]', + type: 'yaml', + }, + 'ssl.certificate_authorities': { + value: '', + type: 'yaml', + }, + 'ssl.certificate': { + value: '', + type: 'yaml', + }, + 'ssl.key': { + value: '', + type: 'yaml', + }, + 'ssl.key_passphrase': { + type: 'text', + }, + 'ssl.verification_mode': { + value: 'full', + type: 'text', + }, + }, + }, + ], + }, + { + type: 'synthetics/tcp', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'tcp', + }, + vars: { + type: { + value: 'tcp', + type: 'text', + }, + name: { + type: 'text', + }, + schedule: { + value: '"@every 5s"', + type: 'text', + }, + hosts: { + type: 'text', + }, + 'service.name': { + type: 'text', + }, + timeout: { + type: 'integer', + }, + max_redirects: { + type: 'integer', + }, + proxy_url: { + type: 'text', + }, + proxy_use_local_resolver: { + value: false, + type: 'bool', + }, + tags: { + type: 'yaml', + }, + 'check.send': { + type: 'text', + }, + 'check.receive': { + value: '', + type: 'yaml', + }, + 'ssl.certificate_authorities': { + type: 'yaml', + }, + 'ssl.certificate': { + type: 'yaml', + }, + 'ssl.key': { + type: 'yaml', + }, + 'ssl.key_passphrase': { + type: 'text', + }, + 'ssl.verification_mode': { + type: 'text', + }, + }, + }, + ], + }, + { + type: 'synthetics/icmp', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'icmp', + }, + vars: { + type: { + value: 'icmp', + type: 'text', + }, + name: { + type: 'text', + }, + schedule: { + value: '"@every 5s"', + type: 'text', + }, + wait: { + value: '1s', + type: 'text', + }, + hosts: { + type: 'text', + }, + 'service.name': { + type: 'text', + }, + timeout: { + type: 'integer', + }, + max_redirects: { + type: 'integer', + }, + tags: { + type: 'yaml', + }, + }, + }, + ], + }, + ], + package: { + name: 'synthetics', + title: 'Elastic Synthetics', + version: '0.66.0', + }, +}; + +const defaultCurrentPolicy: any = { + ...defaultNewPolicy, + id: '', + revision: '', + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', +}; + +describe('', () => { + const onChange = jest.fn(); + const WrappedComponent = ({ policy = defaultCurrentPolicy, newPolicy = defaultNewPolicy }) => { + return ( + + ); + }; + + it('renders SyntheticsPolicyEditExtension', async () => { + const { getByText, getByLabelText, queryByLabelText } = render(); + const url = getByLabelText('URL') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + const verificationMode = getByLabelText('Verification mode') as HTMLInputElement; + const enableTLSConfig = getByLabelText('Enable TLS configuration') as HTMLInputElement; + expect(url).toBeInTheDocument(); + expect(url.value).toEqual(defaultConfig[ConfigKeys.URLS]); + expect(proxyUrl).toBeInTheDocument(); + expect(proxyUrl.value).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect(monitorIntervalNumber).toBeInTheDocument(); + expect(monitorIntervalNumber.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].number); + expect(monitorIntervalUnit).toBeInTheDocument(); + expect(monitorIntervalUnit.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].unit); + expect(apmServiceName).toBeInTheDocument(); + expect(apmServiceName.value).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect(maxRedirects).toBeInTheDocument(); + expect(maxRedirects.value).toEqual(`${defaultConfig[ConfigKeys.MAX_REDIRECTS]}`); + expect(timeout).toBeInTheDocument(); + expect(timeout.value).toEqual(`${defaultConfig[ConfigKeys.TIMEOUT]}`); + // expect TLS settings to be in the document when at least one tls key is populated + expect(enableTLSConfig.checked).toBe(true); + expect(verificationMode).toBeInTheDocument(); + expect(verificationMode.value).toEqual( + `${defaultConfig[ConfigKeys.TLS_VERIFICATION_MODE].value}` + ); + + // ensure other monitor type options are not in the DOM + expect(queryByLabelText('Host')).not.toBeInTheDocument(); + expect(queryByLabelText('Wait in seconds')).not.toBeInTheDocument(); + + // ensure at least one http advanced option is present + const advancedOptionsButton = getByText('Advanced HTTP options'); + fireEvent.click(advancedOptionsButton); + await waitFor(() => { + expect(getByLabelText('Request method')).toBeInTheDocument(); + }); + }); + + it('does not allow user to edit monitor type', async () => { + const { queryByLabelText } = render(); + + expect(queryByLabelText('Monitor type')).not.toBeInTheDocument(); + }); + + it('handles updating each field', async () => { + const { getByLabelText } = render(); + const url = getByLabelText('URL') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + fireEvent.change(proxyUrl, { target: { value: 'http://proxy.co' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(monitorIntervalUnit, { target: { value: ScheduleUnit.MINUTES } }); + fireEvent.change(apmServiceName, { target: { value: 'APM Service' } }); + fireEvent.change(maxRedirects, { target: { value: '2' } }); + fireEvent.change(timeout, { target: { value: '3' } }); + + expect(url.value).toEqual('http://elastic.co'); + expect(proxyUrl.value).toEqual('http://proxy.co'); + expect(monitorIntervalNumber.value).toEqual('1'); + expect(monitorIntervalUnit.value).toEqual(ScheduleUnit.MINUTES); + expect(apmServiceName.value).toEqual('APM Service'); + expect(maxRedirects.value).toEqual('2'); + expect(timeout.value).toEqual('3'); + }); + + it('handles calling onChange', async () => { + const { getByLabelText } = render(); + const url = getByLabelText('URL') as HTMLInputElement; + + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ + isValid: true, + updatedPolicy: { + ...defaultNewPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + streams: [ + { + ...defaultNewPolicy.inputs[0].streams[0], + vars: { + ...defaultNewPolicy.inputs[0].streams[0].vars, + urls: { + value: 'http://elastic.co', + type: 'text', + }, + }, + }, + ], + }, + defaultNewPolicy.inputs[1], + defaultNewPolicy.inputs[2], + ], + }, + }); + }); + }); + + it('handles http validation', async () => { + const { getByText, getByLabelText, queryByText } = render(); + + const url = getByLabelText('URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(url, { target: { value: '' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(maxRedirects, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + + const urlError = getByText('URL is required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const maxRedirectsError = getByText('Max redirects must be 0 or greater'); + const timeoutError = getByText('Timeout must be 0 or greater and less than schedule interval'); + + expect(urlError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(maxRedirectsError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + + // expect onChange to be called with isValid false + await waitFor(() => { + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + // resolve errors + fireEvent.change(url, { target: { value: 'http://elastic.co' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(maxRedirects, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + + // expect onChange to be called with isValid true + await waitFor(() => { + expect(queryByText('URL is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); + expect( + queryByText('Timeout must be 0 or greater and less than schedule interval') + ).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + }); + + it('handles tcp validation', async () => { + const currentPolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: true, + }, + defaultNewPolicy.inputs[2], + ], + }; + const { getByText, getByLabelText, queryByText } = render( + + ); + + const host = getByLabelText('Host:Port') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(host, { target: { value: 'localhost' } }); // host without port + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + + await waitFor(() => { + const hostError = getByText('Host and port are required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const timeoutError = getByText( + 'Timeout must be 0 or greater and less than schedule interval' + ); + + expect(hostError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + // resolve errors + fireEvent.change(host, { target: { value: 'smtp.gmail.com:587' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + + await waitFor(() => { + expect(queryByText('Host is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect( + queryByText('Timeout must be 0 or greater and less than schedule interval') + ).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + }); + + it('handles icmp validation', async () => { + const currentPolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[2], + enabled: true, + }, + ], + }; + const { getByText, getByLabelText, queryByText } = render( + + ); + + const host = getByLabelText('Host') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + const wait = getByLabelText('Wait in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(host, { target: { value: '' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + fireEvent.change(wait, { target: { value: '-1' } }); + + await waitFor(() => { + const hostError = getByText('Host is required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const timeoutError = getByText( + 'Timeout must be 0 or greater and less than schedule interval' + ); + const waitError = getByText('Wait must be 0 or greater'); + + expect(hostError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + expect(waitError).toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + // resolve errors + fireEvent.change(host, { target: { value: '1.1.1.1' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + fireEvent.change(wait, { target: { value: '1' } }); + + await waitFor(() => { + expect(queryByText('Host is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect( + queryByText('Timeout must be 0 or greater and less than schedule interval') + ).not.toBeInTheDocument(); + expect(queryByText('Wait must be 0 or greater')).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + }); + + it('handles null values for http', async () => { + const httpVars = defaultNewPolicy.inputs[0].streams[0].vars; + const currentPolicy: NewPackagePolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + streams: [ + { + ...defaultNewPolicy.inputs[0].streams[0], + vars: Object.keys(httpVars || []).reduce< + Record + >((acc, key) => { + acc[key] = { + value: undefined, + type: `${httpVars?.[key].type}`, + }; + return acc; + }, {}), + }, + ], + }, + defaultCurrentPolicy.inputs[1], + defaultCurrentPolicy.inputs[2], + ], + }; + const { getByText, getByLabelText, queryByLabelText, queryByText } = render( + + ); + const url = getByLabelText('URL') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + const enableTLSConfig = getByLabelText('Enable TLS configuration') as HTMLInputElement; + + expect(url).toBeInTheDocument(); + expect(url.value).toEqual(defaultConfig[ConfigKeys.URLS]); + expect(proxyUrl).toBeInTheDocument(); + expect(proxyUrl.value).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect(monitorIntervalNumber).toBeInTheDocument(); + expect(monitorIntervalNumber.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].number); + expect(monitorIntervalUnit).toBeInTheDocument(); + expect(monitorIntervalUnit.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].unit); + expect(apmServiceName).toBeInTheDocument(); + expect(apmServiceName.value).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect(maxRedirects).toBeInTheDocument(); + expect(maxRedirects.value).toEqual(`${defaultConfig[ConfigKeys.MAX_REDIRECTS]}`); + expect(timeout).toBeInTheDocument(); + expect(timeout.value).toEqual(`${defaultConfig[ConfigKeys.TIMEOUT]}`); + + /* expect TLS settings not to be in the document when and Enable TLS settings not to be checked + * when all TLS values are falsey */ + expect(enableTLSConfig.checked).toBe(false); + expect(queryByText('Verification mode')).not.toBeInTheDocument(); + + // ensure other monitor type options are not in the DOM + expect(queryByLabelText('Host')).not.toBeInTheDocument(); + expect(queryByLabelText('Wait in seconds')).not.toBeInTheDocument(); + + // ensure at least one http advanced option is present + const advancedOptionsButton = getByText('Advanced HTTP options'); + fireEvent.click(advancedOptionsButton); + await waitFor(() => { + const requestMethod = getByLabelText('Request method') as HTMLInputElement; + expect(requestMethod).toBeInTheDocument(); + expect(requestMethod.value).toEqual(`${defaultConfig[ConfigKeys.REQUEST_METHOD_CHECK]}`); + }); + }); + + it('handles null values for tcp', async () => { + const tcpVars = defaultNewPolicy.inputs[1].streams[0].vars; + const currentPolicy: NewPackagePolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: true, + streams: [ + { + ...defaultNewPolicy.inputs[1].streams[0], + vars: { + ...Object.keys(tcpVars || []).reduce< + Record + >((acc, key) => { + acc[key] = { + value: undefined, + type: `${tcpVars?.[key].type}`, + }; + return acc; + }, {}), + [ConfigKeys.MONITOR_TYPE]: { + value: DataStream.TCP, + type: 'text', + }, + }, + }, + ], + }, + defaultCurrentPolicy.inputs[2], + ], + }; + const { getByText, getByLabelText, queryByLabelText } = render( + + ); + const url = getByLabelText('Host:Port') as HTMLInputElement; + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + expect(url).toBeInTheDocument(); + expect(url.value).toEqual(defaultConfig[ConfigKeys.URLS]); + expect(proxyUrl).toBeInTheDocument(); + expect(proxyUrl.value).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect(monitorIntervalNumber).toBeInTheDocument(); + expect(monitorIntervalNumber.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].number); + expect(monitorIntervalUnit).toBeInTheDocument(); + expect(monitorIntervalUnit.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].unit); + expect(apmServiceName).toBeInTheDocument(); + expect(apmServiceName.value).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect(timeout).toBeInTheDocument(); + expect(timeout.value).toEqual(`${defaultConfig[ConfigKeys.TIMEOUT]}`); + + // ensure other monitor type options are not in the DOM + expect(queryByLabelText('Url')).not.toBeInTheDocument(); + expect(queryByLabelText('Wait in seconds')).not.toBeInTheDocument(); + + // ensure at least one tcp advanced option is present + const advancedOptionsButton = getByText('Advanced TCP options'); + fireEvent.click(advancedOptionsButton); + await waitFor(() => { + expect(getByLabelText('Request payload')).toBeInTheDocument(); + }); + }); + + it('handles null values for icmp', async () => { + const tcpVars = defaultNewPolicy.inputs[1].streams[0].vars; + const currentPolicy: NewPackagePolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[2], + enabled: true, + streams: [ + { + ...defaultNewPolicy.inputs[2].streams[0], + vars: { + ...Object.keys(tcpVars || []).reduce< + Record + >((acc, key) => { + acc[key] = { + value: undefined, + type: `${tcpVars?.[key].type}`, + }; + return acc; + }, {}), + [ConfigKeys.MONITOR_TYPE]: { + value: DataStream.ICMP, + type: 'text', + }, + }, + }, + ], + }, + ], + }; + const { getByLabelText, queryByLabelText } = render( + + ); + const url = getByLabelText('Host') as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + const wait = getByLabelText('Wait in seconds') as HTMLInputElement; + expect(url).toBeInTheDocument(); + expect(url.value).toEqual(defaultConfig[ConfigKeys.URLS]); + expect(monitorIntervalNumber).toBeInTheDocument(); + expect(monitorIntervalNumber.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].number); + expect(monitorIntervalUnit).toBeInTheDocument(); + expect(monitorIntervalUnit.value).toEqual(defaultConfig[ConfigKeys.SCHEDULE].unit); + expect(apmServiceName).toBeInTheDocument(); + expect(apmServiceName.value).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect(timeout).toBeInTheDocument(); + expect(timeout.value).toEqual(`${defaultConfig[ConfigKeys.TIMEOUT]}`); + expect(wait).toBeInTheDocument(); + expect(wait.value).toEqual(`${defaultConfig[ConfigKeys.WAIT]}`); + + // ensure other monitor type options are not in the DOM + expect(queryByLabelText('Url')).not.toBeInTheDocument(); + expect(queryByLabelText('Proxy URL')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.tsx new file mode 100644 index 0000000000000..85b38e05fdbc8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { PackagePolicyEditExtensionComponentProps } from '../../../../fleet/public'; +import { Config, ConfigKeys, ContentType, contentTypesToMode } from './types'; +import { SyntheticsPolicyEditExtension } from './synthetics_policy_edit_extension'; +import { + SimpleFieldsContextProvider, + HTTPAdvancedFieldsContextProvider, + TCPAdvancedFieldsContextProvider, + TLSFieldsContextProvider, + defaultSimpleFields, + defaultHTTPAdvancedFields, + defaultTCPAdvancedFields, + defaultTLSFields, +} from './contexts'; + +/** + * Exports Synthetics-specific package policy instructions + * for use in the Ingest app create / edit package policy + */ +export const SyntheticsPolicyEditExtensionWrapper = memo( + ({ policy: currentPolicy, newPolicy, onChange }) => { + const { enableTLS: isTLSEnabled, config: defaultConfig } = useMemo(() => { + const fallbackConfig: Config = { + name: '', + ...defaultSimpleFields, + ...defaultHTTPAdvancedFields, + ...defaultTCPAdvancedFields, + ...defaultTLSFields, + }; + let enableTLS = false; + const getDefaultConfig = () => { + const currentInput = currentPolicy.inputs.find((input) => input.enabled === true); + const vars = currentInput?.streams[0]?.vars; + + const configKeys: ConfigKeys[] = Object.values(ConfigKeys); + const formattedDefaultConfig = configKeys.reduce( + (acc: Record, key: ConfigKeys) => { + const value = vars?.[key]?.value; + switch (key) { + case ConfigKeys.NAME: + acc[key] = currentPolicy.name; + break; + case ConfigKeys.SCHEDULE: + // split unit and number + if (value) { + const fullString = JSON.parse(value); + const fullSchedule = fullString.replace('@every ', ''); + const unit = fullSchedule.slice(-1); + const number = fullSchedule.slice(0, fullSchedule.length - 1); + acc[key] = { + unit, + number, + }; + } else { + acc[key] = fallbackConfig[key]; + } + break; + case ConfigKeys.TIMEOUT: + case ConfigKeys.WAIT: + acc[key] = value ? value.slice(0, value.length - 1) : fallbackConfig[key]; // remove unit + break; + case ConfigKeys.TAGS: + case ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE: + case ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE: + case ConfigKeys.RESPONSE_STATUS_CHECK: + case ConfigKeys.RESPONSE_HEADERS_CHECK: + case ConfigKeys.REQUEST_HEADERS_CHECK: + acc[key] = value ? JSON.parse(value) : fallbackConfig[key]; + break; + case ConfigKeys.REQUEST_BODY_CHECK: + const headers = value + ? JSON.parse(vars?.[ConfigKeys.REQUEST_HEADERS_CHECK].value) + : fallbackConfig[ConfigKeys.REQUEST_HEADERS_CHECK]; + const requestBodyValue = + value !== null && value !== undefined + ? JSON.parse(value) + : fallbackConfig[key].value; + let type = fallbackConfig[key].type; + Object.keys(headers || []).some((headerKey) => { + if ( + headerKey === 'Content-Type' && + contentTypesToMode[headers[headerKey] as ContentType] + ) { + type = contentTypesToMode[headers[headerKey] as ContentType]; + return true; + } + }); + acc[key] = { + value: requestBodyValue, + type, + }; + break; + case ConfigKeys.TLS_KEY_PASSPHRASE: + case ConfigKeys.TLS_VERIFICATION_MODE: + acc[key] = { + value: value ?? fallbackConfig[key].value, + isEnabled: !!value, + }; + if (!!value) { + enableTLS = true; + } + break; + case ConfigKeys.TLS_CERTIFICATE: + case ConfigKeys.TLS_CERTIFICATE_AUTHORITIES: + case ConfigKeys.TLS_KEY: + case ConfigKeys.TLS_VERSION: + acc[key] = { + value: value ? JSON.parse(value) : fallbackConfig[key].value, + isEnabled: !!value, + }; + if (!!value) { + enableTLS = true; + } + break; + default: + acc[key] = value ?? fallbackConfig[key]; + } + return acc; + }, + {} + ); + + return { config: (formattedDefaultConfig as unknown) as Config, enableTLS }; + }; + + return getDefaultConfig(); + }, [currentPolicy]); + + const simpleFields = { + [ConfigKeys.APM_SERVICE_NAME]: defaultConfig[ConfigKeys.APM_SERVICE_NAME], + [ConfigKeys.HOSTS]: defaultConfig[ConfigKeys.HOSTS], + [ConfigKeys.MAX_REDIRECTS]: defaultConfig[ConfigKeys.MAX_REDIRECTS], + [ConfigKeys.MONITOR_TYPE]: defaultConfig[ConfigKeys.MONITOR_TYPE], + [ConfigKeys.SCHEDULE]: defaultConfig[ConfigKeys.SCHEDULE], + [ConfigKeys.TAGS]: defaultConfig[ConfigKeys.TAGS], + [ConfigKeys.TIMEOUT]: defaultConfig[ConfigKeys.TIMEOUT], + [ConfigKeys.URLS]: defaultConfig[ConfigKeys.URLS], + [ConfigKeys.WAIT]: defaultConfig[ConfigKeys.WAIT], + }; + const httpAdvancedFields = { + [ConfigKeys.USERNAME]: defaultConfig[ConfigKeys.USERNAME], + [ConfigKeys.PASSWORD]: defaultConfig[ConfigKeys.PASSWORD], + [ConfigKeys.PROXY_URL]: defaultConfig[ConfigKeys.PROXY_URL], + [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: + defaultConfig[ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE], + [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: + defaultConfig[ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE], + [ConfigKeys.RESPONSE_BODY_INDEX]: defaultConfig[ConfigKeys.RESPONSE_BODY_INDEX], + [ConfigKeys.RESPONSE_HEADERS_CHECK]: defaultConfig[ConfigKeys.RESPONSE_HEADERS_CHECK], + [ConfigKeys.RESPONSE_HEADERS_INDEX]: defaultConfig[ConfigKeys.RESPONSE_HEADERS_INDEX], + [ConfigKeys.RESPONSE_STATUS_CHECK]: defaultConfig[ConfigKeys.RESPONSE_STATUS_CHECK], + [ConfigKeys.REQUEST_BODY_CHECK]: defaultConfig[ConfigKeys.REQUEST_BODY_CHECK], + [ConfigKeys.REQUEST_HEADERS_CHECK]: defaultConfig[ConfigKeys.REQUEST_HEADERS_CHECK], + [ConfigKeys.REQUEST_METHOD_CHECK]: defaultConfig[ConfigKeys.REQUEST_METHOD_CHECK], + }; + const tcpAdvancedFields = { + [ConfigKeys.PROXY_URL]: defaultConfig[ConfigKeys.PROXY_URL], + [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: defaultConfig[ConfigKeys.PROXY_USE_LOCAL_RESOLVER], + [ConfigKeys.RESPONSE_RECEIVE_CHECK]: defaultConfig[ConfigKeys.RESPONSE_RECEIVE_CHECK], + [ConfigKeys.REQUEST_SEND_CHECK]: defaultConfig[ConfigKeys.REQUEST_SEND_CHECK], + }; + const tlsFields = { + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: + defaultConfig[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES], + [ConfigKeys.TLS_CERTIFICATE]: defaultConfig[ConfigKeys.TLS_CERTIFICATE], + [ConfigKeys.TLS_KEY]: defaultConfig[ConfigKeys.TLS_KEY], + [ConfigKeys.TLS_KEY_PASSPHRASE]: defaultConfig[ConfigKeys.TLS_KEY_PASSPHRASE], + [ConfigKeys.TLS_VERIFICATION_MODE]: defaultConfig[ConfigKeys.TLS_VERIFICATION_MODE], + [ConfigKeys.TLS_VERSION]: defaultConfig[ConfigKeys.TLS_VERSION], + }; + + return ( + + + + + + + + + + ); + } +); +SyntheticsPolicyEditExtensionWrapper.displayName = 'SyntheticsPolicyEditExtensionWrapper'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp_advanced_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tcp_advanced_fields.test.tsx new file mode 100644 index 0000000000000..77551f9aa8011 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp_advanced_fields.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { render } from '../../lib/helper/rtl_helpers'; +import { TCPAdvancedFields } from './tcp_advanced_fields'; +import { + TCPAdvancedFieldsContextProvider, + defaultTCPAdvancedFields as defaultConfig, +} from './contexts'; +import { ConfigKeys, ITCPAdvancedFields } from './types'; + +// ensures fields and labels map appropriately +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +describe('', () => { + const WrappedComponent = ({ + defaultValues = defaultConfig, + }: { + defaultValues?: ITCPAdvancedFields; + }) => { + return ( + + + + ); + }; + + it('renders TCPAdvancedFields', () => { + const { getByLabelText } = render(); + + const requestPayload = getByLabelText('Request payload') as HTMLInputElement; + const proxyURL = getByLabelText('Proxy URL') as HTMLInputElement; + // ComboBox has an issue with associating labels with the field + const responseContains = getByLabelText('Check response contains') as HTMLInputElement; + expect(requestPayload).toBeInTheDocument(); + expect(requestPayload.value).toEqual(defaultConfig[ConfigKeys.REQUEST_SEND_CHECK]); + expect(proxyURL).toBeInTheDocument(); + expect(proxyURL.value).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect(responseContains).toBeInTheDocument(); + expect(responseContains.value).toEqual(defaultConfig[ConfigKeys.RESPONSE_RECEIVE_CHECK]); + }); + + it('handles changing fields', () => { + const { getByLabelText } = render(); + + const requestPayload = getByLabelText('Request payload') as HTMLInputElement; + + fireEvent.change(requestPayload, { target: { value: 'success' } }); + expect(requestPayload.value).toEqual('success'); + }); + + it('shows resolve hostnames locally field when proxy url is filled for tcp monitors', () => { + const { getByLabelText, queryByLabelText } = render(); + + expect(queryByLabelText('Resolve hostnames locally')).not.toBeInTheDocument(); + + const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; + + fireEvent.change(proxyUrl, { target: { value: 'sampleProxyUrl' } }); + + expect(getByLabelText('Resolve hostnames locally')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp_advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tcp_advanced_fields.tsx new file mode 100644 index 0000000000000..d3936b8468664 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp_advanced_fields.tsx @@ -0,0 +1,174 @@ +/* + * 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 } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiAccordion, + EuiCheckbox, + EuiFormRow, + EuiDescribedFormGroup, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; + +import { useTCPAdvancedFieldsContext } from './contexts'; + +import { ConfigKeys } from './types'; + +import { OptionalLabel } from './optional_label'; + +export const TCPAdvancedFields = () => { + const { fields, setFields } = useTCPAdvancedFieldsContext(); + + const handleInputChange = useCallback( + ({ value, configKey }: { value: unknown; configKey: ConfigKeys }) => { + setFields((prevFields) => ({ ...prevFields, [configKey]: value })); + }, + [setFields] + ); + + return ( + + + + + + } + description={ + + } + > + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.PROXY_URL, + }) + } + /> + + {!!fields[ConfigKeys.PROXY_URL] && ( + + + } + onChange={(event) => + handleInputChange({ + value: event.target.checked, + configKey: ConfigKeys.PROXY_USE_LOCAL_RESOLVER, + }) + } + /> + + )} + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.REQUEST_SEND_CHECK, + }), + [handleInputChange] + )} + /> + + + + + + } + description={ + + } + > + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.RESPONSE_RECEIVE_CHECK, + }), + [handleInputChange] + )} + /> + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.test.tsx new file mode 100644 index 0000000000000..0528438650dc3 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 } from '@testing-library/react'; +import { render } from '../../lib/helper/rtl_helpers'; +import { TLSFields, TLSRole } from './tls_fields'; +import { ConfigKeys, VerificationMode } from './types'; +import { TLSFieldsContextProvider, defaultTLSFields as defaultValues } from './contexts'; + +// ensures that fields appropriately match to their label +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +describe('', () => { + const WrappedComponent = ({ + tlsRole = TLSRole.CLIENT, + isEnabled = true, + }: { + tlsRole?: TLSRole; + isEnabled?: boolean; + }) => { + return ( + + + + ); + }; + it('renders TLSFields', () => { + const { getByLabelText, getByText } = render(); + + expect(getByText('Certificate settings')).toBeInTheDocument(); + expect(getByText('Supported TLS protocols')).toBeInTheDocument(); + expect(getByLabelText('Client certificate')).toBeInTheDocument(); + expect(getByLabelText('Client key')).toBeInTheDocument(); + expect(getByLabelText('Certificate authorities')).toBeInTheDocument(); + expect(getByLabelText('Verification mode')).toBeInTheDocument(); + }); + + it('handles role', () => { + const { getByLabelText, rerender } = render(); + + expect(getByLabelText('Server certificate')).toBeInTheDocument(); + expect(getByLabelText('Server key')).toBeInTheDocument(); + + rerender(); + }); + + it('updates fields and calls onChange', async () => { + const { getByLabelText } = render(); + + const clientCertificate = getByLabelText('Client certificate') as HTMLInputElement; + const clientKey = getByLabelText('Client key') as HTMLInputElement; + const clientKeyPassphrase = getByLabelText('Client key passphrase') as HTMLInputElement; + const certificateAuthorities = getByLabelText('Certificate authorities') as HTMLInputElement; + const verificationMode = getByLabelText('Verification mode') as HTMLInputElement; + + const newValues = { + [ConfigKeys.TLS_CERTIFICATE]: 'sampleClientCertificate', + [ConfigKeys.TLS_KEY]: 'sampleClientKey', + [ConfigKeys.TLS_KEY_PASSPHRASE]: 'sampleClientKeyPassphrase', + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: 'sampleCertificateAuthorities', + [ConfigKeys.TLS_VERIFICATION_MODE]: VerificationMode.NONE, + }; + + fireEvent.change(clientCertificate, { + target: { value: newValues[ConfigKeys.TLS_CERTIFICATE] }, + }); + fireEvent.change(clientKey, { target: { value: newValues[ConfigKeys.TLS_KEY] } }); + fireEvent.change(clientKeyPassphrase, { + target: { value: newValues[ConfigKeys.TLS_KEY_PASSPHRASE] }, + }); + fireEvent.change(certificateAuthorities, { + target: { value: newValues[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES] }, + }); + fireEvent.change(verificationMode, { + target: { value: newValues[ConfigKeys.TLS_VERIFICATION_MODE] }, + }); + + expect(clientCertificate.value).toEqual(newValues[ConfigKeys.TLS_CERTIFICATE]); + expect(clientKey.value).toEqual(newValues[ConfigKeys.TLS_KEY]); + expect(certificateAuthorities.value).toEqual(newValues[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]); + expect(verificationMode.value).toEqual(newValues[ConfigKeys.TLS_VERIFICATION_MODE]); + }); + + it('shows warning when verification mode is set to none', () => { + const { getByLabelText, getByText } = render(); + + const verificationMode = getByLabelText('Verification mode') as HTMLInputElement; + + fireEvent.change(verificationMode, { + target: { value: VerificationMode.NONE }, + }); + + expect(getByText('Disabling TLS')).toBeInTheDocument(); + }); + + it('does not show fields when isEnabled is false', async () => { + const { queryByLabelText } = render(); + + expect(queryByLabelText('Client certificate')).not.toBeInTheDocument(); + expect(queryByLabelText('Client key')).not.toBeInTheDocument(); + expect(queryByLabelText('Client key passphrase')).not.toBeInTheDocument(); + expect(queryByLabelText('Certificate authorities')).not.toBeInTheDocument(); + expect(queryByLabelText('verification mode')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.tsx new file mode 100644 index 0000000000000..e01d3d59175a4 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.tsx @@ -0,0 +1,439 @@ +/* + * 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, memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiCallOut, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFormRow, + EuiFieldText, + EuiTextArea, + EuiFormFieldset, + EuiSelect, + EuiScreenReaderOnly, + EuiSpacer, +} from '@elastic/eui'; + +import { useTLSFieldsContext } from './contexts'; + +import { VerificationMode, ConfigKeys, TLSVersion } from './types'; + +import { OptionalLabel } from './optional_label'; + +export enum TLSRole { + CLIENT = 'client', + SERVER = 'server', +} + +export const TLSFields: React.FunctionComponent<{ + isEnabled: boolean; + tlsRole: TLSRole; +}> = memo(({ isEnabled, tlsRole }) => { + const { fields, setFields } = useTLSFieldsContext(); + const [ + verificationVersionInputRef, + setVerificationVersionInputRef, + ] = useState(null); + const [hasVerificationVersionError, setHasVerificationVersionError] = useState< + string | undefined + >(undefined); + + useEffect(() => { + setFields((prevFields) => ({ + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + value: prevFields[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES].value, + isEnabled, + }, + [ConfigKeys.TLS_CERTIFICATE]: { + value: prevFields[ConfigKeys.TLS_CERTIFICATE].value, + isEnabled, + }, + [ConfigKeys.TLS_KEY]: { + value: prevFields[ConfigKeys.TLS_KEY].value, + isEnabled, + }, + [ConfigKeys.TLS_KEY_PASSPHRASE]: { + value: prevFields[ConfigKeys.TLS_KEY_PASSPHRASE].value, + isEnabled, + }, + [ConfigKeys.TLS_VERIFICATION_MODE]: { + value: prevFields[ConfigKeys.TLS_VERIFICATION_MODE].value, + isEnabled, + }, + [ConfigKeys.TLS_VERSION]: { + value: prevFields[ConfigKeys.TLS_VERSION].value, + isEnabled, + }, + })); + }, [isEnabled, setFields]); + + const onVerificationVersionChange = ( + selectedVersionOptions: Array> + ) => { + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_VERSION]: { + value: selectedVersionOptions.map((option) => option.label as TLSVersion), + isEnabled: true, + }, + })); + setHasVerificationVersionError(undefined); + }; + + const onSearchChange = (value: string, hasMatchingOptions?: boolean) => { + setHasVerificationVersionError( + value.length === 0 || hasMatchingOptions ? undefined : `"${value}" is not a valid option` + ); + }; + + const onBlur = () => { + if (verificationVersionInputRef) { + const { value } = verificationVersionInputRef; + setHasVerificationVersionError( + value.length === 0 ? undefined : `"${value}" is not a valid option` + ); + } + }; + + return isEnabled ? ( + + + + + + ), + }} + > + + } + helpText={verificationModeHelpText[fields[ConfigKeys.TLS_VERIFICATION_MODE].value]} + > + { + const value = event.target.value as VerificationMode; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_VERIFICATION_MODE]: { + value, + isEnabled: true, + }, + })); + }} + /> + + {fields[ConfigKeys.TLS_VERIFICATION_MODE].value === VerificationMode.NONE && ( + <> + + + } + color="warning" + size="s" + > +

+ +

+
+ + + )} + + } + error={hasVerificationVersionError} + isInvalid={hasVerificationVersionError !== undefined} + > + ({ + label: version, + }))} + inputRef={setVerificationVersionInputRef} + onChange={onVerificationVersionChange} + onSearchChange={onSearchChange} + onBlur={onBlur} + /> + + + } + helpText={ + + } + labelAppend={} + > + { + const value = event.target.value; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + value, + isEnabled: true, + }, + })); + }} + onBlur={(event) => { + const value = event.target.value; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + value: value.trim(), + isEnabled: true, + }, + })); + }} + /> + + + {tlsRoleLabels[tlsRole]}{' '} + + + } + helpText={ + + } + labelAppend={} + > + { + const value = event.target.value; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_CERTIFICATE]: { + value, + isEnabled: true, + }, + })); + }} + onBlur={(event) => { + const value = event.target.value; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_CERTIFICATE]: { + value: value.trim(), + isEnabled: true, + }, + })); + }} + /> + + + {tlsRoleLabels[tlsRole]}{' '} + + + } + helpText={ + + } + labelAppend={} + > + { + const value = event.target.value; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_KEY]: { + value, + isEnabled: true, + }, + })); + }} + onBlur={(event) => { + const value = event.target.value; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_KEY]: { + value: value.trim(), + isEnabled: true, + }, + })); + }} + /> + + + {tlsRoleLabels[tlsRole]}{' '} + + + } + helpText={ + + } + labelAppend={} + > + { + const value = event.target.value; + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.TLS_KEY_PASSPHRASE]: { + value, + isEnabled: true, + }, + })); + }} + /> + +
+ ) : null; +}); + +const tlsRoleLabels = { + [TLSRole.CLIENT]: ( + + ), + [TLSRole.SERVER]: ( + + ), +}; + +const verificationModeHelpText = { + [VerificationMode.CERTIFICATE]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.certificate.description', + { + defaultMessage: + 'Verifies that the provided certificate is signed by a trusted authority (CA), but does not perform any hostname verification.', + } + ), + [VerificationMode.FULL]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.full.description', + { + defaultMessage: + 'Verifies that the provided certificate is signed by a trusted authority (CA) and also verifies that the server’s hostname (or IP address) matches the names identified within the certificate.', + } + ), + [VerificationMode.NONE]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.none.description', + { + defaultMessage: + 'Performs no verification of the server’s certificate. It is primarily intended as a temporary diagnostic mechanism when attempting to resolve TLS errors; its use in production environments is strongly discouraged.', + } + ), + [VerificationMode.STRICT]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.strict.description', + { + defaultMessage: + 'Verifies that the provided certificate is signed by a trusted authority (CA) and also verifies that the server’s hostname (or IP address) matches the names identified within the certificate. If the Subject Alternative Name is empty, it returns an error.', + } + ), +}; + +const verificationModeLabels = { + [VerificationMode.CERTIFICATE]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.certificate.label', + { + defaultMessage: 'Certificate', + } + ), + [VerificationMode.FULL]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.full.label', + { + defaultMessage: 'Full', + } + ), + [VerificationMode.NONE]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.none.label', + { + defaultMessage: 'None', + } + ), + [VerificationMode.STRICT]: i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.certsField.verificationMode.strict.label', + { + defaultMessage: 'Strict', + } + ), +}; + +const verificationModeOptions = [ + { + value: VerificationMode.CERTIFICATE, + text: verificationModeLabels[VerificationMode.CERTIFICATE], + }, + { value: VerificationMode.FULL, text: verificationModeLabels[VerificationMode.FULL] }, + { value: VerificationMode.NONE, text: verificationModeLabels[VerificationMode.NONE] }, + { value: VerificationMode.STRICT, text: verificationModeLabels[VerificationMode.STRICT] }, +]; + +const tlsVersionOptions = Object.values(TLSVersion).map((method) => ({ + label: method, +})); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx new file mode 100644 index 0000000000000..802d5f08fd646 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx @@ -0,0 +1,170 @@ +/* + * 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 enum DataStream { + HTTP = 'http', + TCP = 'tcp', + ICMP = 'icmp', +} + +export enum HTTPMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + HEAD = 'HEAD', +} + +export enum ResponseBodyIndexPolicy { + ALWAYS = 'always', + NEVER = 'never', + ON_ERROR = 'on_error', +} + +export enum Mode { + FORM = 'form', + JSON = 'json', + TEXT = 'text', + XML = 'xml', +} + +export enum ContentType { + JSON = 'application/json', + TEXT = 'text/plain', + XML = 'application/xml', + FORM = 'application/x-www-form-urlencoded', +} + +export enum ScheduleUnit { + MINUTES = 'm', + SECONDS = 's', +} + +export enum VerificationMode { + CERTIFICATE = 'certificate', + FULL = 'full', + NONE = 'none', + STRICT = 'strict', +} + +export enum TLSVersion { + ONE_ZERO = 'TLSv1.0', + ONE_ONE = 'TLSv1.1', + ONE_TWO = 'TLSv1.2', + ONE_THREE = 'TLSv1.3', +} + +// values must match keys in the integration package +export enum ConfigKeys { + APM_SERVICE_NAME = 'service.name', + HOSTS = 'hosts', + MAX_REDIRECTS = 'max_redirects', + MONITOR_TYPE = 'type', + NAME = 'name', + PASSWORD = 'password', + PROXY_URL = 'proxy_url', + PROXY_USE_LOCAL_RESOLVER = 'proxy_use_local_resolver', + RESPONSE_BODY_CHECK_NEGATIVE = 'check.response.body.negative', + RESPONSE_BODY_CHECK_POSITIVE = 'check.response.body.positive', + RESPONSE_BODY_INDEX = 'response.include_body', + RESPONSE_HEADERS_CHECK = 'check.response.headers', + RESPONSE_HEADERS_INDEX = 'response.include_headers', + RESPONSE_RECEIVE_CHECK = 'check.receive', + RESPONSE_STATUS_CHECK = 'check.response.status', + REQUEST_BODY_CHECK = 'check.request.body', + REQUEST_HEADERS_CHECK = 'check.request.headers', + REQUEST_METHOD_CHECK = 'check.request.method', + REQUEST_SEND_CHECK = 'check.send', + SCHEDULE = 'schedule', + TLS_CERTIFICATE_AUTHORITIES = 'ssl.certificate_authorities', + TLS_CERTIFICATE = 'ssl.certificate', + TLS_KEY = 'ssl.key', + TLS_KEY_PASSPHRASE = 'ssl.key_passphrase', + TLS_VERIFICATION_MODE = 'ssl.verification_mode', + TLS_VERSION = 'ssl.supported_protocols', + TAGS = 'tags', + TIMEOUT = 'timeout', + URLS = 'urls', + USERNAME = 'username', + WAIT = 'wait', +} + +export interface ISimpleFields { + [ConfigKeys.HOSTS]: string; + [ConfigKeys.MAX_REDIRECTS]: string; + [ConfigKeys.MONITOR_TYPE]: DataStream; + [ConfigKeys.SCHEDULE]: { number: string; unit: ScheduleUnit }; + [ConfigKeys.APM_SERVICE_NAME]: string; + [ConfigKeys.TIMEOUT]: string; + [ConfigKeys.URLS]: string; + [ConfigKeys.TAGS]: string[]; + [ConfigKeys.WAIT]: string; +} + +export interface ITLSFields { + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + value: string; + isEnabled: boolean; + }; + [ConfigKeys.TLS_CERTIFICATE]: { + value: string; + isEnabled: boolean; + }; + [ConfigKeys.TLS_KEY]: { + value: string; + isEnabled: boolean; + }; + [ConfigKeys.TLS_KEY_PASSPHRASE]: { + value: string; + isEnabled: boolean; + }; + [ConfigKeys.TLS_VERIFICATION_MODE]: { + value: VerificationMode; + isEnabled: boolean; + }; + [ConfigKeys.TLS_VERSION]: { + value: TLSVersion[]; + isEnabled: boolean; + }; +} + +export interface IHTTPAdvancedFields { + [ConfigKeys.PASSWORD]: string; + [ConfigKeys.PROXY_URL]: string; + [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: string[]; + [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: string[]; + [ConfigKeys.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicy; + [ConfigKeys.RESPONSE_HEADERS_CHECK]: Record; + [ConfigKeys.RESPONSE_HEADERS_INDEX]: boolean; + [ConfigKeys.RESPONSE_STATUS_CHECK]: string[]; + [ConfigKeys.REQUEST_BODY_CHECK]: { value: string; type: Mode }; + [ConfigKeys.REQUEST_HEADERS_CHECK]: Record; + [ConfigKeys.REQUEST_METHOD_CHECK]: string; + [ConfigKeys.USERNAME]: string; +} + +export interface ITCPAdvancedFields { + [ConfigKeys.PROXY_URL]: string; + [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: boolean; + [ConfigKeys.RESPONSE_RECEIVE_CHECK]: string; + [ConfigKeys.REQUEST_SEND_CHECK]: string; +} + +export type ICustomFields = ISimpleFields & ITLSFields & IHTTPAdvancedFields & ITCPAdvancedFields; + +export type Config = { + [ConfigKeys.NAME]: string; +} & ICustomFields; + +export type Validation = Partial void>>; + +export const contentTypesToMode = { + [ContentType.FORM]: Mode.FORM, + [ContentType.JSON]: Mode.JSON, + [ContentType.TEXT]: Mode.TEXT, + [ContentType.XML]: Mode.XML, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx new file mode 100644 index 0000000000000..3732791f895dc --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx @@ -0,0 +1,530 @@ +/* + * 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 { useUpdatePolicy } from './use_update_policy'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { NewPackagePolicy } from '../../../../fleet/public'; +import { validate } from './validation'; +import { ConfigKeys, DataStream, TLSVersion } from './types'; +import { + defaultSimpleFields, + defaultTLSFields, + defaultHTTPAdvancedFields, + defaultTCPAdvancedFields, +} from './contexts'; + +const defaultConfig = { + name: '', + ...defaultSimpleFields, + ...defaultTLSFields, + ...defaultHTTPAdvancedFields, + ...defaultTCPAdvancedFields, +}; + +describe('useBarChartsHooks', () => { + const newPolicy: NewPackagePolicy = { + name: '', + description: '', + namespace: 'default', + policy_id: 'ae774160-8e49-11eb-aba5-99269d21ba6e', + enabled: true, + output_id: '', + inputs: [ + { + type: 'synthetics/http', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { + type: 'synthetics', + dataset: 'http', + }, + vars: { + type: { + value: 'http', + type: 'text', + }, + name: { + value: '', + type: 'text', + }, + schedule: { + value: '"@every 3m"', + type: 'text', + }, + urls: { + value: '', + type: 'text', + }, + 'service.name': { + value: '', + type: 'text', + }, + timeout: { + value: '16s', + type: 'text', + }, + max_redirects: { + value: 0, + type: 'integer', + }, + proxy_url: { + value: '', + type: 'text', + }, + tags: { + value: '[]', + type: 'yaml', + }, + 'response.include_headers': { + value: true, + type: 'bool', + }, + 'response.include_body': { + value: 'on_error', + type: 'text', + }, + 'check.request.method': { + value: 'GET', + type: 'text', + }, + 'check.request.headers': { + value: '{}', + type: 'yaml', + }, + 'check.request.body': { + value: '""', + type: 'yaml', + }, + 'check.response.status': { + value: '[]', + type: 'yaml', + }, + 'check.response.headers': { + value: '{}', + type: 'yaml', + }, + 'check.response.body.positive': { + value: null, + type: 'yaml', + }, + 'check.response.body.negative': { + value: null, + type: 'yaml', + }, + 'ssl.certificate_authorities': { + value: '', + type: 'yaml', + }, + 'ssl.certificate': { + value: '', + type: 'yaml', + }, + 'ssl.key': { + value: '', + type: 'yaml', + }, + 'ssl.key_passphrase': { + type: 'text', + }, + 'ssl.verification_mode': { + value: 'full', + type: 'text', + }, + 'ssl.supported_protocols': { + value: '', + type: 'yaml', + }, + }, + }, + ], + }, + { + type: 'synthetics/tcp', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'tcp', + }, + vars: { + type: { + value: 'tcp', + type: 'text', + }, + name: { + type: 'text', + }, + schedule: { + value: '10s', + type: 'text', + }, + hosts: { + type: 'text', + }, + 'service.name': { + type: 'text', + }, + timeout: { + type: 'integer', + }, + max_redirects: { + type: 'integer', + }, + proxy_url: { + type: 'text', + }, + proxy_use_local_resolver: { + value: false, + type: 'bool', + }, + tags: { + type: 'yaml', + }, + 'check.send': { + type: 'text', + }, + 'check.receive': { + type: 'yaml', + }, + 'ssl.certificate_authorities': { + type: 'yaml', + }, + 'ssl.certificate': { + type: 'yaml', + }, + 'ssl.key': { + type: 'yaml', + }, + 'ssl.key_passphrase': { + type: 'text', + }, + 'ssl.verification_mode': { + type: 'text', + }, + }, + }, + ], + }, + { + type: 'synthetics/icmp', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'icmp', + }, + vars: { + type: { + value: 'icmp', + type: 'text', + }, + name: { + type: 'text', + }, + schedule: { + value: '10s', + type: 'text', + }, + wait: { + value: '1s', + type: 'text', + }, + hosts: { + type: 'text', + }, + 'service.name': { + type: 'text', + }, + timeout: { + type: 'integer', + }, + max_redirects: { + type: 'integer', + }, + tags: { + type: 'yaml', + }, + }, + }, + ], + }, + ], + package: { + name: 'synthetics', + title: 'Elastic Synthetics', + version: '0.66.0', + }, + }; + + it('handles http data stream', () => { + const onChange = jest.fn(); + const { result } = renderHook((props) => useUpdatePolicy(props), { + initialProps: { defaultConfig, newPolicy, onChange, validate }, + }); + + expect(result.current.config).toMatchObject({ ...defaultConfig }); + + // expect only http to be enabled + expect(result.current.updatedPolicy.inputs[0].enabled).toBe(true); + expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.MONITOR_TYPE].value + ).toEqual(defaultConfig[ConfigKeys.MONITOR_TYPE]); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.URLS].value + ).toEqual(defaultConfig[ConfigKeys.URLS]); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.SCHEDULE].value + ).toEqual( + JSON.stringify( + `@every ${defaultConfig[ConfigKeys.SCHEDULE].number}${ + defaultConfig[ConfigKeys.SCHEDULE].unit + }` + ) + ); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.PROXY_URL].value + ).toEqual(defaultConfig[ConfigKeys.PROXY_URL]); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.APM_SERVICE_NAME].value + ).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TIMEOUT].value + ).toEqual(`${defaultConfig[ConfigKeys.TIMEOUT]}s`); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ + ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE + ].value + ).toEqual(null); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ + ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE + ].value + ).toEqual(null); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_STATUS_CHECK] + .value + ).toEqual(JSON.stringify(defaultConfig[ConfigKeys.RESPONSE_STATUS_CHECK])); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.REQUEST_HEADERS_CHECK] + .value + ).toEqual(JSON.stringify(defaultConfig[ConfigKeys.REQUEST_HEADERS_CHECK])); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_HEADERS_CHECK] + .value + ).toEqual(JSON.stringify(defaultConfig[ConfigKeys.RESPONSE_HEADERS_CHECK])); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_BODY_INDEX] + .value + ).toEqual(defaultConfig[ConfigKeys.RESPONSE_BODY_INDEX]); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_HEADERS_INDEX] + .value + ).toEqual(defaultConfig[ConfigKeys.RESPONSE_HEADERS_INDEX]); + }); + + it('stringifies array values and returns null for empty array values', () => { + const onChange = jest.fn(); + const { result } = renderHook((props) => useUpdatePolicy(props), { + initialProps: { defaultConfig, newPolicy, onChange, validate }, + }); + + act(() => { + result.current.setConfig({ + ...defaultConfig, + [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: ['test'], + [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: ['test'], + [ConfigKeys.RESPONSE_STATUS_CHECK]: ['test'], + [ConfigKeys.TAGS]: ['test'], + [ConfigKeys.TLS_VERSION]: { + value: [TLSVersion.ONE_ONE], + isEnabled: true, + }, + }); + }); + + // expect only http to be enabled + expect(result.current.updatedPolicy.inputs[0].enabled).toBe(true); + expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ + ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE + ].value + ).toEqual('["test"]'); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ + ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE + ].value + ).toEqual('["test"]'); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_STATUS_CHECK] + .value + ).toEqual('["test"]'); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TAGS].value + ).toEqual('["test"]'); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TLS_VERSION].value + ).toEqual('["TLSv1.1"]'); + + act(() => { + result.current.setConfig({ + ...defaultConfig, + [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: [], + [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: [], + [ConfigKeys.RESPONSE_STATUS_CHECK]: [], + [ConfigKeys.TAGS]: [], + [ConfigKeys.TLS_VERSION]: { + value: [], + isEnabled: true, + }, + }); + }); + + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ + ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE + ].value + ).toEqual(null); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ + ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE + ].value + ).toEqual(null); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_STATUS_CHECK] + .value + ).toEqual(null); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TAGS].value + ).toEqual(null); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TLS_VERSION].value + ).toEqual(null); + }); + + it('handles tcp data stream', () => { + const onChange = jest.fn(); + const tcpConfig = { + ...defaultConfig, + [ConfigKeys.MONITOR_TYPE]: DataStream.TCP, + }; + const { result } = renderHook((props) => useUpdatePolicy(props), { + initialProps: { defaultConfig, newPolicy, onChange, validate }, + }); + + act(() => { + result.current.setConfig(tcpConfig); + }); + + // expect only tcp to be enabled + expect(result.current.updatedPolicy.inputs[0].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[1].enabled).toBe(true); + expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + + expect(onChange).toBeCalledWith({ + isValid: false, + updatedPolicy: result.current.updatedPolicy, + }); + + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.MONITOR_TYPE].value + ).toEqual(tcpConfig[ConfigKeys.MONITOR_TYPE]); + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.HOSTS].value + ).toEqual(defaultConfig[ConfigKeys.HOSTS]); + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.SCHEDULE].value + ).toEqual( + JSON.stringify( + `@every ${defaultConfig[ConfigKeys.SCHEDULE].number}${ + defaultConfig[ConfigKeys.SCHEDULE].unit + }` + ) + ); + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.PROXY_URL].value + ).toEqual(tcpConfig[ConfigKeys.PROXY_URL]); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.APM_SERVICE_NAME].value + ).toEqual(tcpConfig[ConfigKeys.APM_SERVICE_NAME]); + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.TIMEOUT].value + ).toEqual(`${tcpConfig[ConfigKeys.TIMEOUT]}s`); + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ + ConfigKeys.PROXY_USE_LOCAL_RESOLVER + ].value + ).toEqual(tcpConfig[ConfigKeys.PROXY_USE_LOCAL_RESOLVER]); + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_RECEIVE_CHECK] + .value + ).toEqual(tcpConfig[ConfigKeys.RESPONSE_RECEIVE_CHECK]); + expect( + result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.REQUEST_SEND_CHECK] + .value + ).toEqual(tcpConfig[ConfigKeys.REQUEST_SEND_CHECK]); + }); + + it('handles icmp data stream', () => { + const onChange = jest.fn(); + const icmpConfig = { + ...defaultConfig, + [ConfigKeys.MONITOR_TYPE]: DataStream.ICMP, + }; + const { result } = renderHook((props) => useUpdatePolicy(props), { + initialProps: { defaultConfig, newPolicy, onChange, validate }, + }); + + act(() => { + result.current.setConfig(icmpConfig); + }); + + // expect only icmp to be enabled + expect(result.current.updatedPolicy.inputs[0].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[2].enabled).toBe(true); + + expect(onChange).toBeCalledWith({ + isValid: false, + updatedPolicy: result.current.updatedPolicy, + }); + + expect( + result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.MONITOR_TYPE].value + ).toEqual(icmpConfig[ConfigKeys.MONITOR_TYPE]); + expect( + result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.HOSTS].value + ).toEqual(icmpConfig[ConfigKeys.HOSTS]); + expect( + result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.SCHEDULE].value + ).toEqual( + JSON.stringify( + `@every ${icmpConfig[ConfigKeys.SCHEDULE].number}${icmpConfig[ConfigKeys.SCHEDULE].unit}` + ) + ); + expect( + result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.APM_SERVICE_NAME].value + ).toEqual(defaultConfig[ConfigKeys.APM_SERVICE_NAME]); + expect( + result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.TIMEOUT].value + ).toEqual(`${icmpConfig[ConfigKeys.TIMEOUT]}s`); + expect( + result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.WAIT].value + ).toEqual(`${icmpConfig[ConfigKeys.WAIT]}s`); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts new file mode 100644 index 0000000000000..cb11e9f9c4a9b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts @@ -0,0 +1,119 @@ +/* + * 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, useRef, useState } from 'react'; +import { NewPackagePolicy } from '../../../../fleet/public'; +import { ConfigKeys, Config, DataStream, Validation } from './types'; + +interface Props { + defaultConfig: Config; + newPolicy: NewPackagePolicy; + onChange: (opts: { + /** is current form state is valid */ + isValid: boolean; + /** The updated Integration Policy to be merged back and included in the API call */ + updatedPolicy: NewPackagePolicy; + }) => void; + validate: Record; +} + +export const useUpdatePolicy = ({ defaultConfig, newPolicy, onChange, validate }: Props) => { + const [updatedPolicy, setUpdatedPolicy] = useState(newPolicy); + // Update the integration policy with our custom fields + const [config, setConfig] = useState(defaultConfig); + const currentConfig = useRef(defaultConfig); + + useEffect(() => { + const { type } = config; + const configKeys = Object.keys(config) as ConfigKeys[]; + const validationKeys = Object.keys(validate[type]) as ConfigKeys[]; + const configDidUpdate = configKeys.some((key) => config[key] !== currentConfig.current[key]); + const isValid = + !!newPolicy.name && !validationKeys.find((key) => validate[type][key]?.(config[key])); + const formattedPolicy = { ...newPolicy }; + const currentInput = formattedPolicy.inputs.find( + (input) => input.type === `synthetics/${type}` + ); + const dataStream = currentInput?.streams[0]; + + // prevent an infinite loop of updating the policy + if (currentInput && dataStream && configDidUpdate) { + // reset all data streams to enabled false + formattedPolicy.inputs.forEach((input) => (input.enabled = false)); + // enable only the input type and data stream that matches the monitor type. + currentInput.enabled = true; + dataStream.enabled = true; + configKeys.forEach((key) => { + const configItem = dataStream.vars?.[key]; + if (configItem) { + switch (key) { + case ConfigKeys.SCHEDULE: + configItem.value = JSON.stringify(`@every ${config[key].number}${config[key].unit}`); // convert to cron + break; + case ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE: + case ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE: + case ConfigKeys.RESPONSE_STATUS_CHECK: + case ConfigKeys.TAGS: + configItem.value = config[key].length ? JSON.stringify(config[key]) : null; + break; + case ConfigKeys.RESPONSE_HEADERS_CHECK: + case ConfigKeys.REQUEST_HEADERS_CHECK: + configItem.value = Object.keys(config[key]).length + ? JSON.stringify(config[key]) + : null; + break; + case ConfigKeys.TIMEOUT: + case ConfigKeys.WAIT: + configItem.value = config[key] ? `${config[key]}s` : null; // convert to cron + break; + case ConfigKeys.REQUEST_BODY_CHECK: + configItem.value = config[key].value ? JSON.stringify(config[key].value) : null; // only need value of REQUEST_BODY_CHECK for outputted policy + break; + case ConfigKeys.TLS_CERTIFICATE: + case ConfigKeys.TLS_CERTIFICATE_AUTHORITIES: + case ConfigKeys.TLS_KEY: + configItem.value = + config[key].isEnabled && config[key].value + ? JSON.stringify(config[key].value) + : null; // only add tls settings if they are enabled by the user + break; + case ConfigKeys.TLS_VERSION: + configItem.value = + config[key].isEnabled && config[key].value.length + ? JSON.stringify(config[key].value) + : null; // only add tls settings if they are enabled by the user + break; + case ConfigKeys.TLS_KEY_PASSPHRASE: + case ConfigKeys.TLS_VERIFICATION_MODE: + configItem.value = + config[key].isEnabled && config[key].value ? config[key].value : null; // only add tls settings if they are enabled by the user + break; + default: + configItem.value = + config[key] === undefined || config[key] === null ? null : config[key]; + } + } + }); + currentConfig.current = config; + setUpdatedPolicy(formattedPolicy); + onChange({ + isValid, + updatedPolicy: formattedPolicy, + }); + } + }, [config, currentConfig, newPolicy, onChange, validate]); + + // update our local config state ever time name, which is managed by fleet, changes + useEffect(() => { + setConfig((prevConfig) => ({ ...prevConfig, name: newPolicy.name })); + }, [newPolicy.name, setConfig]); + + return { + config, + setConfig, + updatedPolicy, + }; +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/validation.tsx b/x-pack/plugins/uptime/public/components/fleet_package/validation.tsx new file mode 100644 index 0000000000000..5197cb9299e45 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/validation.tsx @@ -0,0 +1,113 @@ +/* + * 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 { ConfigKeys, DataStream, ICustomFields, Validation, ScheduleUnit } from './types'; + +export const digitsOnly = /^[0-9]*$/g; +export const includesValidPort = /[^\:]+:[0-9]{1,5}$/g; + +// returns true if invalid +function validateHeaders(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 +function 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 = { + [ConfigKeys.MAX_REDIRECTS]: (value: unknown) => + (!!value && !`${value}`.match(digitsOnly)) || + parseFloat(value as ICustomFields[ConfigKeys.MAX_REDIRECTS]) < 0, + [ConfigKeys.MONITOR_TYPE]: (value: unknown) => !value, + [ConfigKeys.SCHEDULE]: (value: unknown) => { + const { number, unit } = value as ICustomFields[ConfigKeys.SCHEDULE]; + const parsedFloat = parseFloat(number); + return !parsedFloat || !unit || parsedFloat < 1; + }, + [ConfigKeys.TIMEOUT]: ( + timeoutValue: unknown, + scheduleNumber: string, + scheduleUnit: ScheduleUnit + ) => + !timeoutValue || + parseFloat(timeoutValue as ICustomFields[ConfigKeys.TIMEOUT]) < 0 || + validateTimeout({ + timeout: timeoutValue as ICustomFields[ConfigKeys.TIMEOUT], + scheduleNumber, + scheduleUnit, + }), +}; + +const validateHTTP = { + [ConfigKeys.RESPONSE_STATUS_CHECK]: (value: unknown) => { + const statusCodes = value as ICustomFields[ConfigKeys.RESPONSE_STATUS_CHECK]; + return statusCodes.length ? statusCodes.some((code) => !`${code}`.match(digitsOnly)) : false; + }, + [ConfigKeys.RESPONSE_HEADERS_CHECK]: (value: unknown) => { + const headers = value as ICustomFields[ConfigKeys.RESPONSE_HEADERS_CHECK]; + return validateHeaders(headers); + }, + [ConfigKeys.REQUEST_HEADERS_CHECK]: (value: unknown) => { + const headers = value as ICustomFields[ConfigKeys.REQUEST_HEADERS_CHECK]; + return validateHeaders(headers); + }, + [ConfigKeys.URLS]: (value: unknown) => !value, + ...validateCommon, +}; + +const validateTCP = { + [ConfigKeys.HOSTS]: (value: unknown) => { + return !value || !`${value}`.match(includesValidPort); + }, + ...validateCommon, +}; + +const validateICMP = { + [ConfigKeys.HOSTS]: (value: unknown) => !value, + [ConfigKeys.WAIT]: (value: unknown) => + !!value && + !digitsOnly.test(`${value}`) && + parseFloat(value as ICustomFields[ConfigKeys.WAIT]) < 0, + ...validateCommon, +}; + +export type ValidateDictionary = Record; + +export const validate: ValidateDictionary = { + [DataStream.HTTP]: validateHTTP, + [DataStream.TCP]: validateTCP, + [DataStream.ICMP]: validateICMP, +}; diff --git a/x-pack/plugins/uptime/tsconfig.json b/x-pack/plugins/uptime/tsconfig.json index 531ee2ecd8d2b..88099b57f0898 100644 --- a/x-pack/plugins/uptime/tsconfig.json +++ b/x-pack/plugins/uptime/tsconfig.json @@ -16,9 +16,20 @@ "../../../typings/**/*" ], "references": [ - { "path": "../alerting/tsconfig.json" }, - { "path": "../ml/tsconfig.json" }, - { "path": "../triggers_actions_ui/tsconfig.json" }, - { "path": "../observability/tsconfig.json" } + { + "path": "../alerting/tsconfig.json" + }, + { + "path": "../ml/tsconfig.json" + }, + { + "path": "../triggers_actions_ui/tsconfig.json" + }, + { + "path": "../observability/tsconfig.json" + }, + { + "path": "../fleet/tsconfig.json" + } ] -} +} \ No newline at end of file